diff --git a/AGENTS.md b/AGENTS.md index 65caaf15..ab6b584a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ by `./sync-agents`. `AGENTS.md` says what it is and how it may be used. - Add SPM library targets in `Package.swift` and wire apps/tests in `Project.swift` (see existing `unitTests` helper). A new module also ships a root `README.md` and `AGENTS.md` — see [Per-module docs](#per-module-docs). - **CI scheme**: CI runs the explicit shared **Stuff-iOS-Tests** scheme (all test bundles) rather than the autogenerated `Stuff-Workspace` scheme. New test bundles must be added to the `Stuff-iOS-Tests` scheme in `Project.swift` or CI won't run them. -- **Never double-link a package product into a test bundle that already gets it through a dynamic-framework dependency.** Xcode's default SPM integration (how `Package.local` is wired here) embeds a product's code into *every* image that links it. **WhereUI** is a dynamic framework that statically embeds its own dependencies (WhereCore, BroadwayCore/BroadwayUI, LifecycleKit, LogViewerUI, SwiftDataInspector, …), so a `*Tests` bundle that depends on **WhereUI** *and* re-lists one of those in `extraPackageProducts` ends up with a **second copy** of it. When the shared **StuffTestHost** loads several `.xctest` bundles into one process, that leaves duplicate **type metadata** for the module, and any *type-keyed runtime lookup that crosses the WhereUI boundary* — SwiftUI `EnvironmentKey`s, `UITraitBridgedEnvironmentKey` bridging, the type-keyed `BTraits`/`BThemes`/`BStylesheets` containers — silently resolves against the wrong copy and returns the default (the writer stores under one copy's key *type*, the reader looks it up under another's). It only reproduces in the **full multi-bundle scheme** (not isolated `tuist test WhereUITests` runs) and is papered over by newer Xcode linkers, so it is brutal to diagnose (it cost ~2 hours once). **Depend on such products only transitively via `WhereUI`; keep them out of `extraPackageProducts`.** `WhereStylesheetTests.resolvesTraitAwareTokensFromTheBroadwayRoot` is the regression guard — it exercises a `\.stylesheet` (→ `\.bContext`) read across the boundary and fails if a duplicate copy returns. +- **Never double-link a package product into a test bundle that already gets it through a dynamic-framework dependency.** Xcode's default SPM integration (how `Package.local` is wired here) embeds a product's code into *every* image that links it. **WhereUI** is a dynamic framework that statically embeds its own dependencies (WhereCore, BroadwayCore/BroadwayUI, LifecycleKit, PeriscopeCore/PeriscopeUI/PeriscopeTools, SwiftDataInspector, …), so a `*Tests` bundle that depends on **WhereUI** *and* re-lists one of those in `extraPackageProducts` ends up with a **second copy** of it. When the shared **StuffTestHost** loads several `.xctest` bundles into one process, that leaves duplicate **type metadata** for the module, and any *type-keyed runtime lookup that crosses the WhereUI boundary* — SwiftUI `EnvironmentKey`s, `UITraitBridgedEnvironmentKey` bridging, the type-keyed `BTraits`/`BThemes`/`BStylesheets` containers — silently resolves against the wrong copy and returns the default (the writer stores under one copy's key *type*, the reader looks it up under another's). It only reproduces in the **full multi-bundle scheme** (not isolated `tuist test WhereUITests` runs) and is papered over by newer Xcode linkers, so it is brutal to diagnose (it cost ~2 hours once). **Depend on such products only transitively via `WhereUI`; keep them out of `extraPackageProducts`.** `WhereStylesheetTests.resolvesTraitAwareTokensFromTheBroadwayRoot` is the regression guard — it exercises a `\.stylesheet` (→ `\.bContext`) read across the boundary and fails if a duplicate copy returns. ## Deployment @@ -212,7 +212,8 @@ the generated (gitignored) `CLAUDE.md` is produced next to it. (or returning a `Result`/typed error) — never absorb it into a benign-looking default like `[]`, `nil`, or `false`. Don't discard errors with `try?` or an empty `catch {}` that hides the failure: at minimum a `catch` must log - (`WhereLog.warning`/`error`) *and* leave observable state honest (preserve the + (a `warning`/`error` on the relevant `WhereLog` scope, ideally a typed + `LogEvent` carrying a `LogAttachment.error`) *and* leave observable state honest (preserve the last good value or move to a `failed` state — not a default that reads as success, e.g. an empty list rendering as "all clear"). Callers decide *how* to react (rethrow, log + keep state, set a `failed` case), but the failure must diff --git a/Package.swift b/Package.swift index 119946f9..adadb7a1 100644 --- a/Package.swift +++ b/Package.swift @@ -11,8 +11,6 @@ let package = Package( .library(name: "StuffCore", targets: ["StuffCore"]), .library(name: "LifecycleKit", targets: ["LifecycleKit"]), .library(name: "JournalKit", targets: ["JournalKit"]), - .library(name: "LogKit", targets: ["LogKit"]), - .library(name: "LogViewerUI", targets: ["LogViewerUI"]), .library(name: "PeriscopeCore", targets: ["PeriscopeCore"]), .library(name: "PeriscopeUI", targets: ["PeriscopeUI"]), .library(name: "PeriscopeTools", targets: ["PeriscopeTools"]), @@ -44,17 +42,6 @@ let package = Package( name: "JournalKit", path: "Shared/JournalKit/Sources", ), - .target( - name: "LogKit", - path: "Shared/LogKit/Sources", - ), - .target( - name: "LogViewerUI", - dependencies: [ - .target(name: "LogKit"), - ], - path: "Shared/LogViewerUI/Sources", - ), .target( name: "PeriscopeCore", dependencies: [ @@ -88,7 +75,7 @@ let package = Package( .target( name: "RegionKit", dependencies: [ - .target(name: "LogKit"), + .target(name: "PeriscopeCore"), ], path: "Where/RegionKit/Sources", resources: [ @@ -98,7 +85,7 @@ let package = Package( .target( name: "WhereCore", dependencies: [ - .target(name: "LogKit"), + .target(name: "PeriscopeCore"), .target(name: "RegionKit"), .product(name: "ZIPFoundation", package: "ZIPFoundation"), ], @@ -114,8 +101,9 @@ let package = Package( .target(name: "BroadwayCore"), .target(name: "BroadwayUI"), .target(name: "LifecycleKit"), - .target(name: "LogKit"), - .target(name: "LogViewerUI"), + .target(name: "PeriscopeCore"), + .target(name: "PeriscopeTools"), + .target(name: "PeriscopeUI"), .target(name: "RegionKit"), .target(name: "SwiftDataInspector"), ], @@ -127,7 +115,7 @@ let package = Package( .target( name: "WhereIntents", dependencies: [ - .target(name: "LogKit"), + .target(name: "PeriscopeCore"), .target(name: "RegionKit"), .target(name: "WhereCore"), .target(name: "WhereUI"), diff --git a/Project.swift b/Project.swift index 1fa30eee..97f39ba1 100644 --- a/Project.swift +++ b/Project.swift @@ -93,7 +93,6 @@ let project = Project( entitlements: whereAppGroupEntitlements, dependencies: [ .package(product: "LifecycleKit"), - .package(product: "LogKit"), .package(product: "RegionKit"), .package(product: "WhereCore"), .package(product: "WhereUI"), @@ -129,7 +128,7 @@ let project = Project( resources: ["Where/WhereWidgets/Resources/**"], entitlements: whereAppGroupEntitlements, dependencies: [ - .package(product: "LogKit"), + .package(product: "PeriscopeCore"), .package(product: "RegionKit"), .package(product: "WhereCore"), .package(product: "WhereUI"), @@ -165,7 +164,7 @@ let project = Project( resources: ["Where/WhereShareExtension/Resources/**"], entitlements: whereAppGroupEntitlements, dependencies: [ - .package(product: "LogKit"), + .package(product: "PeriscopeCore"), .package(product: "WhereCore"), .package(product: "WhereUI"), ], @@ -189,7 +188,6 @@ let project = Project( // No App Group entitlement — the viewer only reads bundled GeoJSON // (embedded via the RegionKit dependency), never the app's store. dependencies: [ - .package(product: "LogKit"), .package(product: "RegionKit"), .package(product: "WhereCore"), .package(product: "WhereUI"), @@ -269,18 +267,6 @@ let project = Project( productDependency: "LifecycleKit", sources: ["Shared/LifecycleKit/Tests/**"], ), - unitTests( - name: "LogKitTests", - bundleIdSuffix: "logkit", - productDependency: "LogKit", - sources: ["Shared/LogKit/Tests/**"], - ), - unitTests( - name: "LogViewerUITests", - bundleIdSuffix: "logviewerui", - productDependency: "LogViewerUI", - sources: ["Shared/LogViewerUI/Tests/**"], - ), unitTests( name: "JournalKitTests", bundleIdSuffix: "journalkit", @@ -340,8 +326,9 @@ let project = Project( // BTraits/BThemes/BStylesheets containers) then silently resolves against // the wrong copy — the writer stores under one copy's key type, the // reader looks it up under another's. Everything the tests need - // (BroadwayCore/BroadwayUI, LifecycleKit, LogViewerUI, SwiftDataInspector, - // RegionKit + its GeoJSON bundle) is reached transitively through WhereUI. + // (BroadwayCore/BroadwayUI, LifecycleKit, PeriscopeCore/UI/Tools, + // SwiftDataInspector, RegionKit + its GeoJSON bundle) is reached + // transitively through WhereUI. // See the root AGENTS.md "Targets" note. unitTests( name: "WhereUITests", @@ -432,8 +419,6 @@ let project = Project( "StuffTestHost", "StuffCoreTests", "LifecycleKitTests", - "LogKitTests", - "LogViewerUITests", "JournalKitTests", "PeriscopeCoreTests", "PeriscopeUITests", @@ -452,8 +437,6 @@ let project = Project( testAction: .targets([ "StuffCoreTests", "LifecycleKitTests", - "LogKitTests", - "LogViewerUITests", "JournalKitTests", "PeriscopeCoreTests", "PeriscopeUITests", @@ -471,8 +454,6 @@ let project = Project( ), testScheme(name: "StuffCoreTests"), testScheme(name: "LifecycleKitTests"), - testScheme(name: "LogKitTests"), - testScheme(name: "LogViewerUITests"), testScheme(name: "JournalKitTests"), testScheme(name: "PeriscopeCoreTests"), testScheme(name: "PeriscopeUITests"), diff --git a/Shared/LogKit/AGENTS.md b/Shared/LogKit/AGENTS.md deleted file mode 100644 index fec110b4..00000000 --- a/Shared/LogKit/AGENTS.md +++ /dev/null @@ -1,38 +0,0 @@ -# LogKit – Module Shape - -LogKit is a logging **facade**: a `LogChannel` fans each call out to Apple -unified logging (`os.Logger`) and, in DEBUG builds, to an in-memory `LogStore` -ring buffer that an in-app viewer reads. See [`README.md`](README.md) for the -narrative and API. - -This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns the -build system, formatting, and global conventions. Read that first. - -## Scope & dependencies - -- Pure **Foundation + os**. It must **not** import SwiftUI, UIKit, WhereCore, - or any app code — the SwiftUI viewer lives in - [`LogViewerUI`](../LogViewerUI), and app-specific wiring (shared store, - typed categories) lives in the consuming facade (e.g. `WhereLog`). - -## Invariants - -- **Two sinks, one call.** `LogChannel` always calls `os.Logger`; the - `LogStore` write is `#if DEBUG` only, so release builds never retain log - text in memory. App call sites log through `LogChannel`, not direct - `LogStore.record`. -- **Recording never hops actors** — `LogStore` is lock-guarded so any thread - can `record` synchronously; observers get snapshots via self-unregistering - `changes()` streams. -- **`warning` maps to `OSLogType.default`**, not `.error` — intentional, so - warnings don't inflate Console error-level queries. `LogLevel` case order - *is* severity order (`Comparable` by `rawValue`); keep it intact. -- **The privacy trade-off is deliberate.** `LogChannel` takes an - already-rendered `String` logged as `.public` (that's what lets the buffer - capture text). The contract is PII-free messages; don't add - `privacy:`-style APIs or start logging user content to "fix" it. - -## Testing - -Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost`. Each test uses -its own fresh `LogStore` (the production shared store is process-global). diff --git a/Shared/LogKit/README.md b/Shared/LogKit/README.md deleted file mode 100644 index 2b85f078..00000000 --- a/Shared/LogKit/README.md +++ /dev/null @@ -1,114 +0,0 @@ -# LogKit - -A tiny logging facade that fans a single call out to **Apple unified logging** -(`os.Logger`, for Console.app) and, in **DEBUG builds**, an **in-memory ring -buffer** an in-app log viewer can read. Get a channel, call `info` / `warning` / -`error`, and the line shows up both in Console and (in DEBUG) in a process-wide -buffer. - -LogKit depends only on **Foundation + os** — no app code, no UI. The SwiftUI -viewer that renders the buffer lives in a separate module, -[`LogViewerUI`](../LogViewerUI). - -## What you get - -- **One call, two sinks** — every message goes to `os.Logger` (all builds) and, - in DEBUG, into a `LogStore` buffer. Release builds pay only the `os` cost; the - buffer is compiled out at the call site. -- **A typed severity ladder** — `LogLevel` (`debug` → `info` → `notice` → - `warning` → `error` → `fault`), `Comparable` by severity, each mapped to an - `OSLogType`. -- **A bounded, thread-safe buffer** — `LogStore` is a `Sendable` ring buffer - (default capacity 1000) that records from any thread and streams snapshots to - observers via `AsyncStream`. - -## Installation - -`LogKit` is a local SPM library in this repo (`Shared/LogKit`). Add it to a -target's dependencies in [`Package.swift`](../../Package.swift): - -```swift -.target(name: "YourModule", dependencies: [.target(name: "LogKit")]) -``` - -## Quick start - -Build a `LogStore` you keep for the process, then make a `LogChannel` per -category pointed at it: - -```swift -import LogKit - -let store = LogStore() -let channel = LogChannel(subsystem: "com.example.app", category: "Networking", store: store) - -channel.info("Request succeeded") -channel.warning("Falling back to cache") -channel.error("Request failed: \(error.localizedDescription)") -``` - -Most apps don't pass the store around by hand — they wrap this in a small facade -that owns the shared store and a typed category enum (see the Where app's -`WhereLog` in `WhereCore`). - -## Public API - -```swift -public enum LogLevel: Int, Sendable, Comparable, CaseIterable, Codable { - case debug, info, notice, warning, error, fault - public var osLogType: OSLogType { /* debug/info/default/default/error/fault */ } -} - -public struct LogEntry: Sendable, Identifiable, Hashable { - public let id: UUID, date: Date, level: LogLevel - public let subsystem: String, category: String, message: String -} - -public final class LogStore: Sendable { - public init(capacity: Int = 1000) - /// Append an entry. Prefer ``LogChannel`` in app code — it only writes to - /// the store in DEBUG builds; direct `record` always retains text. - public func record(_ entry: LogEntry) - public func snapshot() -> [LogEntry] // oldest first - public func clear() - public func changes() -> AsyncStream<[LogEntry]> // current snapshot, then one per change -} - -public struct LogChannel: Sendable { - public init(subsystem: String, category: String, store: LogStore? = nil) - public func debug/info/notice/warning/error/fault(_ message: @autoclosure () -> String) -} -``` - -## How it works - -`LogChannel.emit` does two things: it calls `os.Logger.log(level:)` (always), -then — `#if DEBUG` only — appends a `LogEntry` to its `LogStore`. The store -guards its state with an `OSAllocatedUnfairLock` (so `record` never hops to the -main actor) and notifies observers by yielding a fresh snapshot into each -registered `AsyncStream`; the initial snapshot is yielded before registering the -observer. Observers are unregistered automatically when their stream's consumer -cancels. Past `capacity`, the oldest entries are evicted. - -Direct `LogStore.record` is intended for tests, previews, and the -`LogChannel` facade — app call sites should log through `LogChannel`, which only -writes to the buffer in DEBUG builds. - -## The privacy trade-off - -`LogChannel` takes an **already-rendered `String`**, not an `os` interpolation. -That's what lets it capture the text for the buffer, but it means per-argument -`os` privacy annotations (`privacy: .public` / `.private`) aren't available — the -whole message is logged as `.public`. **Keep PII out of log messages**; use this -for operational diagnostics only. - -`warning` has no dedicated `os` level, so it maps to `OSLogType.default` (same as -`notice`). It reads as a distinct level in the in-app viewer without inflating -Console's error-level queries. - -## Testing - -Swift Testing in a hosted bundle (`LogKitTests`). Drive a `LogChannel` backed by -a fresh `LogStore` and assert on `snapshot()` (level/message ordering), the -capacity eviction, `clear()`, and that `changes()` yields the initial snapshot -then one per `record`/`clear`. diff --git a/Shared/LogKit/Sources/LogChannel.swift b/Shared/LogKit/Sources/LogChannel.swift deleted file mode 100644 index 7acff9c5..00000000 --- a/Shared/LogKit/Sources/LogChannel.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation -import os - -/// A logging facade that fans a single call out to both Apple unified logging -/// (`os.Logger`, for Console.app) and — in DEBUG builds — an in-memory -/// ``LogStore`` the in-app viewer reads. -/// -/// Messages are passed as already-rendered `String`s rather than `os` -/// interpolations. That is a deliberate trade-off: it lets us capture the text -/// for the buffer, but means per-argument `os` privacy (`privacy: .public` / -/// `.private`) is not available — the whole message is logged as `.public`. -/// Use this for operational diagnostics, not for anything carrying PII. -public struct LogChannel: Sendable { - private let logger: Logger - private let subsystem: String - private let category: String - private let store: LogStore? - - public init(subsystem: String, category: String, store: LogStore? = nil) { - logger = Logger(subsystem: subsystem, category: category) - self.subsystem = subsystem - self.category = category - self.store = store - } - - public func debug(_ message: @autoclosure () -> String) { - emit(.debug, message()) - } - - public func info(_ message: @autoclosure () -> String) { - emit(.info, message()) - } - - public func notice(_ message: @autoclosure () -> String) { - emit(.notice, message()) - } - - public func warning(_ message: @autoclosure () -> String) { - emit(.warning, message()) - } - - public func error(_ message: @autoclosure () -> String) { - emit(.error, message()) - } - - public func fault(_ message: @autoclosure () -> String) { - emit(.fault, message()) - } - - private func emit(_ level: LogLevel, _ message: String) { - logger.log(level: level.osLogType, "\(message, privacy: .public)") - #if DEBUG - store?.record(LogEntry( - level: level, - subsystem: subsystem, - category: category, - message: message, - )) - #endif - } -} diff --git a/Shared/LogKit/Sources/LogEntry.swift b/Shared/LogKit/Sources/LogEntry.swift deleted file mode 100644 index 47f78a5c..00000000 --- a/Shared/LogKit/Sources/LogEntry.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation - -/// A single captured log line. The facade builds one per call and appends it to -/// a ``LogStore``; the viewer renders these. `message` is already-rendered text -/// (see ``LogChannel`` for the privacy trade-off that implies). -public struct LogEntry: Sendable, Identifiable, Hashable { - public let id: UUID - public let date: Date - public let level: LogLevel - public let subsystem: String - public let category: String - public let message: String - - public init( - id: UUID = UUID(), - date: Date = Date(), - level: LogLevel, - subsystem: String, - category: String, - message: String, - ) { - self.id = id - self.date = date - self.level = level - self.subsystem = subsystem - self.category = category - self.message = message - } -} diff --git a/Shared/LogKit/Sources/LogLevel.swift b/Shared/LogKit/Sources/LogLevel.swift deleted file mode 100644 index 2f671594..00000000 --- a/Shared/LogKit/Sources/LogLevel.swift +++ /dev/null @@ -1,34 +0,0 @@ -import os - -/// Severity of a captured log message, ordered from least to most severe so -/// callers can filter with comparisons (e.g. `entry.level >= .error`). The -/// cases mirror the levels `os.Logger` exposes; `osLogType` maps each back to -/// the underlying `OSLogType` the facade emits. -public enum LogLevel: Int, Sendable, Comparable, CaseIterable, Codable { - case debug - case info - case notice - case warning - case error - case fault - - public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { - lhs.rawValue < rhs.rawValue - } - - /// The `OSLogType` the facade logs this level as. `notice` is unified - /// logging's default level, so it maps to `.default`. Apple's unified - /// logging has no dedicated warning level, so `warning` also maps to - /// `.default` (the in-app viewer still shows it as a distinct level); - /// keeping it off `.error` avoids inflating Console error-level queries. - public var osLogType: OSLogType { - switch self { - case .debug: .debug - case .info: .info - case .notice: .default - case .warning: .default - case .error: .error - case .fault: .fault - } - } -} diff --git a/Shared/LogKit/Sources/LogStore.swift b/Shared/LogKit/Sources/LogStore.swift deleted file mode 100644 index 4aeabde2..00000000 --- a/Shared/LogKit/Sources/LogStore.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation -import os - -/// A thread-safe, bounded in-memory ring buffer of ``LogEntry`` values, shared -/// between the logging facade (which records into it from any thread/actor) and -/// the viewer (which reads snapshots and observes changes on the main actor). -/// -/// Recording never hops to the main actor: state is guarded by an -/// `OSAllocatedUnfairLock`, and observers are notified through `AsyncStream`s -/// that carry a fresh snapshot. Once `capacity` is reached the oldest entries -/// are evicted. -public final class LogStore: Sendable { - private struct State { - var entries: [LogEntry] = [] - var observers: [UUID: AsyncStream<[LogEntry]>.Continuation] = [:] - } - - /// Maximum number of entries retained; older entries are dropped past this. - public let capacity: Int - - private let state: OSAllocatedUnfairLock - - public init(capacity: Int = 1000) { - precondition(capacity > 0, "LogStore capacity must be positive") - self.capacity = capacity - state = OSAllocatedUnfairLock(initialState: State()) - } - - /// Append an entry, evicting the oldest if at capacity, and notify observers. - /// - /// Production logging should go through ``LogChannel``, which only writes to - /// the store in DEBUG builds. Direct `record` calls always retain text in - /// memory regardless of build configuration — use only from tests and - /// previews via `@_spi(Testing) import LogKit`. - @_spi(Testing) public func record(_ entry: LogEntry) { - let (snapshot, observers) = state.withLock { state -> ( - [LogEntry], - [AsyncStream<[LogEntry]>.Continuation] - ) in - state.entries.append(entry) - let overflow = state.entries.count - capacity - if overflow > 0 { - state.entries.removeFirst(overflow) - } - return (state.entries, Array(state.observers.values)) - } - for observer in observers { - observer.yield(snapshot) - } - } - - /// The current entries, oldest first. - public func snapshot() -> [LogEntry] { - state.withLock { $0.entries } - } - - /// Drop all buffered entries and notify observers with the empty snapshot. - public func clear() { - let observers = state.withLock { state -> [AsyncStream<[LogEntry]>.Continuation] in - state.entries.removeAll(keepingCapacity: true) - return Array(state.observers.values) - } - for observer in observers { - observer.yield([]) - } - } - - /// An async sequence of snapshots: yields the current buffer immediately, - /// then a fresh snapshot on every `record`/`clear`. The observer is - /// unregistered automatically when the stream's consumer cancels. - public func changes() -> AsyncStream<[LogEntry]> { - let id = UUID() - return AsyncStream<[LogEntry]> { continuation in - let initial = state.withLock(\.entries) - continuation.yield(initial) - state.withLock { state in - state.observers[id] = continuation - } - continuation.onTermination = { [weak self] _ in - self?.state.withLock { state in - state.observers[id] = nil - } - } - } - } -} diff --git a/Shared/LogKit/Tests/LogChannelTests.swift b/Shared/LogKit/Tests/LogChannelTests.swift deleted file mode 100644 index fe9ff937..00000000 --- a/Shared/LogKit/Tests/LogChannelTests.swift +++ /dev/null @@ -1,29 +0,0 @@ -@_spi(Testing) import LogKit -import Testing - -#if DEBUG - @Test - func channelRecordsEachLevelIntoStore() { - let store = LogStore() - let channel = LogChannel(subsystem: "com.test", category: "Sample", store: store) - - channel.debug("d") - channel.info("i") - channel.notice("n") - channel.warning("w") - channel.error("e") - channel.fault("f") - - let entries = store.snapshot() - #expect(entries.map(\.level) == [.debug, .info, .notice, .warning, .error, .fault]) - #expect(entries.map(\.message) == ["d", "i", "n", "w", "e", "f"]) - #expect(entries.allSatisfy { $0.subsystem == "com.test" && $0.category == "Sample" }) - } -#endif - -@Test -func channelWithoutStoreStillLogs() { - // No store attached: should not crash, just emit to os.Logger. - let channel = LogChannel(subsystem: "com.test", category: "NoStore") - channel.error("no store attached") -} diff --git a/Shared/LogKit/Tests/LogLevelTests.swift b/Shared/LogKit/Tests/LogLevelTests.swift deleted file mode 100644 index d64a2af6..00000000 --- a/Shared/LogKit/Tests/LogLevelTests.swift +++ /dev/null @@ -1,23 +0,0 @@ -import LogKit -import os -import Testing - -@Test -func levelsAreOrderedBySeverity() { - #expect(LogLevel.debug < .info) - #expect(LogLevel.info < .notice) - #expect(LogLevel.notice < .warning) - #expect(LogLevel.warning < .error) - #expect(LogLevel.error < .fault) - #expect(LogLevel.allCases == [.debug, .info, .notice, .warning, .error, .fault]) -} - -@Test -func osLogTypeMapping() { - #expect(LogLevel.debug.osLogType == .debug) - #expect(LogLevel.info.osLogType == .info) - #expect(LogLevel.notice.osLogType == .default) - #expect(LogLevel.warning.osLogType == .default) - #expect(LogLevel.error.osLogType == .error) - #expect(LogLevel.fault.osLogType == .fault) -} diff --git a/Shared/LogKit/Tests/LogStoreTests.swift b/Shared/LogKit/Tests/LogStoreTests.swift deleted file mode 100644 index 01491c9b..00000000 --- a/Shared/LogKit/Tests/LogStoreTests.swift +++ /dev/null @@ -1,161 +0,0 @@ -import Foundation -@_spi(Testing) import LogKit -import Testing - -private func entry(_ message: String, level: LogLevel = .info) -> LogEntry { - LogEntry(level: level, subsystem: "test", category: "test", message: message) -} - -@Test -func recordsAppendInOrder() { - let store = LogStore() - store.record(entry("a")) - store.record(entry("b")) - store.record(entry("c")) - - #expect(store.snapshot().map(\.message) == ["a", "b", "c"]) -} - -@Test -func evictsOldestPastCapacity() { - let store = LogStore(capacity: 3) - for index in 0 ..< 5 { - store.record(entry("\(index)")) - } - - #expect(store.snapshot().map(\.message) == ["2", "3", "4"]) -} - -@Test -func clearEmptiesTheBuffer() { - let store = LogStore() - store.record(entry("a")) - store.clear() - - #expect(store.snapshot().isEmpty) -} - -@Test -func changesYieldsInitialThenUpdates() async { - let store = LogStore() - store.record(entry("initial")) - - var iterator = store.changes().makeAsyncIterator() - - let first = await iterator.next() - #expect(first?.map(\.message) == ["initial"]) - - store.record(entry("second")) - let second = await iterator.next() - #expect(second?.map(\.message) == ["initial", "second"]) - - store.clear() - let third = await iterator.next() - #expect(third?.isEmpty == true) -} - -@Test -func cancelledChangesStreamUnregistersObserver() async { - let store = LogStore() - store.record(entry("before")) - - do { - var iterator = store.changes().makeAsyncIterator() - _ = await iterator.next() - } - - store.record(entry("after-cancel")) - - var iterator = store.changes().makeAsyncIterator() - let snapshot = await iterator.next() - #expect(snapshot?.map(\.message) == ["before", "after-cancel"]) -} - -@Test -func changesNotifiesMultipleObservers() async { - let store = LogStore() - store.record(entry("a")) - - var firstObserver = store.changes().makeAsyncIterator() - var secondObserver = store.changes().makeAsyncIterator() - - let firstInitial = await firstObserver.next() - let secondInitial = await secondObserver.next() - #expect(firstInitial?.map(\.message) == ["a"]) - #expect(secondInitial?.map(\.message) == ["a"]) - - store.record(entry("b")) - let firstUpdate = await firstObserver.next() - let secondUpdate = await secondObserver.next() - #expect(firstUpdate?.map(\.message) == ["a", "b"]) - #expect(secondUpdate?.map(\.message) == ["a", "b"]) -} - -@Test -func changesDeliversMonotonicSnapshotsForSequentialRecords() async { - let store = LogStore() - var iterator = store.changes().makeAsyncIterator() - _ = await iterator.next() - - var previousCount = 0 - for index in 0 ..< 10 { - store.record(entry("\(index)")) - if let snapshot = await iterator.next() { - #expect(snapshot.count == previousCount + 1) - previousCount = snapshot.count - } - } -} - -@Test -func changesDeliversFinalSnapshotAfterConcurrentRecords() async { - let store = LogStore() - var iterator = store.changes().makeAsyncIterator() - _ = await iterator.next() - - let recordCount = 50 - await withTaskGroup(of: Void.self) { group in - for index in 0 ..< recordCount { - group.addTask { - store.record(entry("\(index)")) - } - } - } - - var lastSnapshot: [LogEntry]? - while let snapshot = await iterator.next() { - lastSnapshot = snapshot - if snapshot.count == recordCount { - break - } - } - #expect(lastSnapshot?.count == recordCount) -} - -@Test -func changesInitialYieldReflectsPreRegistrationSnapshot() async { - let store = LogStore() - - // `changes()` synchronously captures and yields the snapshot that exists at - // subscription time *before* it registers the observer, so records that land - // afterwards cannot deliver an update ahead of the initial yield. Subscribing - // to an empty store and only then recording proves it deterministically: the - // records are already buffered behind the empty initial element by the time - // we consume the stream, yet the first yield is still empty. - var iterator = store.changes().makeAsyncIterator() - - for index in 0 ..< 10 { - store.record(entry("\(index)")) - } - - let initial = await iterator.next() - #expect(initial?.isEmpty == true) - - // The post-subscription records still arrive, just as later updates. - var last: [LogEntry]? - while let snapshot = await iterator.next() { - last = snapshot - if snapshot.count == 10 { break } - } - #expect(last?.count == 10) -} diff --git a/Shared/LogViewerUI/AGENTS.md b/Shared/LogViewerUI/AGENTS.md deleted file mode 100644 index 534ee318..00000000 --- a/Shared/LogViewerUI/AGENTS.md +++ /dev/null @@ -1,39 +0,0 @@ -# LogViewerUI – Module Shape - -LogViewerUI is an app-agnostic SwiftUI **log viewer** over one or more -[`LogKit`](../LogKit) `LogStore`s: hand it a `LogViewerConfiguration` (stores, -title, category display names) and `LogViewer` renders entries newest-first -with filtering, share, copy, and clear. See [`README.md`](README.md) for the -narrative and API. - -This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns the -build system, formatting, and global conventions. Read that first. - -## Scope & dependencies - -- **SwiftUI + Observation + UIKit (pasteboard only) + LogKit.** It must - **not** import WhereCore or any app code — app-specific wiring comes in via - `LogViewerConfiguration`. -- **Intended for DEBUG / developer surfaces**; consumers gate the entry point - behind `#if DEBUG`. Developer-facing strings are plain literals here. -- `LogViewer` expects an ambient `NavigationStack` the consumer owns. - -## Invariants - -- **Read-only mirror.** Recording lives in `LogKit`; this module only - consumes snapshots on the main actor (the one write is `clear()`, which - empties every configured store and updates `entries` synchronously so the - list clears immediately). -- **Multiple stores merge by timestamp.** Each store is observed concurrently - and its latest snapshot kept per-store; `entries` is the re-merged, date-sorted - union, so several modules' buffers read as one chronological stream. -- **Newest-first for display, oldest-first for export** — a shared/copied log - reads chronologically. -- **The level filter is driven by `LogLevel.allCases`**, so a new level in - LogKit flows through automatically — don't hardcode the level list. - -## Testing - -Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost`: seed a -`LogStore`, drive `LogViewerModel`, assert on filtering/export; host -`LogViewer` for populated and empty states. diff --git a/Shared/LogViewerUI/README.md b/Shared/LogViewerUI/README.md deleted file mode 100644 index 0c330428..00000000 --- a/Shared/LogViewerUI/README.md +++ /dev/null @@ -1,120 +0,0 @@ -# LogViewerUI - -A small, app-agnostic SwiftUI **log viewer** over one or more [`LogKit`](../LogKit) -`LogStore`s. Point it at a buffer (or several) and it renders captured entries -newest-first with a level badge, category, timestamp, and message, plus live -search, level/category filtering, share, copy, and clear — for *any* `LogStore`, -with no per-app code. Multiple buffers are merged chronologically, so a host can -surface several modules' logs (each with its own subsystem/category) in one view. - -It's built for **developer / DEBUG surfaces** (think a hidden "Logs" row in a -Settings screen): it reads whatever the app's loggers wrote into the shared -buffer(s) this session. - -LogViewerUI depends only on **SwiftUI + Observation + UIKit (pasteboard) + -LogKit** — no app code. - -## What you get - -- **Live list** — entries newest-first; each row shows a tinted level badge, the - (display-mapped) category, an `HH:MM:SS` timestamp, and a selectable message. - The list updates as new lines are logged (it observes `LogStore.changes()`). -- **Filtering & search** — a minimum-level picker, a category picker (built from - the categories actually present), and a `.searchable` field matching message - *or* the mapped category display name. -- **Share / copy / clear** — share or copy the currently-filtered entries as - plain text (ISO-8601 timestamps; formatting deferred until share is initiated), - copy a single message from its context menu, or clear the buffer (with - confirmation). -- **Empty states** — a `ContentUnavailableView` when nothing has been captured, - and a separate one when filters match nothing. - -## Installation - -`LogViewerUI` is a local SPM library in this repo (`Shared/LogViewerUI`). Add it -to a target's dependencies in [`Package.swift`](../../Package.swift): - -```swift -.target(name: "YourUI", dependencies: [.target(name: "LogViewerUI")]) -``` - -## Quick start - -Drop `LogViewer` into a navigation context you own and hand it a configuration -built from your store: - -```swift -import LogViewerUI - -NavigationStack { // the viewer expects an ambient stack - LogViewer(configuration: LogViewerConfiguration(store: myLogStore, title: "Logs")) -} -``` - -The view loads the current snapshot on appear and streams updates while it's on -screen. - -> `LogViewer` sets a navigation title and toolbar but **doesn't** create its own -> `NavigationStack`, so it composes inside a settings screen, tab, or sheet. - -## Configuration - -```swift -public struct LogViewerConfiguration: Sendable { - public init( - stores: [LogStore], - title: String = "Logs", - categoryDisplayName: @escaping @Sendable (String) -> String = { $0 }, - ) - - /// Convenience for the common single-buffer case. - public init( - store: LogStore, - title: String = "Logs", - categoryDisplayName: @escaping @Sendable (String) -> String = { $0 }, - ) -} -``` - -- **`stores`** — the `LogKit` buffer(s) to read and observe, merged - chronologically. The `store:` init is a convenience for the single-buffer case. -- **`title`** — the viewer's navigation title. -- **`categoryDisplayName`** — maps a raw `LogEntry.category` to a friendly name - (e.g. an app's typed-category enum → a label). Defaults to identity. - -## How it works - -`LogViewer` owns a `@MainActor @Observable LogViewerModel` that mirrors the -store(s) into `entries` and derives cached `filteredEntries` (newest-first, after -level/category/search). Observation starts in the model's `init` and iterates -each store's `LogStore.changes()` concurrently (in a task group) until the model -is deallocated, remerging the per-store snapshots by timestamp on every change — -so the list stays live without the view touching the lock-guarded stores -directly. Recording stays off the main actor in `LogKit`; this module only -consumes snapshots on the main actor for display. - -## Example: adopting it in an app (Where) - -The Where app exposes it behind a DEBUG-only entry in its floating developer -overlay, pointed at both process-wide buffers — `WhereLog` (the app/WhereCore -facade) and `RegionLog` (RegionKit) — merged into one list: - -```swift -#if DEBUG -NavigationLink { - LogViewer(configuration: LogViewerConfiguration( - stores: [WhereLog.store, RegionLog.store], - title: Strings.settingsDebugLogsTitle, - )) -} label: { - Label(Strings.settingsDebugLogsLink, systemImage: "ladybug") -} -#endif -``` - -## Testing - -Swift Testing in a hosted bundle (`LogViewerUITests`). Seed a `LogStore`, drive -`LogViewerModel` (filters, `exportText`, `clear`), and assert on -`filteredEntries`; plus hosting tests that mount `LogViewer` for the populated -and empty states. diff --git a/Shared/LogViewerUI/Sources/LogLevel+Display.swift b/Shared/LogViewerUI/Sources/LogLevel+Display.swift deleted file mode 100644 index 06b8e34e..00000000 --- a/Shared/LogViewerUI/Sources/LogLevel+Display.swift +++ /dev/null @@ -1,33 +0,0 @@ -import LogKit -import SwiftUI - -extension LogLevel { - /// Short capitalized name shown in badges and the level filter. - var displayName: String { - switch self { - case .debug: "Debug" - case .info: "Info" - case .notice: "Notice" - case .warning: "Warning" - case .error: "Error" - case .fault: "Fault" - } - } - - /// Uppercased label used in badges and export text. - var badgeLabel: String { - displayName.uppercased() - } - - /// Tint used for the level badge, escalating with severity. - var tint: Color { - switch self { - case .debug: .gray - case .info: .blue - case .notice: .teal - case .warning: .yellow - case .error: .orange - case .fault: .red - } - } -} diff --git a/Shared/LogViewerUI/Sources/LogViewer.swift b/Shared/LogViewerUI/Sources/LogViewer.swift deleted file mode 100644 index 21dd0c56..00000000 --- a/Shared/LogViewerUI/Sources/LogViewer.swift +++ /dev/null @@ -1,183 +0,0 @@ -@_spi(Testing) import LogKit -import SwiftUI -import UIKit - -/// A generic, read-only viewer over a ``LogStore``. Renders entries newest -/// first with a level badge, category, timestamp, and message, and offers -/// search, level/category filtering, share, and clear. -/// -/// Designed to be pushed inside an existing `NavigationStack` (it sets a -/// navigation title and toolbar but does not create its own stack). -public struct LogViewer: View { - private let configuration: LogViewerConfiguration - @State private var model: LogViewerModel - @State private var showClearConfirmation = false - - public init(configuration: LogViewerConfiguration) { - self.configuration = configuration - _model = State(initialValue: LogViewerModel( - stores: configuration.stores, - categoryDisplayName: configuration.categoryDisplayName, - )) - } - - public var body: some View { - @Bindable var model = model - content - .navigationTitle(configuration.title) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Menu { - Picker("Level", selection: $model.minimumLevel) { - ForEach(LogLevel.allCases, id: \.self) { level in - Text(level.displayName).tag(level) - } - } - - Picker("Category", selection: $model.selectedCategory) { - Text("All Categories").tag(String?.none) - ForEach(model.categories, id: \.self) { category in - Text(configuration.categoryDisplayName(category)) - .tag(String?.some(category)) - } - } - } label: { - Label("Filter", systemImage: "line.3.horizontal.decrease.circle") - } - } - - ToolbarItem(placement: .topBarTrailing) { - Menu { - ShareLink( - item: LogExportItem( - entries: model.filteredEntries.reversed(), - categoryDisplayName: configuration.categoryDisplayName, - ), - preview: SharePreview("Logs"), - ) { - Label("Share Logs", systemImage: "square.and.arrow.up") - } - Button(role: .destructive) { - showClearConfirmation = true - } label: { - Label("Clear Logs", systemImage: "trash") - } - } label: { - Label("More", systemImage: "ellipsis.circle") - } - .confirmationDialog( - "Clear all captured logs?", - isPresented: $showClearConfirmation, - titleVisibility: .visible, - ) { - Button("Clear Logs", role: .destructive) { model.clear() } - Button("Cancel", role: .cancel) {} - } - } - } - .searchable(text: $model.searchText) - } - - @ViewBuilder - private var content: some View { - if model.isEmpty { - ContentUnavailableView( - "No Logs", - systemImage: "doc.text.magnifyingglass", - description: Text("Logs captured this session will appear here."), - ) - } else if model.hasNoFilterMatches { - ContentUnavailableView( - "No Matching Logs", - systemImage: "line.3.horizontal.decrease.circle", - description: Text("Try adjusting your filters or search."), - ) - } else { - List(model.filteredEntries) { entry in - LogEntryRow( - entry: entry, - categoryName: configuration.categoryDisplayName(entry.category), - ) - .contextMenu { - Button { - UIPasteboard.general.string = entry.message - } label: { - Label("Copy Message", systemImage: "doc.on.doc") - } - } - } - .listStyle(.plain) - } - } -} - -/// Defers plain-text export until the share sheet requests the payload. -private struct LogExportItem: Transferable { - let entries: [LogEntry] - let categoryDisplayName: @Sendable (String) -> String - - static var transferRepresentation: some TransferRepresentation { - ProxyRepresentation { item in - LogViewerModel.formatExportText( - entries: item.entries, - categoryDisplayName: item.categoryDisplayName, - ) - } - } -} - -private struct LogEntryRow: View { - let entry: LogEntry - let categoryName: String - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - LevelBadge(level: entry.level) - Text(categoryName) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Text(entry.date, format: .dateTime.hour().minute().second()) - .font(.caption2) - .monospacedDigit() - .foregroundStyle(.tertiary) - } - Text(entry.message) - .font(.callout) - .textSelection(.enabled) - } - .padding(.vertical, 2) - } -} - -private struct LevelBadge: View { - let level: LogLevel - - var body: some View { - Text(level.badgeLabel) - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(level.tint.opacity(0.18), in: .capsule) - .foregroundStyle(level.tint) - } -} - -#if DEBUG - #Preview { - let store = LogStore() - for index in 0 ..< 6 { - let levels: [LogLevel] = [.debug, .info, .notice, .error, .fault] - store.record(LogEntry( - level: levels[index % levels.count], - subsystem: "com.example.app", - category: index.isMultiple(of: 2) ? "Networking" : "Persistence", - message: "Sample log message #\(index) describing what happened.", - )) - } - return NavigationStack { - LogViewer(configuration: LogViewerConfiguration(store: store, title: "Logs")) - } - } -#endif diff --git a/Shared/LogViewerUI/Sources/LogViewerConfiguration.swift b/Shared/LogViewerUI/Sources/LogViewerConfiguration.swift deleted file mode 100644 index 09e2c9ec..00000000 --- a/Shared/LogViewerUI/Sources/LogViewerConfiguration.swift +++ /dev/null @@ -1,41 +0,0 @@ -import LogKit -import SwiftUI - -/// Host-supplied configuration for ``LogViewer``. Keeps the viewer generic: the -/// host points it at one or more ``LogStore``s and supplies display strings -/// (e.g. a title and a mapping from raw category identifiers to human-readable -/// names). -/// -/// Multiple stores let a host surface buffers from several modules (each with -/// its own subsystem/category) in one viewer; entries are merged -/// chronologically. Each `LogEntry` carries its `subsystem`/`category`, so the -/// category filter still tells them apart. -public struct LogViewerConfiguration: Sendable { - /// The buffers to read and observe, merged chronologically for display. - public var stores: [LogStore] - - /// Navigation title for the viewer. - public var title: String - - /// Maps a raw `LogEntry.category` to a display name. Defaults to identity. - public var categoryDisplayName: @Sendable (String) -> String - - public init( - stores: [LogStore], - title: String = "Logs", - categoryDisplayName: @escaping @Sendable (String) -> String = { $0 }, - ) { - self.stores = stores - self.title = title - self.categoryDisplayName = categoryDisplayName - } - - /// Convenience for the common single-buffer case. - public init( - store: LogStore, - title: String = "Logs", - categoryDisplayName: @escaping @Sendable (String) -> String = { $0 }, - ) { - self.init(stores: [store], title: title, categoryDisplayName: categoryDisplayName) - } -} diff --git a/Shared/LogViewerUI/Sources/LogViewerModel.swift b/Shared/LogViewerUI/Sources/LogViewerModel.swift deleted file mode 100644 index 732a9b14..00000000 --- a/Shared/LogViewerUI/Sources/LogViewerModel.swift +++ /dev/null @@ -1,180 +0,0 @@ -import Foundation -import LogKit -import Observation - -private let exportTimestampFormatter = Date.ISO8601FormatStyle( - includingFractionalSeconds: true, -) - -private final class ObservationHandle: @unchecked Sendable { - private var tasks: [Task] = [] - - func start(_ operation: @escaping @MainActor () async -> Void) { - tasks.append(Task { await operation() }) - } - - func cancel() { - for task in tasks { - task.cancel() - } - } -} - -/// Drives ``LogViewer``: mirrors one or more ``LogStore``s into observable -/// state (merged chronologically) and applies the active filters. Recording -/// happens off the main actor in the store(s); this model only consumes -/// snapshots on the main actor for display. -@MainActor -@Observable -final class LogViewerModel { - private let stores: [LogStore] - private let categoryDisplayName: @Sendable (String) -> String - @ObservationIgnored private let observation = ObservationHandle() - - /// The most recent snapshot from each store, kept in `stores` order so a - /// change to one store re-merges without re-reading the others. - @ObservationIgnored private var latestSnapshots: [[LogEntry]] - - private(set) var entries: [LogEntry] - - private var cachedCategories: [String]? - private var cachedFilteredEntries: [LogEntry]? - - var searchText = "" { - didSet { if searchText != oldValue { invalidateFilterCache() } } - } - - var minimumLevel: LogLevel = .debug { - didSet { if minimumLevel != oldValue { invalidateFilterCache() } } - } - - /// `nil` means "all categories". - var selectedCategory: String? { - didSet { if selectedCategory != oldValue { invalidateFilterCache() } } - } - - init( - stores: [LogStore], - categoryDisplayName: @escaping @Sendable (String) -> String = { $0 }, - ) { - self.stores = stores - self.categoryDisplayName = categoryDisplayName - latestSnapshots = stores.map { $0.snapshot() } - entries = Self.merged(latestSnapshots) - // Observe each store on its own task. Each loop re-promotes `self` per - // iteration (`guard let self else { break }`), so between log lines the - // tasks hold only a weak reference: the model can deinit while parked in - // `for await`, and `deinit` then cancels every task. (An instance - // `observe()` call would instead keep `self` alive for the streams' - // whole lifetime — see `YearReportModel.observeDataChanges()`.) - for index in stores.indices { - let store = stores[index] - observation.start { [weak self] in - for await snapshot in store.changes() { - guard let self else { break } - apply(snapshot, at: index) - } - } - } - } - - /// Convenience for the common single-buffer case. - convenience init( - store: LogStore, - categoryDisplayName: @escaping @Sendable (String) -> String = { $0 }, - ) { - self.init(stores: [store], categoryDisplayName: categoryDisplayName) - } - - deinit { - observation.cancel() - } - - private func apply(_ snapshot: [LogEntry], at index: Int) { - latestSnapshots[index] = snapshot - entries = Self.merged(latestSnapshots) - invalidateEntryCache() - } - - /// Flatten every store's snapshot into one oldest-first list. Each store's - /// snapshot is already chronological; sorting by `date` interleaves them. - private static func merged(_ snapshots: [[LogEntry]]) -> [LogEntry] { - guard snapshots.count > 1 else { return snapshots.first ?? [] } - return snapshots.flatMap(\.self).sorted { $0.date < $1.date } - } - - /// Distinct categories present in the buffer, sorted for a stable filter. - var categories: [String] { - if let cachedCategories { - return cachedCategories - } - let result = Array(Set(entries.map(\.category))).sorted() - cachedCategories = result - return result - } - - /// Entries newest-first, after level/category/search filters. - var filteredEntries: [LogEntry] { - if let cachedFilteredEntries { - return cachedFilteredEntries - } - let result = entries.reversed().filter { entry in - guard entry.level >= minimumLevel else { return false } - if let selectedCategory, entry.category != selectedCategory { return false } - guard !searchText.isEmpty else { return true } - let displayCategory = categoryDisplayName(entry.category) - return entry.message.localizedCaseInsensitiveContains(searchText) - || displayCategory.localizedCaseInsensitiveContains(searchText) - } - cachedFilteredEntries = result - return result - } - - var isEmpty: Bool { - entries.isEmpty - } - - /// The store has entries, but the active filters exclude all of them. - var hasNoFilterMatches: Bool { - !isEmpty && filteredEntries.isEmpty - } - - func clear() { - for store in stores { - store.clear() - } - latestSnapshots = stores.map { $0.snapshot() } - entries = Self.merged(latestSnapshots) - invalidateEntryCache() - } - - /// Plain-text rendering of the currently-filtered entries, for share/copy. - func exportText() -> String { - Self.formatExportText( - entries: filteredEntries.reversed(), - categoryDisplayName: categoryDisplayName, - ) - } - - nonisolated static func formatExportText( - entries: [LogEntry], - categoryDisplayName: @escaping @Sendable (String) -> String, - ) -> String { - entries.map { entry in - let timestamp = entry.date.formatted(exportTimestampFormatter) - let level = entry.level.badgeLabel - let category = categoryDisplayName(entry.category) - return "\(timestamp) [\(level)] \(category): \(entry.message)" - } - .joined(separator: "\n") - } - - private func invalidateEntryCache() { - cachedCategories = nil - invalidateFilterCache() - } - - private func invalidateFilterCache() { - cachedFilteredEntries = nil - } -} diff --git a/Shared/LogViewerUI/Tests/LogViewerHostingTests.swift b/Shared/LogViewerUI/Tests/LogViewerHostingTests.swift deleted file mode 100644 index 06ec6681..00000000 --- a/Shared/LogViewerUI/Tests/LogViewerHostingTests.swift +++ /dev/null @@ -1,72 +0,0 @@ -@_spi(Testing) import LogKit -import LogViewerUI -import SwiftUI -import TestHostSupport -import Testing -import UIKit - -private func isViewHosted(_ hosted: UIViewController) -> Bool { - hosted.parent != nil - && hosted.view.window != nil - && hosted.view.bounds.width > 0 - && hosted.view.bounds.height > 0 -} - -@MainActor -struct LogViewerHostingTests { - @Test func viewerHostsWithEntries() throws { - let store = LogStore() - store.record(LogEntry(level: .error, subsystem: "s", category: "DB", message: "boom")) - #expect(store.snapshot().map(\.message) == ["boom"]) - - let rootView = NavigationStack { - LogViewer(configuration: LogViewerConfiguration(store: store, title: "Logs")) - } - let hosted = UIHostingController(rootView: rootView) - try show(hosted) { controller in - try waitFor { isViewHosted(controller) } - } - } - - @Test func viewerHostsWhenEmpty() throws { - let store = LogStore() - #expect(store.snapshot().isEmpty) - - let rootView = NavigationStack { - LogViewer(configuration: LogViewerConfiguration(store: store, title: "Logs")) - } - let hosted = UIHostingController(rootView: rootView) - try show(hosted) { controller in - try waitFor { isViewHosted(controller) } - } - } - - @Test func viewerHostsWithMultipleStores() throws { - // Mirrors the Settings entry, which merges WhereLog + RegionLog buffers. - let appStore = LogStore() - appStore.record(LogEntry( - level: .info, - subsystem: "app", - category: "Session", - message: "hi", - )) - let regionStore = LogStore() - regionStore.record(LogEntry( - level: .error, - subsystem: "region", - category: "RegionAttributor", - message: "boom", - )) - - let rootView = NavigationStack { - LogViewer(configuration: LogViewerConfiguration( - stores: [appStore, regionStore], - title: "Logs", - )) - } - let hosted = UIHostingController(rootView: rootView) - try show(hosted) { controller in - try waitFor { isViewHosted(controller) } - } - } -} diff --git a/Shared/LogViewerUI/Tests/LogViewerModelTests.swift b/Shared/LogViewerUI/Tests/LogViewerModelTests.swift deleted file mode 100644 index 3abd920e..00000000 --- a/Shared/LogViewerUI/Tests/LogViewerModelTests.swift +++ /dev/null @@ -1,238 +0,0 @@ -import Foundation -@_spi(Testing) import LogKit -@testable import LogViewerUI -import Testing - -@MainActor -struct LogViewerModelTests { - private func seededStore() -> LogStore { - let store = LogStore() - store.record(LogEntry(level: .debug, subsystem: "s", category: "Net", message: "debug net")) - store.record(LogEntry(level: .info, subsystem: "s", category: "Net", message: "info net")) - store.record(LogEntry(level: .error, subsystem: "s", category: "DB", message: "error db")) - store.record(LogEntry( - level: .fault, - subsystem: "s", - category: "DB", - message: "fault db crash", - )) - return store - } - - @Test - func defaultsShowAllEntriesNewestFirst() { - let model = LogViewerModel(store: seededStore()) - #expect(model.filteredEntries.map(\.message) == [ - "fault db crash", - "error db", - "info net", - "debug net", - ]) - } - - @Test - func minimumLevelFiltersOutLowerSeverity() { - let model = LogViewerModel(store: seededStore()) - model.minimumLevel = .error - #expect(model.filteredEntries.map(\.message) == ["fault db crash", "error db"]) - } - - @Test - func categoryFilterRestrictsToSelection() { - let model = LogViewerModel(store: seededStore()) - model.selectedCategory = "Net" - #expect(model.filteredEntries.map(\.category) == ["Net", "Net"]) - #expect(model.categories == ["DB", "Net"]) - } - - @Test - func searchMatchesMessageAndCategory() { - let model = LogViewerModel(store: seededStore()) - model.searchText = "crash" - #expect(model.filteredEntries.map(\.message) == ["fault db crash"]) - - model.searchText = "net" - #expect(model.filteredEntries.count == 2) - } - - @Test - func searchMatchesCategoryDisplayName() { - let store = LogStore() - store.record(LogEntry( - level: .info, - subsystem: "s", - category: "net.raw", - message: "connected", - )) - let model = LogViewerModel(store: store, categoryDisplayName: { _ in "Networking" }) - model.searchText = "network" - #expect(model.filteredEntries.map(\.message) == ["connected"]) - } - - @Test - func warningFiltersBetweenNoticeAndError() { - let store = LogStore() - store.record(LogEntry(level: .notice, subsystem: "s", category: "C", message: "n")) - store.record(LogEntry(level: .warning, subsystem: "s", category: "C", message: "w")) - store.record(LogEntry(level: .error, subsystem: "s", category: "C", message: "e")) - let model = LogViewerModel(store: store) - model.minimumLevel = .warning - #expect(model.filteredEntries.map(\.message) == ["e", "w"]) - } - - @Test - func combinedLevelCategoryAndSearchFilters() { - let model = LogViewerModel(store: seededStore()) - model.minimumLevel = .error - model.selectedCategory = "DB" - model.searchText = "fault" - #expect(model.filteredEntries.map(\.message) == ["fault db crash"]) - } - - @Test - func hasNoFilterMatchesWhenFiltersExcludeEverything() { - let model = LogViewerModel(store: seededStore()) - model.searchText = "missing-term" - #expect(!model.isEmpty) - #expect(model.hasNoFilterMatches) - #expect(model.filteredEntries.isEmpty) - } - - @Test - func clearEmptiesModelEntries() { - let model = LogViewerModel(store: seededStore()) - model.clear() - #expect(model.isEmpty) - } - - @Test - func exportTextRendersOldestFirst() { - let model = LogViewerModel(store: seededStore()) - model.minimumLevel = .error - let text = model.exportText() - let lines = text.split(separator: "\n") - #expect(lines.count == 2) - #expect(lines.first?.contains("error db") == true) - #expect(lines.last?.contains("fault db crash") == true) - } - - @Test - func exportTextUsesCategoryDisplayName() { - let model = LogViewerModel(store: seededStore(), categoryDisplayName: { category in - category == "DB" ? "Database" : category - }) - let text = model.exportText() - #expect(text.contains("Database")) - #expect(!text.contains("] DB:")) - } - - @Test - func observeReflectsLiveStoreUpdates() async { - let store = LogStore() - let model = LogViewerModel(store: store) - - store.record(LogEntry(level: .info, subsystem: "s", category: "Net", message: "live")) - - let deadline = Date(timeIntervalSinceNow: 1) - while model.entries.isEmpty, Date() < deadline { - await Task.yield() - } - - #expect(model.entries.map(\.message) == ["live"]) - } - - // MARK: - Multiple stores - - @Test - func mergesMultipleStoresChronologically() { - let base = Date(timeIntervalSince1970: 1000) - let appStore = LogStore() - appStore.record(LogEntry( - date: base, - level: .info, - subsystem: "app", - category: "Session", - message: "app 1", - )) - appStore.record(LogEntry( - date: base.addingTimeInterval(2), - level: .info, - subsystem: "app", - category: "Session", - message: "app 2", - )) - let regionStore = LogStore() - regionStore.record(LogEntry( - date: base.addingTimeInterval(1), - level: .info, - subsystem: "region", - category: "RegionAttributor", - message: "region 1", - )) - - let model = LogViewerModel(stores: [appStore, regionStore]) - // Entries interleave by date (oldest-first); display reverses to newest-first. - #expect(model.entries.map(\.message) == ["app 1", "region 1", "app 2"]) - #expect(model.filteredEntries.map(\.message) == ["app 2", "region 1", "app 1"]) - // Categories from every store show up in the filter. - #expect(model.categories == ["RegionAttributor", "Session"]) - } - - @Test - func clearEmptiesEveryStore() { - let appStore = LogStore() - appStore.record(LogEntry(level: .info, subsystem: "app", category: "C", message: "a")) - let regionStore = LogStore() - regionStore.record(LogEntry(level: .info, subsystem: "region", category: "C", message: "b")) - - let model = LogViewerModel(stores: [appStore, regionStore]) - #expect(model.entries.count == 2) - - model.clear() - #expect(model.isEmpty) - #expect(appStore.snapshot().isEmpty) - #expect(regionStore.snapshot().isEmpty) - } - - /// Guards against a retain cycle through the long-lived observation tasks: - /// they capture `[weak self]` and `deinit` cancels them, so dropping the last - /// strong reference deallocates the model even while the tasks are parked in - /// `for await` (quiet stores emit nothing on their own). - @Test - func deinitsWhileObservingStores() { - weak var weakModel: LogViewerModel? - do { - let model = LogViewerModel(stores: [LogStore(), LogStore()]) - weakModel = model - #expect(weakModel != nil) - } - #expect(weakModel == nil) - } - - @Test - func observeReflectsUpdatesFromEveryStore() async { - let appStore = LogStore() - let regionStore = LogStore() - let model = LogViewerModel(stores: [appStore, regionStore]) - - appStore.record(LogEntry( - level: .info, - subsystem: "app", - category: "C", - message: "from app", - )) - regionStore.record(LogEntry( - level: .info, - subsystem: "region", - category: "C", - message: "from region", - )) - - let deadline = Date(timeIntervalSinceNow: 1) - while model.entries.count < 2, Date() < deadline { - await Task.yield() - } - - #expect(Set(model.entries.map(\.message)) == ["from app", "from region"]) - } -} diff --git a/Shared/Periscope/PeriscopeCore/AGENTS.md b/Shared/Periscope/PeriscopeCore/AGENTS.md index 47153495..1e9582b9 100644 --- a/Shared/Periscope/PeriscopeCore/AGENTS.md +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -13,7 +13,7 @@ the build system, formatting, and global conventions. Read that first. - **Foundation + os + SwiftData + Network + JournalKit only** (plus the ObjectiveC runtime, for `LogContextProviding`'s deallocation trackers and `NotificationAmbientSource`'s target/selector observation). No SwiftUI, no - app code, no LogKit. UIKit is allowed **only** inside `#if canImport(UIKit)` + app code. UIKit is allowed **only** inside `#if canImport(UIKit)` (ambient sources, the image-attachment convenience). - Layering: `PeriscopeUI` and `PeriscopeTools` depend on this module — never the reverse. diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index 72f08de7..ffa1bb21 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -15,6 +15,7 @@ - design: `LogContextProviding` parent hierarchy — instance logs need a way to nest under a container's context (e.g. a controller inside another controller). Plan/build loop. - design: Ambient state snapshots — ambients should persist their *current state* (session-style) alongside change events, so any event joins to the system state at that moment. Plan/build loop. (Groundwork landed: ambient sources are now reference types that own their per-kind state — see the "Ambient change filtering" completed entry.) - feat: Implement `SpanRelaunchPolicy.survivesRelaunch` resume mechanics. The policy is already recorded on `SpanBegan` payloads and the relaunch sweep honors it (surviving spans are left open, not orphan-closed), but nothing re-seeds them: `end(for:)` in the new process warns "without a matching begin". Needs an async bootstrap step at store/system startup that queries unmatched surviving `SpanBegan` events and re-opens them in `Periscope.openSpans` — plus wall-clock durations for resumed spans (`ContinuousClock` instants don't survive reboot; `SpanEnded.duration` is already optional for this) and accepting that signpost intervals can't resume. +- feat: Never drop the pre-store-attach window — journal from process start (Where migration dogfood; PR #94 review). `PeriscopeStore.make` is `async`, so events emitted between process launch and `add(sink:)` (early launch steps, ambient start-up snapshots) reach neither the store nor today's journal (journaling only begins once an on-disk store is attached) — they survive only in the in-memory recent buffer and OSLog, and are lost from the durable record. We must never drop or lose events. Fix: write to a **short-lived journal from app start, before the store is registered, reusing the JournalKit infra**; when the store attaches, ingest that bootstrap journal (dedupe by event ID like the crash-journal ingest) and delete it. Composes with — but is stronger than — a recent-buffer replay into a late-added sink (that only covers what's still buffered, not a slow/large pre-attach burst). Related: the "No eager store handle" P2 below. ## P1s (Should do) @@ -24,6 +25,10 @@ ## P2s (Nice to have) +- refactor: Reconsider the `callAsFunction` scope-derivation API (Where migration/PR #94 review). `log(SomeLog.self)` / `log(for: id)` derivation reads as an opaque function call at declaration sites; a named form (`log.scope(SomeLog.self)` / `log.subcatalog(for: id)` / `log.child(_:)`) would read clearer. Constraint: the one-expression derive-and-emit (`log(PhotoLogs.self) { … }`) exists *because* `callAsFunction` lets Swift resolve the type arg + trailing closure as one application — a named method splits it, so the emit ergonomics need a paired design (a method that also takes the trailing closure) before renaming. Affects every derivation call site + all Periscope consumers. +- feat: Add non-closure emit overloads alongside the `{}` form (Where migration/PR #94 review). Today emit is only `log { .event }` / `log(attachments:) { .event }`; the closure is nice for multi-line payload builds but heavy for a bare event. Add a value form — either `log.emit(.event)` (named, no overload ambiguity) or a `log(.event)` value overload — keeping `{}` for multi-line. Additive; pairs with the derivation-naming item above. +- feat: Inspect-by-object is scope-granular, not instance-granular (Where migration dogfood). `.logInspectable(_:)` keys the badge/inspector to a `Log`'s *scope*, so tagging a list row (Where tags `EvidenceRow` with `WhereLog.evidence`, `LocationStatusRow` with `WhereLog.session`) surfaces the whole scope's recent events, not that one row's. Events already carry `externalID` for object correlation, but the inspector can't filter by it — a per-instance child scope (blocked on the `LogContextProviding` parent-hierarchy P0) or an `externalID`-scoped inspect entry would make true row-/object-level inspection work. +- design: No eager store handle — `PeriscopeStore.make` being `async` forces an "optional store, observe until it lands" dance on consumers. Where exposes an `Optional` on `WhereModel` that stays `nil` until the bootstrap `Task` completes, and `RootView` has to watch the transition (`.onChange` of the store identity) to wire the viewer/inspector/alerter. A synchronous pending-store handle (usable immediately, resolves in the background) or an `await`-readiness accessor would remove the optional-and-observe boilerplate every app repeats. # Completed issues diff --git a/Where/AGENTS.md b/Where/AGENTS.md index c0b848cb..ef683bbf 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -51,15 +51,30 @@ Rules the code enforces and agents must preserve: refresh inline. The scene's `YearReportModel` subscribes while it's active; `DataIssueScanner` drops its cache on the same signal. Launch is driven by [`LifecycleKit`](../Shared/LifecycleKit) (see `WhereLaunch` in WhereUI). -- **All logging goes through `WhereLog.channel(_:)`** with a typed - `WhereLog.Category` case, never a raw string. Messages log as `.public`, so - keep PII out. `info` = success of an important operation, `warning` = - degraded-but-handled, `error`/`fault` = outright failure; hot paths - (per-sample persist, widget throttle) stay quiet by design. **RegionKit** logs - through its own `RegionLog` facade (subsystem `com.stuff.regionkit`, separate - store) since it can't see `WhereLog`; the DEBUG developer log viewer is - configured with **both** buffers (`[WhereLog.store, RegionLog.store]`) so it - shows a single merged stream. +- **All logging goes through [Periscope](../Shared/Periscope)** via the + `WhereLog` facade — a `"Where"` root `Log` scope with grouping scopes + (`location`, `reminders`, `backup`, `widgets`, `session`, `evidence`, + `recentActivity`) that each collaborator derives a typed `LogEvent` leaf from + (`WhereLog.(SomeLog.self)` / `WhereLog.root(SomeLog.self)`), never a + raw string. Each module keeps its facade and `*Log.swift` event types together + in its own `Sources/Logging/` folder. Events log as `.public`, so keep PII out; catch-path events carry + a `LogAttachment.error(_:)`. `info` = success of an important operation, + `warning` = degraded-but-handled, `error`/`fault` = outright failure; hot + paths (per-sample persist, widget throttle) stay quiet by design. **RegionKit** + emits through its own `RegionLog` facade (a separate `"RegionKit"` root scope) + since it can't see `WhereLog`, but into the *same* process-wide + `Periscope.shared` — the app attaches one `PeriscopeStore` sink at launch, and + the DEBUG developer surface (`PeriscopeViewer`) shows every scope subtree in a + single stream. Widgets, the share extension, and the intents surface run in + their own processes, so their `Periscope.shared` stays OSLog-only (no store). + An event that concerns a store object stamps its `externalID` with the + object's canonical `store://` identity — `DataIssueID.storeURL` for dismissals, + and `WhereStoreID` (`store://days/…`, `store://years/…`, `store://evidence/…`, + `store://samples/…`) for the other families — so inspect-by-object shares the + same key the store and backups use. **RegionKit** can't see the app's + `store://` types, so it owns a parallel `region://` scheme (`RegionURL`, + `Region.regionURL` → `region://regions/`) and `RegionAttributorLog` keys on + that — a separate namespace, since regions are a bundled catalog, not store rows. - **Location comes through the `LocationSource` protocol** — production is `CoreLocationSource`; tests and previews use `ScriptedLocationSource`. Besides the passive `sampleStream`, it offers a best-effort one-shot diff --git a/Where/RegionKit/AGENTS.md b/Where/RegionKit/AGENTS.md index 5d8cf921..3d62ef90 100644 --- a/Where/RegionKit/AGENTS.md +++ b/Where/RegionKit/AGENTS.md @@ -10,7 +10,8 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature ## Scope & dependencies -- **Pure Swift + Foundation**, plus [`LogKit`](../../Shared/LogKit). It must +- **Pure Swift + Foundation**, plus + [`PeriscopeCore`](../../Shared/Periscope/PeriscopeCore) for logging. It must **not** import SwiftUI, UIKit, SwiftData, CoreLocation, or `WhereCore` — it is the lowest layer of the feature, and `WhereCore` depends on *it*, never the reverse. @@ -45,9 +46,19 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature - **Missing/corrupt bundled geometry (or manifest) is a programmer error** — the loader logs a `fault` via `RegionLog` *and* `assertionFailure`s (debug), degrading to `.other`/an empty catalog in release rather than crashing. -- **Logging goes through `RegionLog.channel(_:)`** (subsystem - `com.stuff.regionkit`), never `WhereLog` — RegionKit owns its own channel and - in-memory store. +- **Logging goes through `RegionLog`** — a Periscope facade with a `"RegionKit"` + root scope and one typed `LogEvent` per collaborator, emitted into + `Periscope.shared`. RegionKit owns its own root scope, never `WhereLog`, but + shares the process-wide store (the app wires the `PeriscopeStore` sink). The + `RegionLog` facade and the `*Log.swift` event types live together in + `Sources/Logging/`. +- **Object identities are `region://` URLs** — `RegionURL` (RegionKit's local + analog of WhereCore's `StoreURL`) builds/parses `region:///` + URLs, and `Region.regionURL` vends `region://regions/`. Used to key a + `LogEvent.externalID` (see `RegionAttributorLog`) so inspect-by-object works + without RegionKit reaching up into the app's `store://` scheme — a separate, + intentionally parallel namespace. Distinct from `Region`'s bare-`rawValue` + `Codable`, which stays the persisted form. ## Testing diff --git a/Where/RegionKit/README.md b/Where/RegionKit/README.md index 432c16f4..c1358cc6 100644 --- a/Where/RegionKit/README.md +++ b/Where/RegionKit/README.md @@ -8,7 +8,8 @@ unit-tested in isolation. RegionKit is the lowest layer of the Where feature: `WhereCore` (and, through it, `WhereUI`, the widgets, and the RegionViewer) depend on RegionKit and call -into it for lookup. RegionKit depends only on [`LogKit`](../../Shared/LogKit). +into it for lookup. RegionKit depends only on +[`PeriscopeCore`](../../Shared/Periscope/PeriscopeCore) for logging. ## What you get @@ -34,7 +35,10 @@ into it for lookup. RegionKit depends only on [`LogKit`](../../Shared/LogKit). - **`RegionGeometryCatalog`** — read-only drawable `RegionOutline`s for the developer region-map viewer (`.attribution` for a given attributor vs `.source` for the whole catalog). -- **`RegionLog`** — RegionKit's LogKit facade (subsystem `com.stuff.regionkit`). +- **`RegionLog`** — RegionKit's Periscope logging facade: one `"RegionKit"` + root scope with a typed `LogEvent` per collaborator (`RegionAttributor`, + `RegionCatalog`, `RegionGeometryCatalog`), emitted into the process-wide + `Periscope.shared` system. ## Installation diff --git a/Where/RegionKit/Sources/Logging/RegionAttributorLog.swift b/Where/RegionKit/Sources/Logging/RegionAttributorLog.swift new file mode 100644 index 00000000..15d61a57 --- /dev/null +++ b/Where/RegionKit/Sources/Logging/RegionAttributorLog.swift @@ -0,0 +1,48 @@ +import PeriscopeCore + +/// Structured events for `RegionAttributor`'s per-region geometry load. Missing +/// or corrupt bundled geometry is a programmer error, so those cases log at +/// `.fault` (paired with a debug `assertionFailure`); the region id rides on +/// `externalID` so the tooling can pull every event about one region. +enum RegionAttributorLog: LogEvent { + /// The manifest names a geometry file the bundle doesn't contain. + case missingGeometry(region: Region) + /// The region's GeoJSON decoded to zero polygons. + case emptyPolygons(region: Region) + /// The region's GeoJSON failed to decode. + case decodeFailed(region: Region, description: String) + /// Finished loading polygons for `regionCount` regions. + case loaded(regionCount: Int) + + static let eventName = "RegionAttributor" + + var level: LogLevel { + switch self { + case .missingGeometry, .emptyPolygons, .decodeFailed: .fault + case .loaded: .info + } + } + + var message: String { + switch self { + case let .missingGeometry(region): + "Missing bundled GeoJSON for region \(region.rawValue)" + case let .emptyPolygons(region): + "Region \(region.rawValue) decoded no polygons" + case let .decodeFailed(region, description): + "Failed to decode bundled GeoJSON for region \(region.rawValue): \(description)" + case let .loaded(regionCount): + "Loaded region polygons for \(regionCount) region(s)" + } + } + + var externalID: String? { + switch self { + case let .missingGeometry(region), let .emptyPolygons(region), + let .decodeFailed(region, _): + region.regionURL.absoluteString + case .loaded: + nil + } + } +} diff --git a/Where/RegionKit/Sources/Logging/RegionCatalogLog.swift b/Where/RegionKit/Sources/Logging/RegionCatalogLog.swift new file mode 100644 index 00000000..0712275f --- /dev/null +++ b/Where/RegionKit/Sources/Logging/RegionCatalogLog.swift @@ -0,0 +1,33 @@ +import PeriscopeCore + +/// Structured events for `RegionCatalog`'s bundled-manifest load. A missing or +/// unparseable `regions.json` is a programmer error (corrupt bundled resource), +/// so those cases log at `.fault` to match the paired `assertionFailure`. +enum RegionCatalogLog: LogEvent { + /// The bundled `regions.json` manifest is absent from the bundle. + case missingManifest + /// The manifest decoded successfully into `regionCount` entries. + case loaded(regionCount: Int) + /// The manifest was present but could not be decoded. + case decodeFailed(description: String) + + static let eventName = "RegionCatalog" + + var level: LogLevel { + switch self { + case .missingManifest, .decodeFailed: .fault + case .loaded: .info + } + } + + var message: String { + switch self { + case .missingManifest: + "Missing required bundled regions.json manifest" + case let .loaded(regionCount): + "Loaded region catalog with \(regionCount) region(s)" + case let .decodeFailed(description): + "Failed to decode bundled regions.json: \(description)" + } + } +} diff --git a/Where/RegionKit/Sources/Logging/RegionGeometryCatalogLog.swift b/Where/RegionKit/Sources/Logging/RegionGeometryCatalogLog.swift new file mode 100644 index 00000000..d318677a --- /dev/null +++ b/Where/RegionKit/Sources/Logging/RegionGeometryCatalogLog.swift @@ -0,0 +1,23 @@ +import PeriscopeCore + +/// Structured events for the developer region-map viewer's geometry load. A +/// failed load is degraded-but-handled (the viewer shows an error state), so it +/// logs at `.warning`. Public because the viewer lives in WhereUI, above +/// RegionKit, and emits through ``RegionLog/geometryCatalog``. +public enum RegionGeometryCatalogLog: LogEvent { + /// Loading the outlines for a `RegionGeometryKind` failed. + case loadFailed(kind: String, description: String) + + public static let eventName = "RegionGeometryCatalog" + + public var level: LogLevel { + .warning + } + + public var message: String { + switch self { + case let .loadFailed(kind, description): + "Region map viewer failed to load \(kind) geometry: \(description)" + } + } +} diff --git a/Where/RegionKit/Sources/Logging/RegionLog.swift b/Where/RegionKit/Sources/Logging/RegionLog.swift new file mode 100644 index 00000000..d95e8d4b --- /dev/null +++ b/Where/RegionKit/Sources/Logging/RegionLog.swift @@ -0,0 +1,37 @@ +import PeriscopeCore + +/// Phantom root event naming RegionKit's log scope tree. It is never emitted — +/// its only job is to give ``RegionLog``'s root `Log` the scope name +/// `"RegionKit"`, so every RegionKit event sits under one filterable subtree. +struct RegionKitRoot: LogEvent { + static let eventName = "RegionKit" + var message: String { + "" + } +} + +/// Logging facade for `RegionKit`. Every logger site derives from one root +/// `Log` scoped `"RegionKit"`, so RegionKit's events form a single subtree +/// under Periscope's process-wide system (``Periscope/shared``) — the log +/// viewer filters or inspects them as a group, and each collaborator's events +/// live in their own named child scope. +/// +/// RegionKit owns its own root scope rather than borrowing the Where app's +/// `WhereLog`: it's a standalone lower-level module that must not depend on app +/// code. Loggers are typed to a per-collaborator ``LogEvent`` so a new event +/// can't silently typo into an untracked category. +public enum RegionLog { + /// The `"RegionKit"` root every RegionKit logger descends from. + static let root = Log(system: .shared) + + /// `RegionAttributor` — surfaces missing/unparseable bundled geometry as + /// faults alongside the debug-build `assertionFailure`. + static let attributor = root(RegionAttributorLog.self) + + /// `RegionCatalog` — the bundled `regions.json` manifest load. + static let catalog = root(RegionCatalogLog.self) + + /// `RegionGeometryCatalog` — the developer region-map viewer's geometry + /// load. Public because the viewer lives in WhereUI, above RegionKit. + public static let geometryCatalog = root(RegionGeometryCatalogLog.self) +} diff --git a/Where/RegionKit/Sources/Region.swift b/Where/RegionKit/Sources/Region.swift index 7fa71ee8..0d84f07b 100644 --- a/Where/RegionKit/Sources/Region.swift +++ b/Where/RegionKit/Sources/Region.swift @@ -71,6 +71,15 @@ extension Region { public var localizedName: String { RegionCatalog.shared.localizedName(for: self) } + + /// Stable `region://regions/` identity, built with ``RegionURL``, for + /// logging/tooling correlation (e.g. a Periscope `LogEvent.externalID`) so + /// every event about one region shares a namespaced key. Distinct from the + /// bare-`rawValue` `Codable` used for storage — this is the tooling + /// identity, not the persisted form. + public var regionURL: URL { + RegionURL.url(collection: "regions", type: rawValue, items: [:]) + } } // MARK: - Well-known regions diff --git a/Where/RegionKit/Sources/RegionAttributor.swift b/Where/RegionKit/Sources/RegionAttributor.swift index 56320eea..af816126 100644 --- a/Where/RegionKit/Sources/RegionAttributor.swift +++ b/Where/RegionKit/Sources/RegionAttributor.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore /// A coordinate-to-`Region` lookup engine. Abstracted as a protocol so callers /// can hold either an immutable ``RegionAttributor`` snapshot or a live, @@ -95,9 +95,9 @@ public struct RegionAttributor: RegionAttributing { return entry.polygons.map { $0.distanceToBoundary(from: coordinate) }.min() } - /// `RegionLog` channel — surfaces missing/unparseable bundled resources as + /// `RegionLog` logger — surfaces missing/unparseable bundled resources as /// Console.app faults alongside the debug-build `assertionFailure`. - private static let logger = RegionLog.channel(.attributor) + private static let logger = RegionLog.attributor /// Loads the exterior-ring polygons for each region from its bundled /// per-region GeoJSON. Missing/corrupt geometry is a programmer error: it's @@ -108,29 +108,31 @@ public struct RegionAttributor: RegionAttributing { for region in regions { guard region != .other else { continue } guard let url = RegionCatalog.shared.geometryURL(for: region) else { - logger.fault("Missing bundled GeoJSON for region \(region.rawValue)") + logger { .missingGeometry(region: region) } assertionFailure("Missing bundled GeoJSON for region \(region.rawValue)") continue } do { let polygons = try GeoJSON.polygons(at: url) guard !polygons.isEmpty else { - logger.fault("Region \(region.rawValue) decoded no polygons") + logger { .emptyPolygons(region: region) } assertionFailure("Region \(region.rawValue) decoded no polygons") continue } entries.append(RegionPolygons(region: region, polygons: polygons)) } catch { - logger - .fault( - "Failed to decode bundled GeoJSON for region \(region.rawValue): \(error.localizedDescription)", + logger(attachments: [.error(error, name: "decode-error")]) { + .decodeFailed( + region: region, + description: error.localizedDescription, ) + } assertionFailure( "Failed to decode bundled GeoJSON for region \(region.rawValue): \(error)", ) } } - logger.info("Loaded region polygons for \(entries.count) region(s)") + logger { .loaded(regionCount: entries.count) } return entries } } diff --git a/Where/RegionKit/Sources/RegionCatalog.swift b/Where/RegionKit/Sources/RegionCatalog.swift index 8584f9c0..011ebe00 100644 --- a/Where/RegionKit/Sources/RegionCatalog.swift +++ b/Where/RegionKit/Sources/RegionCatalog.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore /// The catalog of **available** regions, loaded once from the bundled /// `regions.json` manifest. It is RegionKit's single source of the region @@ -92,11 +92,11 @@ public struct RegionCatalog: Sendable { } extension RegionCatalog { - private static let logger = RegionLog.channel(.catalog) + private static let logger = RegionLog.catalog private static func loadFromBundle() -> RegionCatalog { guard let url = Bundle.module.url(forResource: "regions", withExtension: "json") else { - logger.fault("Missing required bundled regions.json manifest") + logger { .missingManifest } assertionFailure("Missing bundled regions.json") return RegionCatalog(entries: []) } @@ -111,10 +111,12 @@ extension RegionCatalog { geometryFile: item.geometry.file, ) } - logger.info("Loaded region catalog with \(entries.count) region(s)") + logger { .loaded(regionCount: entries.count) } return RegionCatalog(entries: entries) } catch { - logger.fault("Failed to decode bundled regions.json: \(error.localizedDescription)") + logger(attachments: [.error(error, name: "decode-error")]) { + .decodeFailed(description: error.localizedDescription) + } assertionFailure("Failed to decode bundled regions.json: \(error)") return RegionCatalog(entries: []) } diff --git a/Where/RegionKit/Sources/RegionLog.swift b/Where/RegionKit/Sources/RegionLog.swift deleted file mode 100644 index 2955cb91..00000000 --- a/Where/RegionKit/Sources/RegionLog.swift +++ /dev/null @@ -1,30 +0,0 @@ -import LogKit - -/// Logging facade for `RegionKit`. Every logger site routes through -/// ``channel(_:)`` so messages reach both Apple unified logging (Console.app, -/// subsystem `com.stuff.regionkit`) and the shared in-memory ``LogStore`` a -/// log viewer can read (DEBUG builds only). -/// -/// RegionKit owns its own subsystem and store rather than borrowing the Where -/// app's `WhereLog`: it's a standalone lower-level module that must not depend -/// on app code. Categories are a typed enum rather than raw strings so a new -/// logger can't silently typo into an untracked category. -public enum RegionLog { - /// The subsystem every RegionKit log shares. - public static let subsystem = "com.stuff.regionkit" - - /// Process-wide buffer feeding an in-app log viewer. Logging is inherently - /// process-global, so a single shared store is the natural home. - public static let store = LogStore() - - public enum Category: String, CaseIterable, Sendable { - case attributor = "RegionAttributor" - case geometryCatalog = "RegionGeometryCatalog" - case catalog = "RegionCatalog" - } - - /// A logging channel for `category`, wired to the shared buffer. - public static func channel(_ category: Category) -> LogChannel { - LogChannel(subsystem: subsystem, category: category.rawValue, store: store) - } -} diff --git a/Where/RegionKit/Sources/RegionURL.swift b/Where/RegionKit/Sources/RegionURL.swift new file mode 100644 index 00000000..f3f67e32 --- /dev/null +++ b/Where/RegionKit/Sources/RegionURL.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Builder and parser for `region:///?` identity URLs +/// — RegionKit's local analog of WhereCore's `StoreURL`. It lets a RegionKit +/// value vend a stable, namespaced URL identity (e.g. a Periscope +/// `LogEvent.externalID`) without reaching up into app code for `StoreURL`. +/// +/// `collection` is the object family (`regions`), `type` the specific instance +/// or kind within it, and named query items carry any additional identifying +/// values. RegionKit is a standalone lower module, so it owns its own +/// `region://` scheme rather than borrowing the app's `store://` one — the two +/// are intentionally separate namespaces (a region is a bundled-catalog entry, +/// not a store row). +public enum RegionURL { + public static let scheme = "region" + + /// The parsed components of a `region://` URL. A named struct (not a tuple) + /// so it can carry across call boundaries per the repo's tuple rule. + public struct Parts: Sendable, Hashable { + public let collection: String + public let type: String + public let items: [String: String] + + public init(collection: String, type: String, items: [String: String]) { + self.collection = collection + self.type = type + self.items = items + } + + /// The value for a query item, or `nil` when the key is absent. + public func value(_ key: String) -> String? { + items[key] + } + } + + /// Build a `region:///?` URL. Query items are + /// sorted by key so the same identity always produces the same string. + public static func url(collection: String, type: String, items: [String: String]) -> URL { + var components = URLComponents() + components.scheme = scheme + components.host = collection + components.path = "/" + type + components.queryItems = items.isEmpty + ? nil + : items + .sorted { $0.key < $1.key } + .map { URLQueryItem(name: $0.key, value: $0.value) } + // The inputs are controlled identifiers, so a `nil` here would be a + // programmer error (an illegal collection/type), not a recoverable one. + guard let url = components.url else { + preconditionFailure("Could not build region URL for \(collection)/\(type)") + } + return url + } + + /// Parse a `region://` URL into its components, or `nil` if it isn't a + /// well-formed `region:///` URL. + public static func parts(of url: URL) -> Parts? { + guard url.scheme == scheme, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let collection = components.host, !collection.isEmpty + else { return nil } + let path = components.path + let type = path.hasPrefix("/") ? String(path.dropFirst()) : path + guard !type.isEmpty else { return nil } + var items: [String: String] = [:] + for item in components.queryItems ?? [] { + if let value = item.value { items[item.name] = value } + } + return Parts(collection: collection, type: type, items: items) + } +} diff --git a/Where/RegionKit/Tests/Logging/RegionLogTests.swift b/Where/RegionKit/Tests/Logging/RegionLogTests.swift new file mode 100644 index 00000000..ac3edc02 --- /dev/null +++ b/Where/RegionKit/Tests/Logging/RegionLogTests.swift @@ -0,0 +1,69 @@ +import PeriscopeCore +@testable import RegionKit +import Testing + +/// Covers RegionKit's Periscope log tree: the scope names/hierarchy the +/// ``RegionLog`` facade vends, and each collaborator event's rendering. +struct RegionLogTests { + // MARK: - Scope tree + + @Test func rootScopeIsRegionKit() { + #expect(RegionLog.root.primaryScope.name == "RegionKit") + } + + @Test func collaboratorScopesDescendFromTheRoot() { + let rootID = RegionLog.root.primaryScope.id + #expect(RegionLog.attributor.primaryScope.name == "RegionAttributor") + #expect(RegionLog.attributor.primaryScope.parentID == rootID) + #expect(RegionLog.catalog.primaryScope.name == "RegionCatalog") + #expect(RegionLog.catalog.primaryScope.parentID == rootID) + #expect(RegionLog.geometryCatalog.primaryScope.name == "RegionGeometryCatalog") + #expect(RegionLog.geometryCatalog.primaryScope.parentID == rootID) + } + + // MARK: - RegionCatalogLog + + @Test func catalogEventsRenderAndLevel() { + #expect(RegionCatalogLog.missingManifest.level == .fault) + #expect(RegionCatalogLog.decodeFailed(description: "boom").level == .fault) + #expect(RegionCatalogLog.loaded(regionCount: 4).level == .info) + #expect(RegionCatalogLog.loaded(regionCount: 4).message.contains("4 region")) + } + + // MARK: - RegionAttributorLog + + @Test func attributorEventsCarryRegionAsExternalID() { + // The region rides on externalID as its region:// identity (see + // RegionURLTests for the exact string). + #expect( + RegionAttributorLog.missingGeometry(region: .california) + .externalID == Region.california.regionURL.absoluteString, + ) + #expect( + RegionAttributorLog.emptyPolygons(region: .canada) + .externalID == Region.canada.regionURL.absoluteString, + ) + #expect( + RegionAttributorLog.decodeFailed(region: .newYork, description: "x") + .externalID == Region.newYork.regionURL.absoluteString, + ) + #expect(RegionAttributorLog.loaded(regionCount: 2).externalID == nil) + } + + @Test func attributorFaultsAndInfo() { + #expect(RegionAttributorLog.missingGeometry(region: .california).level == .fault) + #expect(RegionAttributorLog.emptyPolygons(region: .california).level == .fault) + #expect( + RegionAttributorLog.decodeFailed(region: .california, description: "x").level == .fault, + ) + #expect(RegionAttributorLog.loaded(regionCount: 2).level == .info) + } + + // MARK: - RegionGeometryCatalogLog + + @Test func geometryCatalogFailureIsWarning() { + let event = RegionGeometryCatalogLog.loadFailed(kind: "source", description: "nope") + #expect(event.level == .warning) + #expect(event.message.contains("source")) + } +} diff --git a/Where/RegionKit/Tests/RegionURLTests.swift b/Where/RegionKit/Tests/RegionURLTests.swift new file mode 100644 index 00000000..a7535b1d --- /dev/null +++ b/Where/RegionKit/Tests/RegionURLTests.swift @@ -0,0 +1,40 @@ +import Foundation +@testable import RegionKit +import Testing + +/// Covers ``RegionURL`` (RegionKit's `region://` identity builder/parser) and +/// the `Region.regionURL` identity used for log `externalID` correlation. +struct RegionURLTests { + @Test func buildsCollectionTypeAndSortedParams() { + let url = RegionURL.url( + collection: "regions", + type: "us-CA", + items: ["b": "2", "a": "1"], + ) + #expect(url.absoluteString == "region://regions/us-CA?a=1&b=2") + } + + @Test func buildsWithoutParamsWhenEmpty() { + let url = RegionURL.url(collection: "regions", type: "canada", items: [:]) + #expect(url.absoluteString == "region://regions/canada") + } + + @Test func parsesBackIntoParts() throws { + let url = try #require(URL(string: "region://regions/us-NY?since=2026")) + let parts = try #require(RegionURL.parts(of: url)) + #expect(parts.collection == "regions") + #expect(parts.type == "us-NY") + #expect(parts.value("since") == "2026") + } + + @Test func rejectsForeignSchemes() throws { + // A store:// URL is a different namespace; RegionURL must not claim it. + let url = try #require(URL(string: "store://issues/borderDrift?day=2026-04-01")) + #expect(RegionURL.parts(of: url) == nil) + } + + @Test func regionVendsItsCanonicalIdentity() { + #expect(Region.california.regionURL.absoluteString == "region://regions/us-CA") + #expect(Region.other.regionURL.absoluteString == "region://regions/other") + } +} diff --git a/Where/RegionViewer/AGENTS.md b/Where/RegionViewer/AGENTS.md index 87194e3e..d7a6ee32 100644 --- a/Where/RegionViewer/AGENTS.md +++ b/Where/RegionViewer/AGENTS.md @@ -10,8 +10,8 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature ## Scope & rules - **Tuist app target** (bundle ID `com.stuff.regionviewer`), depending on - **WhereUI**, **WhereCore**, **RegionKit** (geometry + GeoJSON, whose resource - bundle is embedded for `RegionGeometryCatalog`), and **LogKit**. The `@main` + **WhereUI**, **WhereCore**, and **RegionKit** (geometry + GeoJSON, whose + resource bundle is embedded for `RegionGeometryCatalog`). The `@main` body is `WindowGroup { NavigationStack { RegionMapView() } }` — that's the whole target. - **Shell only, session-less.** No domain logic, SwiftData, App Group, or diff --git a/Where/TODOs.md b/Where/TODOs.md index 3cfba94e..fb95cc3c 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -42,6 +42,7 @@ This feels like it might result in a cleaner "pipeline-esque" code layout, and a - Open question: the exclusion UX — an older device progressively hiding days a newer device has touched — needs a deliberate warning surface, not a silent drop. ## P2s (Nice to have) +- feat: Bound the Periscope log store by size, not just age (PR #94 review). `WhereLaunch.bootstrapLogging` prunes events older than the retention window (now 100 days); with the built-in ambient sources emitting continuously, a heavy-logging device could still grow the store large within the window. Add a count cap alongside the time prune — `PeriscopeStore.pruneEvents(keepingNewest:)` already exists — so the store is bounded regardless of volume. - refactor: Make the scene-scoped model wiring compiler-checked rather than an `@Environment` lookup that fails silently. `WhereSession` (the always-on coordinator) is read from the environment, so a screen mounted without a parent injecting it resolves to a runtime fallback/precondition instead of a compile error. The scoped models (`YearReportModel`, `ResolveModel`, `BackupModel`, `RemindersSettingsModel`) are already constructor-injected; explore threading the coordinator the same way (or a non-defaulting typed `EnvironmentKey`) so a broken wiring can't build. Follow-up from the `WhereSession` split. - refactor: Split `YearReportModel` further. Post-split it still fuses several roles for the selected year: the loaded report + everything derived from it (ranking, missing days, calendar inputs, tracked-day count), the Resolve badge *count*, the day-write intents (`setManualDay(s)`, `overrideDay`, `clearManualDay`, `clearSelectedYear`), and the Elsewhere drill-in reads (`days(in:)`, `locations(in:)`, `representativeCoordinates()`). The read-only presentation state and the write-intent/drill-in surface could be separate collaborators so a view only holds what it uses. Follow-up from the `WhereSession` split. - The `guard let controller else { return }` in the WhereModel in WhereUI is weird diff --git a/Where/Where/Sources/AppDelegate.swift b/Where/Where/Sources/AppDelegate.swift index da1e202c..141b3f19 100644 --- a/Where/Where/Sources/AppDelegate.swift +++ b/Where/Where/Sources/AppDelegate.swift @@ -58,6 +58,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate { from: application.applicationState, locationAuthorization: CLLocationManager().authorizationStatus, ) + // Open the durable Periscope log store and attach it to the shared + // logging pipeline. Off the launch critical path (it touches disk); the + // shared OSLog sink covers logging until the store is attached. + WhereLaunch.bootstrapLogging(model: model) // `initializePrerequisites` installs the CLLocationManager synchronously // (so a queued location event isn't lost) and registers the // foreground-notification presenter; the rest (store open, etc.) runs as diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 8f6ea764..2f919244 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -87,7 +87,10 @@ internal shape. `Codable` and a stable SwiftData string key for free — never an ad-hoc `type:value` string or a hand-written keyed `Codable`. Build/parse with `StoreURL` so every conformer shares the `store:///?` - shape. + shape. Object families without a dedicated identity type (days, years, + evidence, samples) get their `store://` identity from `WhereStoreID`, used to + stamp Periscope `LogEvent.externalID`s so log inspect-by-object shares the + store's keys. - **No in-app data migration or legacy recovery.** A data-shape change is not migrated on read or at boot: `SD….toValue()` reads only the current shape and drops (fault-logs) a row it can't place — e.g. an `SDManualDay` with no @@ -131,8 +134,13 @@ internal shape. loads tracked-region geometry, so `distanceToBoundary` is `nil` elsewhere. - **Impossible states trap; recoverable ones surface.** `WhereStore` methods are `async throws` so the CloudKit-backed store can report I/O failure; a `catch` - must log via `WhereLog.channel(_:)` (typed `Category`, PII-free) and leave - state honest — never swallow into an empty default. + must log via a `WhereLog` typed `LogEvent` (PII-free, `.public`) and leave + state honest — never swallow into an empty default. Each collaborator emits + its own `LogEvent` under its scope (`WhereLog.(SomeLog.self)` or + `WhereLog.root(SomeLog.self)`); errors ride as `LogAttachment.error(_:)`. The + `WhereLog` facade and every `*Log.swift` event type live together in + `Sources/Logging/` (not beside their collaborator), so the module's logging + vocabulary sits in one place. ## Testing diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index d0e78dd4..e1e5a36b 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -7,7 +7,7 @@ widget snapshots, backups, on-device activity summaries). It is pure Swift + Foundation + SwiftData + CoreLocation + FoundationModels — **no SwiftUI or UIKit** — so all of it is unit-testable off-screen. It builds on [`RegionKit`](../RegionKit) for coordinate→region lookup and logs through -[`LogKit`](../../Shared/LogKit). +[`Periscope`](../../Shared/Periscope) via the `WhereLog` facade. Everything is reached through one `Sendable` container, **`WhereServices`**, which the presentation layer (`WhereUI`) and the widget extension talk to. For @@ -96,8 +96,9 @@ one it belongs to rather than to a god-object: a selectable look-back `RecentActivityWindow`. - **`WherePreferences`** — persisted user intent (onboarding, tracking intent, reminder / summary schedules) behind a `KeyValueStore`. -- **`WhereLog`** — the LogKit facade (subsystem `com.stuff.where`, a typed - `Category`). +- **`WhereLog`** — the Periscope logging facade: a `"Where"` root scope with + grouping scopes (`location`, `reminders`, `backup`, `widgets`, …) and a typed + `LogEvent` per collaborator, emitted into `Periscope.shared`. ## Installation diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index 43885510..0561a506 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import RegionKit /// Owns backup export/import over the `BackupService` and the store, running a @@ -56,7 +56,7 @@ public actor BackupCoordinator { /// (drop the issue-scan cache, reconcile the app-icon badge + issues /// notification, republish the widget snapshot). private let onImport: @Sendable () async -> Void - private static let logger = WhereLog.channel(.backupService) + private static let logger = WhereLog.backup(BackupCoordinatorLog.self) /// Staging directory of the most recent export. Each archive lands in its /// own temporary directory; the share sheet copies the file it needs out of @@ -155,9 +155,7 @@ public actor BackupCoordinator { do { try FileManager.default.removeItem(at: previous) } catch { - Self.logger.warning( - "Failed to remove previous backup export directory: \(error.localizedDescription)", - ) + Self.logger { .removePreviousExportFailed(description: error.localizedDescription) } } } diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift index 015d806d..68e61579 100644 --- a/Where/WhereCore/Sources/Backup/BackupService.swift +++ b/Where/WhereCore/Sources/Backup/BackupService.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import RegionKit import ZIPFoundation @@ -62,7 +62,7 @@ public struct BackupService: Sendable { private static let manifestFilename = "manifest.json" private static let assetsDirectory = "assets" - private static let logger = WhereLog.channel(.backupService) + private static let logger = WhereLog.backup(BackupServiceLog.self) public init() {} @@ -133,15 +133,23 @@ public struct BackupService: Sendable { let name = archiveName ?? Self.defaultArchiveName(for: exportedAt) let zipURL = workRoot.appendingPathComponent(name) - try fileManager.zipItem( - at: staging, - to: zipURL, - shouldKeepParent: false, - compressionMethod: .deflate, - ) - Self.logger.info( - "Wrote backup with \(samples.count) samples, \(evidence.count) evidence, \(manualDays.count) manual days, \(dismissedIssues.count) dismissals, \(trackedRegions.count) tracked regions", - ) + try Self.logger.measure(.writeArchive) { + try fileManager.zipItem( + at: staging, + to: zipURL, + shouldKeepParent: false, + compressionMethod: .deflate, + ) + } + Self.logger { + .wroteBackup( + sampleCount: samples.count, + evidenceCount: evidence.count, + manualDayCount: manualDays.count, + dismissedIssueCount: dismissedIssues.count, + trackedRegionCount: trackedRegions.count, + ) + } return zipURL } @@ -168,7 +176,9 @@ public struct BackupService: Sendable { try fileManager.createDirectory(at: extractDir, withIntermediateDirectories: true) defer { try? fileManager.removeItem(at: extractDir) } - try fileManager.unzipItem(at: url, to: extractDir) + try Self.logger.measure(.readArchive) { + try fileManager.unzipItem(at: url, to: extractDir) + } let manifestURL = extractDir.appendingPathComponent(Self.manifestFilename) guard fileManager.fileExists(atPath: manifestURL.path) else { @@ -188,9 +198,7 @@ public struct BackupService: Sendable { autoreleasepool { let assetURL = extractDir.appendingPathComponent(entry.filename) guard let data = try? Data(contentsOf: assetURL) else { - Self.logger.warning( - "Backup asset missing for evidence \(entry.evidenceId); skipping blob", - ) + Self.logger { .assetMissing(evidenceID: entry.evidenceId.uuidString) } return } blobs[entry.evidenceId] = data diff --git a/Where/WhereCore/Sources/Journal/DayJournal.swift b/Where/WhereCore/Sources/Journal/DayJournal.swift index 25d96fec..f2f9f5c3 100644 --- a/Where/WhereCore/Sources/Journal/DayJournal.swift +++ b/Where/WhereCore/Sources/Journal/DayJournal.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import RegionKit /// Owns the user-sourced writes into the store — sample ingestion, manual-day @@ -21,7 +21,7 @@ public actor DayJournal { private let issueScanner: DataIssueScanner private let widgets: WidgetSnapshotPublisher - private static let logger = WhereLog.channel(.dayJournal) + private static let logger = WhereLog.root(DayJournalLog.self) init( store: any WhereStore, @@ -104,7 +104,7 @@ public actor DayJournal { let presence = DayPresence(day: day, regions: regions, audit: audit) try await store.perform { try await store.setManualDay(presence) } await reconcileAfterDayChange() - Self.logger.info("Added manual day \(day) with \(regions.count) region(s)") + Self.logger { .addedManualDay(day: String(describing: day), regionCount: regions.count) } } /// Authoritatively set the regions for a single calendar day, *replacing* @@ -121,7 +121,7 @@ public actor DayJournal { let presence = DayPresence(day: day, regions: regions, isAuthoritative: true, audit: audit) try await store.perform { try await store.setManualDay(presence) } await reconcileAfterDayChange() - Self.logger.info("Overrode day \(day) with \(regions.count) region(s)") + Self.logger { .overrodeDay(day: String(describing: day), regionCount: regions.count) } } /// Drop the manual overlay for a single calendar day, restoring the @@ -132,7 +132,7 @@ public actor DayJournal { let day = CalendarDay(from: date, in: aggregator.calendar) try await store.perform { try await store.clearManualDay(day) } await reconcileAfterDayChange() - Self.logger.info("Cleared manual overlay for day \(day)") + Self.logger { .clearedManualDay(day: String(describing: day)) } } /// Drop the manual overlays for several calendar days (the logged-days @@ -153,7 +153,7 @@ public actor DayJournal { } } await reconcileAfterDayChange() - Self.logger.info("Cleared manual overlays for \(days.count) day(s)") + Self.logger { .clearedManualDays(dayCount: days.count) } } /// Assert `regions` for every calendar day in the inclusive range @@ -185,9 +185,9 @@ public actor DayJournal { } } await reconcileAfterDayChange() - Self.logger.info( - "Backfilled \(days.count) manual day(s) with \(regions.count) region(s)", - ) + Self.logger { + .backfilledManualDays(dayCount: days.count, regionCount: regions.count) + } } // MARK: - Clearing @@ -197,7 +197,7 @@ public actor DayJournal { let dayRange = CalendarDay.yearRange(year) try await store.perform { try await store.clear(in: interval, manualDays: dayRange) } await reconcileAfterDayChange() - Self.logger.info("Cleared year \(year)") + Self.logger { .clearedYear(year: year) } } /// Erase every sample, manual day, and piece of evidence in the store, then @@ -208,14 +208,16 @@ public actor DayJournal { public func eraseAllData() async throws { try await store.perform { try await store.clearAll() } await reconcileAfterDayChange() - Self.logger.info("Erased all store data") + Self.logger { .erasedAllData } } // MARK: - Evidence public func addEvidence(_ evidence: Evidence, blob: Data? = nil) async throws { try await store.perform { try await store.write(evidence: evidence, blob: blob) } - Self.logger.info("Wrote evidence \(evidence.id) (blob: \(blob != nil))") + Self.logger { + .wroteEvidence(id: String(describing: evidence.id), hasBlob: blob != nil) + } } public func evidence(for year: Int) async throws -> [Evidence] { diff --git a/Where/WhereCore/Sources/Location/LocationIngestor.swift b/Where/WhereCore/Sources/Location/LocationIngestor.swift index 0eac7b73..c7ab6e3f 100644 --- a/Where/WhereCore/Sources/Location/LocationIngestor.swift +++ b/Where/WhereCore/Sources/Location/LocationIngestor.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore /// Owns live GPS ingestion: the location-monitoring lifecycle, the /// single-consumer sample stream, the retry queue (mirrored to a durable @@ -84,7 +84,7 @@ public actor LocationIngestor { /// significant-change/Visits ingestion; tests inject a smaller cap. private let retryQueueCapacity: Int - private static let logger = WhereLog.channel(.locationIngestor) + private static let logger = WhereLog.location(LocationIngestorLog.self) init( store: any WhereStore, @@ -125,14 +125,14 @@ public actor LocationIngestor { guard !isMonitoring else { return } isMonitoring = true await locationSource.start() - Self.logger.info("GPS monitoring started") + Self.logger { .monitoringStarted } // Seed the in-memory queue from the durable backlog once, so samples that // failed to persist in a prior launch get retried now. if !didLoadDurableBacklog { didLoadDurableBacklog = true let restored = await outbox.load() if !restored.isEmpty { - Self.logger.info("Restored \(restored.count) sample(s) from durable retry backlog") + Self.logger { .restoredBacklog(count: restored.count) } } retryQueue = restored + retryQueue } @@ -164,7 +164,7 @@ public actor LocationIngestor { guard isMonitoring else { return } isMonitoring = false await locationSource.stop() - Self.logger.info("GPS monitoring stopped") + Self.logger { .monitoringStopped } } /// Stop ingestion and guarantee nothing else writes until the next @@ -198,7 +198,7 @@ public actor LocationIngestor { // Clear the durable mirror too; the store is about to be erased, so the // backlog must not re-drain into it on the next launch. await outbox.save([]) - Self.logger.info("GPS ingestion quiesced; retry backlog cleared") + Self.logger { .quiesced } } /// Whether GPS monitoring is currently active. Exposed so the view-model can @@ -255,7 +255,7 @@ public actor LocationIngestor { private func performTodayCapture(now: Date) async { let startOfDay = calendar.startOfDay(for: now) guard let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) else { - Self.logger.warning("Could not compute today's interval for foreground capture") + Self.logger { .todayIntervalUnavailable } return } let interval = DateInterval(start: startOfDay, end: endOfDay) @@ -265,9 +265,7 @@ public actor LocationIngestor { } catch { // Fail closed: if today's samples can't be read we skip rather than // risk logging a duplicate fix. Surfaced, not silently swallowed. - Self.logger.warning( - "Skipping foreground capture; could not read today's samples: \(error.localizedDescription)", - ) + Self.logger { .foregroundCaptureReadFailed(description: error.localizedDescription) } return } guard let sample = await locationSource.requestCurrentLocation() else { return } @@ -277,7 +275,7 @@ public actor LocationIngestor { // `quiesce()` either sees `acceptsSamples == false` here (we skip) or sees // the handle already set (it awaits us) — never neither. guard acceptsSamples else { return } - Self.logger.info("Captured one-shot foreground location for today") + Self.logger { .capturedForegroundFix } // Persist via `processIngestedSample` on the capture's own handle rather // than `ingest(_:)`, so it never shares the stream loop's single // `inFlightIngest` slot. `quiesce()` awaits this handle independently. @@ -336,9 +334,12 @@ public actor LocationIngestor { // via `os.Logger` rather than silently dropped. The stream keeps // running so a transient error doesn't stop tracking, and the sample // is queued for retry on the next save attempt. - Self.logger.error( - "Failed to persist GPS sample \(sample.id): \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "persist-error")]) { + .persistFailed( + sampleID: String(describing: sample.id), + description: error.localizedDescription, + ) + } enqueueForRetry(sample) await outbox.save(retryQueue) } @@ -346,9 +347,7 @@ public actor LocationIngestor { private func enqueueForRetry(_ sample: LocationSample) { if retryQueue.count >= retryQueueCapacity { - Self.logger.warning( - "Retry queue at capacity (\(retryQueueCapacity)); dropping oldest queued GPS sample", - ) + Self.logger { .retryQueueAtCapacity(capacity: retryQueueCapacity) } retryQueue.removeFirst() } retryQueue.append(sample) @@ -367,16 +366,22 @@ public actor LocationIngestor { try await store.perform { try await store.add(sample: sample) } persistedDays.insert(calendar.startOfDay(for: sample.timestamp)) } catch { - Self.logger.error( - "Retry still failing for GPS sample \(sample.id): \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "retry-error")]) { + .retryStillFailing( + sampleID: String(describing: sample.id), + description: error.localizedDescription, + ) + } enqueueForRetry(sample) } } if !persistedDays.isEmpty { - Self.logger.info( - "Drained retry backlog: persisted \(pending.count - retryQueue.count) sample(s) across \(persistedDays.count) day(s)", - ) + Self.logger { + .drainedBacklog( + sampleCount: pending.count - retryQueue.count, + dayCount: persistedDays.count, + ) + } } await outbox.save(retryQueue) return persistedDays diff --git a/Where/WhereCore/Sources/Location/LocationOutbox.swift b/Where/WhereCore/Sources/Location/LocationOutbox.swift index bc004466..c2d93c8b 100644 --- a/Where/WhereCore/Sources/Location/LocationOutbox.swift +++ b/Where/WhereCore/Sources/Location/LocationOutbox.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore /// A durable backlog of GPS samples that failed to persist, so a transient /// store outage (SwiftData/CloudKit) that *outlives the process* doesn't @@ -37,7 +37,7 @@ public struct NoOpLocationOutbox: LocationOutbox { public actor FileLocationOutbox: LocationOutbox { private let fileURL: URL - private static let logger = WhereLog.channel(.locationOutbox) + private static let logger = WhereLog.location(LocationOutboxLog.self) public init(fileURL: URL) { self.fileURL = fileURL @@ -55,9 +55,7 @@ public actor FileLocationOutbox: LocationOutbox { appropriateFor: nil, create: true, ) else { - logger.warning( - "No Application Support directory; using in-memory retry queue (backlog won't survive relaunch)", - ) + logger { .noApplicationSupport } return NoOpLocationOutbox() } return FileLocationOutbox(fileURL: directory.appending(path: "location-retry-outbox.json")) @@ -70,9 +68,9 @@ public actor FileLocationOutbox: LocationOutbox { } catch { // A decode failure means a corrupt or stale-format file; drop it // rather than crash-looping on every launch. - Self.logger.error( - "Dropping unreadable location retry backlog: \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "read-error")]) { + .droppedUnreadableBacklog(description: error.localizedDescription) + } return [] } } @@ -86,9 +84,9 @@ public actor FileLocationOutbox: LocationOutbox { let data = try JSONEncoder().encode(samples) try data.write(to: fileURL, options: .atomic) } catch { - Self.logger.error( - "Failed to persist location retry backlog: \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "persist-error")]) { + .persistBacklogFailed(description: error.localizedDescription) + } } } } diff --git a/Where/WhereCore/Sources/Logging/BackupCoordinatorLog.swift b/Where/WhereCore/Sources/Logging/BackupCoordinatorLog.swift new file mode 100644 index 00000000..39f02fdc --- /dev/null +++ b/Where/WhereCore/Sources/Logging/BackupCoordinatorLog.swift @@ -0,0 +1,21 @@ +import PeriscopeCore + +/// Structured events for `BackupCoordinator`. Failing to clear a previous export +/// staging directory is degraded-but-handled housekeeping, so it logs at +/// `.warning`. +enum BackupCoordinatorLog: LogEvent { + case removePreviousExportFailed(description: String) + + static let eventName = "BackupCoordinator" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .removePreviousExportFailed(description): + "Failed to remove previous backup export directory: \(description)" + } + } +} diff --git a/Where/WhereCore/Sources/Logging/BackupServiceLog.swift b/Where/WhereCore/Sources/Logging/BackupServiceLog.swift new file mode 100644 index 00000000..07efaf0b --- /dev/null +++ b/Where/WhereCore/Sources/Logging/BackupServiceLog.swift @@ -0,0 +1,51 @@ +import PeriscopeCore + +/// Structured events for `BackupService`'s archive read/write. Evidence ids ride +/// on `externalID` so a skipped blob traces back to its evidence row. +enum BackupServiceLog: LogEvent { + /// Names the service's timed spans. + enum SpanName: Hashable { + case writeArchive + case readArchive + } + + case wroteBackup( + sampleCount: Int, + evidenceCount: Int, + manualDayCount: Int, + dismissedIssueCount: Int, + trackedRegionCount: Int, + ) + case assetMissing(evidenceID: String) + + static let eventName = "BackupService" + + var level: LogLevel { + switch self { + case .wroteBackup: .info + case .assetMissing: .warning + } + } + + var message: String { + switch self { + case let .wroteBackup( + sampleCount, + evidenceCount, + manualDayCount, + dismissedIssueCount, + trackedRegionCount, + ): + "Wrote backup with \(sampleCount) samples, \(evidenceCount) evidence, \(manualDayCount) manual days, \(dismissedIssueCount) dismissals, \(trackedRegionCount) tracked regions" + case let .assetMissing(evidenceID): + "Backup asset missing for evidence \(evidenceID); skipping blob" + } + } + + var externalID: String? { + switch self { + case let .assetMissing(evidenceID): WhereStoreID.evidence(evidenceID) + case .wroteBackup: nil + } + } +} diff --git a/Where/WhereCore/Sources/Logging/DailySummaryReconcilerLog.swift b/Where/WhereCore/Sources/Logging/DailySummaryReconcilerLog.swift new file mode 100644 index 00000000..3811cf10 --- /dev/null +++ b/Where/WhereCore/Sources/Logging/DailySummaryReconcilerLog.swift @@ -0,0 +1,19 @@ +import PeriscopeCore + +/// Structured events for `DailySummaryReconciler`. +enum DailySummaryReconcilerLog: LogEvent { + case reconcileFailed(description: String) + + static let eventName = "DailySummaryReconciler" + + var level: LogLevel { + .error + } + + var message: String { + switch self { + case let .reconcileFailed(description): + "Failed to reconcile daily summary: \(description)" + } + } +} diff --git a/Where/WhereCore/Sources/Logging/DailySummarySchedulerLog.swift b/Where/WhereCore/Sources/Logging/DailySummarySchedulerLog.swift new file mode 100644 index 00000000..c866de5e --- /dev/null +++ b/Where/WhereCore/Sources/Logging/DailySummarySchedulerLog.swift @@ -0,0 +1,39 @@ +import PeriscopeCore + +/// Structured events for `DailySummaryScheduler` — authorization outcomes and +/// the scheduling of the daily recap notification. +enum DailySummarySchedulerLog: LogEvent { + case authorizationRequestFailed(description: String) + case authorizationNotGranted + case authorizationUnknown + case scheduled(time: String) + case scheduleFailed(description: String) + + static let eventName = "DailySummaryScheduler" + + var level: LogLevel { + switch self { + case .authorizationRequestFailed, .scheduleFailed: + .error + case .authorizationNotGranted, .authorizationUnknown: + .warning + case .scheduled: + .info + } + } + + var message: String { + switch self { + case let .authorizationRequestFailed(description): + "Notification authorization request failed: \(description)" + case .authorizationNotGranted: + "Daily summary enabled but notification authorization not granted; summary disabled" + case .authorizationUnknown: + "Daily summary enabled but notification authorization status is unknown; summary disabled" + case let .scheduled(time): + "Scheduled daily summary at \(time)" + case let .scheduleFailed(description): + "Failed to schedule daily summary: \(description)" + } + } +} diff --git a/Where/WhereCore/Sources/Logging/DataIssueAlertReconcilerLog.swift b/Where/WhereCore/Sources/Logging/DataIssueAlertReconcilerLog.swift new file mode 100644 index 00000000..02d2818e --- /dev/null +++ b/Where/WhereCore/Sources/Logging/DataIssueAlertReconcilerLog.swift @@ -0,0 +1,19 @@ +import PeriscopeCore + +/// Structured events for `DataIssueAlertReconciler`. +enum DataIssueAlertReconcilerLog: LogEvent { + case reconcileFailed(description: String) + + static let eventName = "DataIssueAlertReconciler" + + var level: LogLevel { + .error + } + + var message: String { + switch self { + case let .reconcileFailed(description): + "Failed to reconcile issue alerts: \(description)" + } + } +} diff --git a/Where/WhereCore/Sources/Logging/DataIssueAlertSchedulerLog.swift b/Where/WhereCore/Sources/Logging/DataIssueAlertSchedulerLog.swift new file mode 100644 index 00000000..2cb6e4d6 --- /dev/null +++ b/Where/WhereCore/Sources/Logging/DataIssueAlertSchedulerLog.swift @@ -0,0 +1,39 @@ +import PeriscopeCore + +/// Structured events for `DataIssueAlertScheduler` — authorization outcomes and +/// the scheduling of the "issues to resolve" notification. +enum DataIssueAlertSchedulerLog: LogEvent { + case authorizationRequestFailed(description: String) + case authorizationNotGranted + case authorizationUnknown + case scheduled(time: String) + case scheduleFailed(description: String) + + static let eventName = "DataIssueAlertScheduler" + + var level: LogLevel { + switch self { + case .authorizationRequestFailed, .scheduleFailed: + .error + case .authorizationNotGranted, .authorizationUnknown: + .warning + case .scheduled: + .info + } + } + + var message: String { + switch self { + case let .authorizationRequestFailed(description): + "Notification authorization request failed: \(description)" + case .authorizationNotGranted: + "Issue alerts enabled but notification authorization not granted; alert disabled" + case .authorizationUnknown: + "Issue alerts enabled but notification authorization status is unknown; alert disabled" + case let .scheduled(time): + "Scheduled issue alert at \(time)" + case let .scheduleFailed(description): + "Failed to schedule issue alert: \(description)" + } + } +} diff --git a/Where/WhereCore/Sources/Logging/DayJournalLog.swift b/Where/WhereCore/Sources/Logging/DayJournalLog.swift new file mode 100644 index 00000000..184a2351 --- /dev/null +++ b/Where/WhereCore/Sources/Logging/DayJournalLog.swift @@ -0,0 +1,52 @@ +import PeriscopeCore + +/// Structured events for `DayJournal`'s committed writes. The affected calendar +/// day (or year) rides on `externalID` so the tooling can pull every event +/// about one day. All are successful-operation `.info` events. +enum DayJournalLog: LogEvent { + case addedManualDay(day: String, regionCount: Int) + case overrodeDay(day: String, regionCount: Int) + case clearedManualDay(day: String) + case clearedManualDays(dayCount: Int) + case backfilledManualDays(dayCount: Int, regionCount: Int) + case clearedYear(year: Int) + case erasedAllData + case wroteEvidence(id: String, hasBlob: Bool) + + static let eventName = "DayJournal" + + var message: String { + switch self { + case let .addedManualDay(day, regionCount): + "Added manual day \(day) with \(regionCount) region(s)" + case let .overrodeDay(day, regionCount): + "Overrode day \(day) with \(regionCount) region(s)" + case let .clearedManualDay(day): + "Cleared manual overlay for day \(day)" + case let .clearedManualDays(dayCount): + "Cleared manual overlays for \(dayCount) day(s)" + case let .backfilledManualDays(dayCount, regionCount): + "Backfilled \(dayCount) manual day(s) with \(regionCount) region(s)" + case let .clearedYear(year): + "Cleared year \(year)" + case .erasedAllData: + "Erased all store data" + case let .wroteEvidence(id, hasBlob): + "Wrote evidence \(id) (blob: \(hasBlob))" + } + } + + var externalID: String? { + switch self { + case let .addedManualDay(day, _), let .overrodeDay(day, _), + let .clearedManualDay(day): + WhereStoreID.day(day) + case let .clearedYear(year): + WhereStoreID.year(year) + case let .wroteEvidence(id, _): + WhereStoreID.evidence(id) + case .clearedManualDays, .backfilledManualDays, .erasedAllData: + nil + } + } +} diff --git a/Where/WhereCore/Sources/Logging/LocationIngestorLog.swift b/Where/WhereCore/Sources/Logging/LocationIngestorLog.swift new file mode 100644 index 00000000..7a2c1e89 --- /dev/null +++ b/Where/WhereCore/Sources/Logging/LocationIngestorLog.swift @@ -0,0 +1,70 @@ +import PeriscopeCore + +/// Structured events for `LocationIngestor`'s GPS lifecycle, one-shot capture, +/// and the durable retry queue. Persist failures carry the offending sample id +/// on `externalID` so the tooling can trace one sample across retries. +enum LocationIngestorLog: LogEvent { + case monitoringStarted + case monitoringStopped + case restoredBacklog(count: Int) + case quiesced + case todayIntervalUnavailable + case foregroundCaptureReadFailed(description: String) + case capturedForegroundFix + case persistFailed(sampleID: String, description: String) + case retryQueueAtCapacity(capacity: Int) + case retryStillFailing(sampleID: String, description: String) + case drainedBacklog(sampleCount: Int, dayCount: Int) + + static let eventName = "LocationIngestor" + + var level: LogLevel { + switch self { + case .monitoringStarted, .monitoringStopped, .restoredBacklog, .quiesced, + .capturedForegroundFix, .drainedBacklog: + .info + case .todayIntervalUnavailable, .foregroundCaptureReadFailed, .retryQueueAtCapacity: + .warning + case .persistFailed, .retryStillFailing: + .error + } + } + + var message: String { + switch self { + case .monitoringStarted: + "GPS monitoring started" + case .monitoringStopped: + "GPS monitoring stopped" + case let .restoredBacklog(count): + "Restored \(count) sample(s) from durable retry backlog" + case .quiesced: + "GPS ingestion quiesced; retry backlog cleared" + case .todayIntervalUnavailable: + "Could not compute today's interval for foreground capture" + case let .foregroundCaptureReadFailed(description): + "Skipping foreground capture; could not read today's samples: \(description)" + case .capturedForegroundFix: + "Captured one-shot foreground location for today" + case let .persistFailed(sampleID, description): + "Failed to persist GPS sample \(sampleID): \(description)" + case let .retryQueueAtCapacity(capacity): + "Retry queue at capacity (\(capacity)); dropping oldest queued GPS sample" + case let .retryStillFailing(sampleID, description): + "Retry still failing for GPS sample \(sampleID): \(description)" + case let .drainedBacklog(sampleCount, dayCount): + "Drained retry backlog: persisted \(sampleCount) sample(s) across \(dayCount) day(s)" + } + } + + var externalID: String? { + switch self { + case let .persistFailed(sampleID, _), let .retryStillFailing(sampleID, _): + WhereStoreID.sample(sampleID) + case .monitoringStarted, .monitoringStopped, .restoredBacklog, .quiesced, + .todayIntervalUnavailable, .foregroundCaptureReadFailed, .capturedForegroundFix, + .retryQueueAtCapacity, .drainedBacklog: + nil + } + } +} diff --git a/Where/WhereCore/Sources/Logging/LocationOutboxLog.swift b/Where/WhereCore/Sources/Logging/LocationOutboxLog.swift new file mode 100644 index 00000000..c5aff773 --- /dev/null +++ b/Where/WhereCore/Sources/Logging/LocationOutboxLog.swift @@ -0,0 +1,30 @@ +import PeriscopeCore + +/// Structured events for `FileLocationOutbox`, the durable mirror of the GPS +/// retry queue. A missing Application Support directory is degraded-but-handled +/// (`.warning`); read/write failures are surfaced as `.error`. +enum LocationOutboxLog: LogEvent { + case noApplicationSupport + case droppedUnreadableBacklog(description: String) + case persistBacklogFailed(description: String) + + static let eventName = "LocationOutbox" + + var level: LogLevel { + switch self { + case .noApplicationSupport: .warning + case .droppedUnreadableBacklog, .persistBacklogFailed: .error + } + } + + var message: String { + switch self { + case .noApplicationSupport: + "No Application Support directory; using in-memory retry queue (backlog won't survive relaunch)" + case let .droppedUnreadableBacklog(description): + "Dropping unreadable location retry backlog: \(description)" + case let .persistBacklogFailed(description): + "Failed to persist location retry backlog: \(description)" + } + } +} diff --git a/Where/WhereCore/Sources/Logging/LoggingReminderSchedulerLog.swift b/Where/WhereCore/Sources/Logging/LoggingReminderSchedulerLog.swift new file mode 100644 index 00000000..7562806f --- /dev/null +++ b/Where/WhereCore/Sources/Logging/LoggingReminderSchedulerLog.swift @@ -0,0 +1,42 @@ +import PeriscopeCore + +/// Structured events for `LoggingReminderScheduler` — authorization outcomes and +/// the reconcile of scheduled/removed reminders + badge. +enum LoggingReminderSchedulerLog: LogEvent { + case authorizationRequestFailed(description: String) + case authorizationNotGranted + case authorizationUnknown + case reconciled(scheduled: Int, removed: Int, badge: Int) + case scheduleFailed(identifier: String, description: String) + case badgeUpdateFailed(description: String) + + static let eventName = "LoggingReminderScheduler" + + var level: LogLevel { + switch self { + case .authorizationRequestFailed, .scheduleFailed, .badgeUpdateFailed: + .error + case .authorizationNotGranted, .authorizationUnknown: + .warning + case .reconciled: + .info + } + } + + var message: String { + switch self { + case let .authorizationRequestFailed(description): + "Notification authorization request failed: \(description)" + case .authorizationNotGranted: + "Logging reminders enabled but notification authorization not granted; reminders disabled" + case .authorizationUnknown: + "Logging reminders enabled but notification authorization status is unknown; reminders disabled" + case let .reconciled(scheduled, removed, badge): + "Reconciled logging reminders (scheduled \(scheduled), removed \(removed); badge: \(badge))" + case let .scheduleFailed(identifier, description): + "Failed to schedule reminder \(identifier): \(description)" + case let .badgeUpdateFailed(description): + "Failed to set badge count: \(description)" + } + } +} diff --git a/Where/WhereCore/Sources/Logging/RecentActivitySummarizerLog.swift b/Where/WhereCore/Sources/Logging/RecentActivitySummarizerLog.swift new file mode 100644 index 00000000..086d6108 --- /dev/null +++ b/Where/WhereCore/Sources/Logging/RecentActivitySummarizerLog.swift @@ -0,0 +1,25 @@ +import PeriscopeCore + +/// Structured events for `RecentActivitySummarizer`, the on-device look-back +/// summary. Both outcomes are `.info` — a skip (no samples) and a successful +/// generation. +enum RecentActivitySummarizerLog: LogEvent { + /// Names the summarizer's timed spans. + enum SpanName: Hashable { + case generate + } + + case skippedNoSamples + case generated(segmentCount: Int) + + static let eventName = "RecentActivitySummarizer" + + var message: String { + switch self { + case .skippedNoSamples: + "Recent-activity summary skipped: no samples in window" + case let .generated(segmentCount): + "Recent-activity summary generated from \(segmentCount) segment(s)" + } + } +} diff --git a/Where/WhereCore/Sources/Logging/RegionAttributionLog.swift b/Where/WhereCore/Sources/Logging/RegionAttributionLog.swift new file mode 100644 index 00000000..eddc4309 --- /dev/null +++ b/Where/WhereCore/Sources/Logging/RegionAttributionLog.swift @@ -0,0 +1,21 @@ +import PeriscopeCore + +/// Structured events for `RegionAttribution`, the live attributor rebuild that +/// tracks the user's tracked-region set. A failed read leaves the prior +/// attributor in place, so it's degraded-but-handled (`.warning`). +enum RegionAttributionLog: LogEvent { + case trackedRegionsReadFailed(description: String) + + static let eventName = "RegionAttribution" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .trackedRegionsReadFailed(description): + "Failed to read tracked regions for attributor rebuild: \(description)" + } + } +} diff --git a/Where/WhereCore/Sources/Logging/ReminderReconcilerLog.swift b/Where/WhereCore/Sources/Logging/ReminderReconcilerLog.swift new file mode 100644 index 00000000..91d2ba64 --- /dev/null +++ b/Where/WhereCore/Sources/Logging/ReminderReconcilerLog.swift @@ -0,0 +1,27 @@ +import PeriscopeCore + +/// Structured events for `ReminderReconciler`. Failing to reconcile the schedule +/// is an outright failure (`.error`); failing only the badge scan is +/// degraded-but-handled (`.warning`). +enum ReminderReconcilerLog: LogEvent { + case reconcileFailed(description: String) + case badgeScanFailed(description: String) + + static let eventName = "ReminderReconciler" + + var level: LogLevel { + switch self { + case .reconcileFailed: .error + case .badgeScanFailed: .warning + } + } + + var message: String { + switch self { + case let .reconcileFailed(description): + "Failed to reconcile logging reminders: \(description)" + case let .badgeScanFailed(description): + "Failed to scan data issues for badge: \(description)" + } + } +} diff --git a/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift b/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift new file mode 100644 index 00000000..f2938a5f --- /dev/null +++ b/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift @@ -0,0 +1,51 @@ +import PeriscopeCore + +/// Structured events for `SwiftDataStore` — store open (with the resolved +/// on-disk path / App Group state) and the data-integrity guards. A dropped +/// corrupt record is a programmer error, so it logs at `.fault`. +enum SwiftDataStoreLog: LogEvent { + /// Names the store's timed spans (`log.measure(.open) { … }`). + enum SpanName: Hashable { + case open + } + + /// Opened an in-memory store (tests/previews). + case openedInMemory(mode: String) + /// Opened the on-disk store, reporting whether the App Group container + /// resolved and the resolved database URL. + case openedOnDisk(mode: String, appGroupResolved: Bool, url: String) + /// Ignored tracked-region ids the current catalog doesn't know (a store + /// written by a newer catalog version). The ids persist as a structured + /// list; formatting is done at display time (see `message`). + case ignoredUnknownTrackedRegions(ids: [String]) + /// Ignored primary-region ids the current catalog doesn't know (a store + /// written by a newer catalog version). + case ignoredUnknownPrimaryRegions(ids: [String]) + /// Dropped a record that failed to materialize into a domain value. + case droppedCorruptRecord(type: String) + + static let eventName = "SwiftDataStore" + + var level: LogLevel { + switch self { + case .openedInMemory, .openedOnDisk: .info + case .ignoredUnknownTrackedRegions, .ignoredUnknownPrimaryRegions: .warning + case .droppedCorruptRecord: .fault + } + } + + var message: String { + switch self { + case let .openedInMemory(mode): + "Opened SwiftData store (mode: \(mode))" + case let .openedOnDisk(mode, appGroupResolved, url): + "Opened SwiftData store (mode: \(mode), appGroupResolved: \(appGroupResolved), url: \(url))" + case let .ignoredUnknownTrackedRegions(ids): + "Ignored \(ids.count) unknown tracked-region id(s): \(ids.joined(separator: ", "))" + case let .ignoredUnknownPrimaryRegions(ids): + "Ignored \(ids.count) unknown primary-region id(s): \(ids.joined(separator: ", "))" + case let .droppedCorruptRecord(type): + "Dropped corrupt SwiftData record of type \(type)" + } + } +} diff --git a/Where/WhereCore/Sources/Logging/WhereLog.swift b/Where/WhereCore/Sources/Logging/WhereLog.swift new file mode 100644 index 00000000..9c581fea --- /dev/null +++ b/Where/WhereCore/Sources/Logging/WhereLog.swift @@ -0,0 +1,66 @@ +import PeriscopeCore + +/// Phantom root event naming the Where app's log scope tree. It is never +/// emitted — its only job is to give ``WhereLog``'s root `Log` the scope name +/// `"Where"`, so every app event sits under one filterable subtree in the +/// process-wide `Periscope.shared` system. +public struct WhereRoot: LogEvent { + public static let eventName = "Where" + public var message: String { + "" + } +} + +/// Central logging facade for the Where app and its modules. +/// +/// Every logger derives from one `"Where"` root `Log` and emits into the +/// process-wide Periscope system (``Periscope/shared``). Collaborators that +/// belong together sit under a shared group scope (``location``, ``reminders``, +/// ``backup``, ``widgets``, ``session``, ``evidence``, ``recentActivity``); +/// everything else hangs directly off ``root``. A collaborator derives its own +/// typed leaf — `WhereLog.location(LocationIngestorLog.self)` — so its events +/// carry a structured payload the log viewer can decode, and the loggers form a +/// hierarchy the viewer can filter or inspect by subtree. +/// +/// Upper layers (WhereUI, the extensions) derive their own leaves from the same +/// group/root loggers with event types they define locally, so `WhereLog` never +/// needs to know their event shapes. +public enum WhereLog { + // MARK: Periscope log tree + + /// The `"Where"` root every app logger descends from. + public static let root = Log(system: .shared) + + /// GPS ingestion collaborators (`LocationIngestor`, `LocationOutbox`). + public static let location = group(.location) + /// Notification schedulers/reconcilers (logging reminders, daily summary, + /// data-issue alerts). + public static let reminders = group(.reminders) + /// Backup export/import (`BackupCoordinator`, `BackupService`). + public static let backup = group(.backup) + /// In-app widget snapshot publishing (`WidgetSnapshotPublisher`, + /// `WidgetTimelineRefresher`). + public static let widgets = group(.widgets) + /// The always-on session coordinator and its scope-tiered view models. + public static let session = group(.session) + /// Evidence capture/list/detail view models. + public static let evidence = group(.evidence) + /// On-device recent-activity summarization. + public static let recentActivity = group(.recentActivity) + + private static func group(_ area: Area) -> Log { + root(for: area) + } + + /// The intermediate grouping scopes under ``root``. A plain `Hashable` + /// token whose case name becomes the scope name (`location`, `reminders`, …). + enum Area: Hashable { + case location + case reminders + case backup + case widgets + case session + case evidence + case recentActivity + } +} diff --git a/Where/WhereCore/Sources/Logging/WidgetSnapshotPublisherLog.swift b/Where/WhereCore/Sources/Logging/WidgetSnapshotPublisherLog.swift new file mode 100644 index 00000000..35e40f9d --- /dev/null +++ b/Where/WhereCore/Sources/Logging/WidgetSnapshotPublisherLog.swift @@ -0,0 +1,33 @@ +import PeriscopeCore + +/// Structured events for `WidgetSnapshotPublisher`. A publish records the day +/// and region count; a build failure surfaces as `.error`. +enum WidgetSnapshotPublisherLog: LogEvent { + case published(day: String, regionCount: Int) + case buildFailed(description: String) + + static let eventName = "WidgetSnapshotPublisher" + + var level: LogLevel { + switch self { + case .published: .info + case .buildFailed: .error + } + } + + var message: String { + switch self { + case let .published(day, regionCount): + "Published widget snapshot for \(day) (\(regionCount) region(s))" + case let .buildFailed(description): + "Failed to build widget snapshot: \(description)" + } + } + + var externalID: String? { + switch self { + case let .published(day, _): WhereStoreID.day(day) + case .buildFailed: nil + } + } +} diff --git a/Where/WhereCore/Sources/Logging/WidgetTimelineRefresherLog.swift b/Where/WhereCore/Sources/Logging/WidgetTimelineRefresherLog.swift new file mode 100644 index 00000000..0ff0f2a0 --- /dev/null +++ b/Where/WhereCore/Sources/Logging/WidgetTimelineRefresherLog.swift @@ -0,0 +1,26 @@ +import PeriscopeCore + +/// Structured events for `WidgetCenterTimelineRefresher`, which writes the +/// snapshot to the App Group and reloads WidgetKit timelines. +enum WidgetTimelineRefresherLog: LogEvent { + case wroteSnapshot + case publishFailed(description: String) + + static let eventName = "WidgetRefresher" + + var level: LogLevel { + switch self { + case .wroteSnapshot: .info + case .publishFailed: .error + } + } + + var message: String { + switch self { + case .wroteSnapshot: + "Wrote widget snapshot to App Group; reloading timelines" + case let .publishFailed(description): + "Failed to publish widget snapshot: \(description)" + } + } +} diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index efb7a99e..e86f843c 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import RegionKit import SwiftData @@ -177,9 +177,9 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { /// racing to *create* the store on a fresh install is how the launch /// once failed with `SwiftDataError`). public static func make(storage: Storage = .default) throws -> SwiftDataStore { - let container = try makeContainer(storage: storage) + let container = try logger.measure(.open) { try makeContainer(storage: storage) } if storage == .inMemory { - logger.info("Opened SwiftData store (mode: \(storage))") + logger { .openedInMemory(mode: String(describing: storage)) } } else { // Log the resolved on-disk path and whether the App Group container // is actually reachable at runtime. If the App Group capability isn't @@ -190,9 +190,13 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { let groupResolved = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) != nil let url = container.configurations.first?.url.path(percentEncoded: false) ?? "unknown" - logger.info( - "Opened SwiftData store (mode: \(storage), appGroupResolved: \(groupResolved), url: \(url))", - ) + logger { + .openedOnDisk( + mode: String(describing: storage), + appGroupResolved: groupResolved, + url: url, + ) + } } let store = SwiftDataStore(modelContainer: container) // On-disk stores live in a shared App Group container, so another process @@ -251,7 +255,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { ] } - private static let logger = WhereLog.channel(.swiftDataStore) + private static let logger = WhereLog.root(SwiftDataStoreLog.self) /// Fans "committed data changed" pings to `changes()` subscribers. Fired /// once per outermost `perform` commit (see `perform`). @@ -742,9 +746,9 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } } if !unknown.isEmpty { - Self.logger.warning( - "Ignored \(unknown.count) unknown tracked-region id(s): \(unknown.sorted().joined(separator: ", "))", - ) + Self.logger { + .ignoredUnknownTrackedRegions(ids: unknown.sorted()) + } } return resolved } @@ -819,9 +823,9 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { )) } if !unknown.isEmpty { - Self.logger.warning( - "Ignored \(unknown.count) unknown primary-region id(s): \(unknown.sorted().joined(separator: ", "))", - ) + Self.logger { + .ignoredUnknownPrimaryRegions(ids: unknown.sorted()) + } } return resolved } @@ -858,9 +862,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } private static func logFault(forCorrupt _: Record) { - logger.fault( - "Dropped corrupt SwiftData record of type \(String(describing: Record.self))", - ) + logger { .droppedCorruptRecord(type: String(describing: Record.self)) } } } diff --git a/Where/WhereCore/Sources/Persistence/WhereStoreID.swift b/Where/WhereCore/Sources/Persistence/WhereStoreID.swift new file mode 100644 index 00000000..5a71be9a --- /dev/null +++ b/Where/WhereCore/Sources/Persistence/WhereStoreID.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Canonical `store://` identities for the Where store's object families, +/// vended as the string form used for Periscope `LogEvent.externalID`s. Keying +/// a log event's `externalID` on the same identity the store and backups use +/// lets the log tooling's inspect-by-object pull every event about one row +/// (a day, a year, an evidence item, a GPS sample) under one namespaced, +/// collision-free key — a bare `"2025"` year and an evidence UUID can't clash +/// the way flat strings in one index could. +/// +/// Mirrors ``DataIssueID``'s use of ``StoreURL`` (`store://issues/…`), which is +/// the typed exemplar: a value with a persisted identity conforms to +/// ``WhereStoreURLCodable`` and vends its own `storeURL`. These families don't +/// have (or don't yet need) a dedicated identity type, so their identity lives +/// here instead. Each is a single-identity object, so the identifying value +/// sits in the URL's `type` position (`store:///`); the +/// values are UUID strings, ISO `CalendarDay` strings, or years — all +/// path-safe. +/// +/// RegionKit deliberately does **not** use this: it sits below WhereCore (must +/// not import app code) and its regions are a bundled catalog, not store rows, +/// so `RegionAttributorLog` keeps its bare, already-stable catalog id as its +/// `externalID`. +public enum WhereStoreID { + /// `store://days/` for a logical calendar day (its `CalendarDay` + /// `description`, e.g. `2026-06-05`). + public static func day(_ isoDay: String) -> String { + StoreURL.url(collection: "days", type: isoDay, items: [:]).absoluteString + } + + /// `store://years/` for a whole year's data/scope (no `SDYear` row + /// backs it — it's the synthetic identity the year-scoped reads share). + public static func year(_ year: Int) -> String { + StoreURL.url(collection: "years", type: String(year), items: [:]).absoluteString + } + + /// `store://evidence/` for an `Evidence` row (its `id.uuidString`). + public static func evidence(_ id: String) -> String { + StoreURL.url(collection: "evidence", type: id, items: [:]).absoluteString + } + + /// `store://samples/` for a `LocationSample` (its `id.uuidString`). + public static func sample(_ id: String) -> String { + StoreURL.url(collection: "samples", type: id, items: [:]).absoluteString + } +} diff --git a/Where/WhereCore/Sources/RecentActivity/FoundationModelSummaryGenerator.swift b/Where/WhereCore/Sources/RecentActivity/FoundationModelSummaryGenerator.swift index 8060e475..cadc7009 100644 --- a/Where/WhereCore/Sources/RecentActivity/FoundationModelSummaryGenerator.swift +++ b/Where/WhereCore/Sources/RecentActivity/FoundationModelSummaryGenerator.swift @@ -1,6 +1,5 @@ import Foundation import FoundationModels -import LogKit /// On-device `ActivitySummaryGenerating` backed by Apple's Foundation Models. /// Runs entirely on device (no network, no data leaves the phone), which suits @@ -8,8 +7,6 @@ import LogKit /// `ActivitySummaryUnavailableError` when the system model can't run so the UI /// can guide the user rather than showing a generic failure. public struct FoundationModelSummaryGenerator: ActivitySummaryGenerating { - private static let logger = WhereLog.channel(.recentActivitySummarizer) - public init() {} public func summarize(_ input: RecentActivityInput) async throws -> String { diff --git a/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift b/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift index 68915f1e..59c23c36 100644 --- a/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift +++ b/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import RegionKit /// One attributed reading in a recent-activity window: when the device was @@ -105,7 +105,7 @@ public actor RecentActivitySummarizer { private let now: @Sendable () -> Date private let segmentLimit: Int - private static let logger = WhereLog.channel(.recentActivitySummarizer) + private static let logger = WhereLog.recentActivity(RecentActivitySummarizerLog.self) init( store: any WhereStore, @@ -132,7 +132,7 @@ public actor RecentActivitySummarizer { let interval = window.interval(now: now(), calendar: calendar) let samples = try await store.samples(in: interval) guard !samples.isEmpty else { - Self.logger.info("Recent-activity summary skipped: no samples in window") + Self.logger { .skippedNoSamples } return .empty } let stops = samples.map { sample in @@ -143,12 +143,12 @@ public actor RecentActivitySummarizer { ) } let segments = Self.segments(from: stops, limit: segmentLimit) - let text = try await generator.summarize( - RecentActivityInput(interval: interval, segments: segments), - ) - Self.logger.info( - "Recent-activity summary generated from \(segments.count) segment(s)", - ) + let text = try await Self.logger.measure(.generate) { + try await generator.summarize( + RecentActivityInput(interval: interval, segments: segments), + ) + } + Self.logger { .generated(segmentCount: segments.count) } return .summary(text) } diff --git a/Where/WhereCore/Sources/RegionAttribution.swift b/Where/WhereCore/Sources/RegionAttribution.swift index 0b4a6b6c..8cfb8d06 100644 --- a/Where/WhereCore/Sources/RegionAttribution.swift +++ b/Where/WhereCore/Sources/RegionAttribution.swift @@ -1,5 +1,6 @@ import Foundation import os +import PeriscopeCore import RegionKit /// A live, swappable `RegionAttributing` derived from the store's tracked @@ -17,7 +18,7 @@ final class RegionAttribution: RegionAttributing { var trackedIDs: Set } - private static let logger = WhereLog.channel(.regionAttribution) + private static let logger = WhereLog.root(RegionAttributionLog.self) private let store: any WhereStore private let state: OSAllocatedUnfairLock @@ -76,7 +77,7 @@ final class RegionAttribution: RegionAttributing { // Degraded-but-handled: keep the last-good attributor rather than // silently freezing on an empty/stale set, and surface the failure so // a persistent read error is observable instead of invisible. - Self.logger.warning("Failed to read tracked regions for attributor rebuild: \(error)") + Self.logger { .trackedRegionsReadFailed(description: String(describing: error)) } return } let ids = Set(tracked.map(\.rawValue)) diff --git a/Where/WhereCore/Sources/Reminders/DailySummaryReconciler.swift b/Where/WhereCore/Sources/Reminders/DailySummaryReconciler.swift index 51aaf24b..05761d2d 100644 --- a/Where/WhereCore/Sources/Reminders/DailySummaryReconciler.swift +++ b/Where/WhereCore/Sources/Reminders/DailySummaryReconciler.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import RegionKit /// Owns the daily summary recap intent and the reconciliation that recomputes @@ -22,7 +22,7 @@ public actor DailySummaryReconciler { /// body stays short. static let defaultRegionLimit = 3 - private static let logger = WhereLog.channel(.dailySummaryReconciler) + private static let logger = WhereLog.reminders(DailySummaryReconcilerLog.self) init( scheduler: any DailySummaryScheduling, @@ -68,9 +68,9 @@ public actor DailySummaryReconciler { body: summaryBody(for: report), ) } catch { - Self.logger.error( - "Failed to reconcile daily summary: \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "reconcile-error")]) { + .reconcileFailed(description: error.localizedDescription) + } } } diff --git a/Where/WhereCore/Sources/Reminders/DailySummaryScheduler.swift b/Where/WhereCore/Sources/Reminders/DailySummaryScheduler.swift index ad1edda5..6be10731 100644 --- a/Where/WhereCore/Sources/Reminders/DailySummaryScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/DailySummaryScheduler.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import UserNotifications /// Schedules the single repeating local notification that gives the user a @@ -56,7 +56,7 @@ public final class UserNotificationDailySummaryScheduler: DailySummaryScheduling /// One repeating notification, so a single stable identifier is enough. private static let identifier = "com.stuff.where.daily-summary" - private static let logger = WhereLog.channel(.dailySummaryScheduler) + private static let logger = WhereLog.reminders(DailySummarySchedulerLog.self) public init(center: UNUserNotificationCenter = .current()) { self.center = UNUserNotificationCenterAdapter(center: center) @@ -70,9 +70,7 @@ public final class UserNotificationDailySummaryScheduler: DailySummaryScheduling do { return try await center.requestAuthorization(options: [.alert, .sound, .badge]) } catch { - Self.logger.error( - "Notification authorization request failed: \(error.localizedDescription)", - ) + Self.logger { .authorizationRequestFailed(description: error.localizedDescription) } return false } } @@ -98,15 +96,11 @@ public final class UserNotificationDailySummaryScheduler: DailySummaryScheduling case .authorized, .provisional, .ephemeral: break case .notDetermined, .denied: - Self.logger.warning( - "Daily summary enabled but notification authorization not granted; summary disabled", - ) + Self.logger { .authorizationNotGranted } await removeAllOwned() return @unknown default: - Self.logger.warning( - "Daily summary enabled but notification authorization status is unknown; summary disabled", - ) + Self.logger { .authorizationUnknown } await removeAllOwned() return } @@ -148,13 +142,13 @@ public final class UserNotificationDailySummaryScheduler: DailySummaryScheduling ) do { try await center.add(request) - Self.logger.info( - "Scheduled daily summary at \(String(format: "%02d:%02d", time.hour, time.minute))", - ) + Self.logger { + .scheduled(time: String(format: "%02d:%02d", time.hour, time.minute)) + } } catch { - Self.logger.error( - "Failed to schedule daily summary: \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "schedule-error")]) { + .scheduleFailed(description: error.localizedDescription) + } } } diff --git a/Where/WhereCore/Sources/Reminders/DataIssueAlertReconciler.swift b/Where/WhereCore/Sources/Reminders/DataIssueAlertReconciler.swift index 6c07771c..b16513f3 100644 --- a/Where/WhereCore/Sources/Reminders/DataIssueAlertReconciler.swift +++ b/Where/WhereCore/Sources/Reminders/DataIssueAlertReconciler.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore /// Owns the "you have issues to resolve" notification intent and the /// reconciliation that keeps that notification in sync with the current year's @@ -21,7 +21,7 @@ public actor DataIssueAlertReconciler { var driftThresholdMeters = Double(DriftThreshold.default.rawValue) } - private static let logger = WhereLog.channel(.dataIssueAlertReconciler) + private static let logger = WhereLog.reminders(DataIssueAlertReconcilerLog.self) init( scheduler: any DataIssueAlertScheduling, @@ -72,9 +72,9 @@ public actor DataIssueAlertReconciler { body: Self.body(count: count), ) } catch { - Self.logger.error( - "Failed to reconcile issue alerts: \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "reconcile-error")]) { + .reconcileFailed(description: error.localizedDescription) + } } } diff --git a/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift b/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift index 80fb41c3..7ddabc71 100644 --- a/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import UserNotifications /// Schedules the single local notification that nudges the user to open the @@ -65,7 +65,7 @@ public final class UserNotificationDataIssueAlertScheduler: DataIssueAlertSchedu /// One repeating notification, so a single stable identifier is enough. private static let identifier = "com.stuff.where.data-issues" - private static let logger = WhereLog.channel(.dataIssueAlertScheduler) + private static let logger = WhereLog.reminders(DataIssueAlertSchedulerLog.self) public init(center: UNUserNotificationCenter = .current()) { self.center = UNUserNotificationCenterAdapter(center: center) @@ -79,9 +79,7 @@ public final class UserNotificationDataIssueAlertScheduler: DataIssueAlertSchedu do { return try await center.requestAuthorization(options: [.alert, .sound, .badge]) } catch { - Self.logger.error( - "Notification authorization request failed: \(error.localizedDescription)", - ) + Self.logger { .authorizationRequestFailed(description: error.localizedDescription) } return false } } @@ -113,15 +111,11 @@ public final class UserNotificationDataIssueAlertScheduler: DataIssueAlertSchedu case .authorized, .provisional, .ephemeral: break case .notDetermined, .denied: - Self.logger.warning( - "Issue alerts enabled but notification authorization not granted; alert disabled", - ) + Self.logger { .authorizationNotGranted } await removeAllOwned() return @unknown default: - Self.logger.warning( - "Issue alerts enabled but notification authorization status is unknown; alert disabled", - ) + Self.logger { .authorizationUnknown } await removeAllOwned() return } @@ -163,13 +157,13 @@ public final class UserNotificationDataIssueAlertScheduler: DataIssueAlertSchedu ) do { try await center.add(request) - Self.logger.info( - "Scheduled issue alert at \(String(format: "%02d:%02d", time.hour, time.minute))", - ) + Self.logger { + .scheduled(time: String(format: "%02d:%02d", time.hour, time.minute)) + } } catch { - Self.logger.error( - "Failed to schedule issue alert: \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "schedule-error")]) { + .scheduleFailed(description: error.localizedDescription) + } } } diff --git a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift index 27c334c3..d6aae07d 100644 --- a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import UserNotifications /// Time of day (in the user's calendar) at which the daily "log before the day @@ -92,7 +92,7 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, private let calendar: Calendar private static let identifierPrefix = "com.stuff.where.logging-reminder" - private static let logger = WhereLog.channel(.loggingReminderScheduler) + private static let logger = WhereLog.reminders(LoggingReminderSchedulerLog.self) public init( center: UNUserNotificationCenter = .current(), @@ -118,9 +118,7 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, do { return try await center.requestAuthorization(options: [.alert, .sound, .badge]) } catch { - Self.logger.error( - "Notification authorization request failed: \(error.localizedDescription)", - ) + Self.logger { .authorizationRequestFailed(description: error.localizedDescription) } return false } } @@ -155,16 +153,12 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, case .authorized, .provisional, .ephemeral: break case .notDetermined, .denied: - Self.logger.warning( - "Logging reminders enabled but notification authorization not granted; reminders disabled", - ) + Self.logger { .authorizationNotGranted } await removeAllOwnedReminders() await setBadge(0) return @unknown default: - Self.logger.warning( - "Logging reminders enabled but notification authorization status is unknown; reminders disabled", - ) + Self.logger { .authorizationUnknown } await removeAllOwnedReminders() await setBadge(0) return @@ -213,9 +207,13 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, // every launch/foreground and after every user write, so a no-op // reconcile (the common case) stays quiet. if !pendingToRemove.isEmpty || !staleDelivered.isEmpty || !toSchedule.isEmpty { - Self.logger.info( - "Reconciled logging reminders (scheduled \(toSchedule.count), removed \(pendingToRemove.count); badge: \(badgeCount))", - ) + Self.logger { + .reconciled( + scheduled: toSchedule.count, + removed: pendingToRemove.count, + badge: badgeCount, + ) + } } } @@ -238,9 +236,9 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, do { try await center.add(request) } catch { - Self.logger.error( - "Failed to schedule reminder \(identifier): \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "schedule-error")]) { + .scheduleFailed(identifier: identifier, description: error.localizedDescription) + } } } @@ -272,9 +270,7 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, do { try await center.setBadgeCount(max(0, count)) } catch { - Self.logger.error( - "Failed to set badge count: \(error.localizedDescription)", - ) + Self.logger { .badgeUpdateFailed(description: error.localizedDescription) } } } diff --git a/Where/WhereCore/Sources/Reminders/ReminderReconciler.swift b/Where/WhereCore/Sources/Reminders/ReminderReconciler.swift index ef33c7a9..fd812724 100644 --- a/Where/WhereCore/Sources/Reminders/ReminderReconciler.swift +++ b/Where/WhereCore/Sources/Reminders/ReminderReconciler.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import RegionKit /// Owns the daily "log before the day ends" reminder intent and the @@ -43,7 +43,7 @@ public actor ReminderReconciler { /// this many days. static let defaultWindowDays = 6 - private static let logger = WhereLog.channel(.reminderReconciler) + private static let logger = WhereLog.reminders(ReminderReconcilerLog.self) init( scheduler: any LoggingReminderScheduling, @@ -166,9 +166,9 @@ public actor ReminderReconciler { ? today : nil } catch { - Self.logger.error( - "Failed to reconcile logging reminders: \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "reconcile-error")]) { + .reconcileFailed(description: error.localizedDescription) + } } } @@ -191,9 +191,7 @@ public actor ReminderReconciler { driftThresholdMeters: config.driftThresholdMeters, ) } catch { - Self.logger.warning( - "Failed to scan data issues for badge: \(error.localizedDescription)", - ) + Self.logger { .badgeScanFailed(description: error.localizedDescription) } return 0 } } diff --git a/Where/WhereCore/Sources/WhereLog.swift b/Where/WhereCore/Sources/WhereLog.swift deleted file mode 100644 index 215abd34..00000000 --- a/Where/WhereCore/Sources/WhereLog.swift +++ /dev/null @@ -1,51 +0,0 @@ -import LogKit - -/// Central logging facade for the Where app and its modules. Every logger site -/// routes through ``channel(_:)`` so messages reach both Apple unified logging -/// (Console.app, subsystem `com.stuff.where`) and the shared in-memory -/// ``LogStore`` the in-app debug log viewer reads (DEBUG builds only). -/// -/// Categories are a typed enum rather than raw strings so a new logger can't -/// silently typo into an untracked category; the raw values match the -/// historical `os.Logger` category strings exactly, keeping Console.app filters -/// working unchanged. -public enum WhereLog { - /// The subsystem every Where log shares. - public static let subsystem = "com.stuff.where" - - /// Process-wide buffer feeding the in-app log viewer. Logging is inherently - /// process-global, so a single shared store is the natural home. The widget - /// extension runs in its own process and therefore has a distinct instance. - public static let store = LogStore() - - public enum Category: String, CaseIterable, Sendable { - case appDelegate = "AppDelegate" - case backupService = "BackupService" - case dailySummaryReconciler = "DailySummaryReconciler" - case dailySummaryScheduler = "DailySummaryScheduler" - case dataIssueAlertReconciler = "DataIssueAlertReconciler" - case dataIssueAlertScheduler = "DataIssueAlertScheduler" - case dayJournal = "DayJournal" - case evidence = "Evidence" - case launch = "WhereLaunch" - case locationIngestor = "LocationIngestor" - case locationOutbox = "LocationOutbox" - case loggingReminderScheduler = "LoggingReminderScheduler" - case model = "WhereModel" - case recentActivitySummarizer = "RecentActivitySummarizer" - case regionAttribution = "RegionAttribution" - case reminderReconciler = "ReminderReconciler" - case session = "WhereSession" - case shareExtension = "WhereShareExtension" - case swiftDataStore = "SwiftDataStore" - case whereIntents = "WhereIntents" - case widgetRefresher = "WidgetRefresher" - case widgetSnapshotPublisher = "WidgetSnapshotPublisher" - case whereWidgets = "WhereWidgets" - } - - /// A logging channel for `category`, wired to the shared buffer. - public static func channel(_ category: Category) -> LogChannel { - LogChannel(subsystem: subsystem, category: category.rawValue, store: store) - } -} diff --git a/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift b/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift index be06b5a3..59ddacde 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import RegionKit /// Owns the published widget snapshot and the policy for when to rebuild it. @@ -37,7 +37,7 @@ public actor WidgetSnapshotPublisher { /// the store and reload widgets. static let defaultMaxAge: TimeInterval = 3 * 60 * 60 - private static let logger = WhereLog.channel(.widgetSnapshotPublisher) + private static let logger = WhereLog.widgets(WidgetSnapshotPublisherLog.self) init( widgetReader: WidgetDataReader, @@ -80,13 +80,14 @@ public actor WidgetSnapshotPublisher { let snapshot = try await widgetReader.snapshot(asOf: now()) await widgetRefresher.publish(snapshot) lastPublished = PublishedWidgetSnapshot(snapshot: snapshot, publishedAt: now()) - Self.logger.info( - "Published widget snapshot for \(dayLogLabel(snapshot.day)) (\(snapshot.dayRegions.count) region(s))", - ) + Self.logger { + .published( + day: dayLogLabel(snapshot.day), + regionCount: snapshot.dayRegions.count, + ) + } } catch { - Self.logger.error( - "Failed to build widget snapshot: \(error.localizedDescription)", - ) + Self.logger { .buildFailed(description: error.localizedDescription) } } } diff --git a/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift b/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift index 1f1d432c..6e854221 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift @@ -1,4 +1,4 @@ -import LogKit +import PeriscopeCore import WidgetKit /// Publishes a freshly-computed `WidgetSnapshot` for the widget extension @@ -26,18 +26,16 @@ public struct NoopWidgetTimelineRefresher: WidgetTimelineRefreshing { /// every kind rather than per-kind because all Where widgets render from the /// same snapshot, so any committed change can affect all of them. public struct WidgetCenterTimelineRefresher: WidgetTimelineRefreshing { - private static let logger = WhereLog.channel(.widgetRefresher) + private static let logger = WhereLog.widgets(WidgetTimelineRefresherLog.self) public init() {} public func publish(_ snapshot: WidgetSnapshot) async { do { try WidgetSnapshotStore.shared().write(snapshot) - Self.logger.info("Wrote widget snapshot to App Group; reloading timelines") + Self.logger { .wroteSnapshot } } catch { - Self.logger.error( - "Failed to publish widget snapshot: \(error.localizedDescription)", - ) + Self.logger { .publishFailed(description: error.localizedDescription) } } WidgetCenter.shared.reloadAllTimelines() } diff --git a/Where/WhereCore/Tests/Logging/WhereLogTests.swift b/Where/WhereCore/Tests/Logging/WhereLogTests.swift new file mode 100644 index 00000000..8b511a54 --- /dev/null +++ b/Where/WhereCore/Tests/Logging/WhereLogTests.swift @@ -0,0 +1,92 @@ +import PeriscopeCore +import Testing +@testable import WhereCore + +// MARK: - Periscope log tree + +/// Covers `WhereLog`'s Periscope scope tree: the `"Where"` root, the grouping +/// scopes, and that collaborator leaves descend from the right parent. +struct WhereLogTreeTests { + @Test func rootScopeIsWhere() { + #expect(WhereLog.root.primaryScope.name == "Where") + } + + @Test func groupScopesDescendFromTheRoot() { + let rootID = WhereLog.root.primaryScope.id + #expect(WhereLog.location.primaryScope.name == "location") + #expect(WhereLog.location.primaryScope.parentID == rootID) + #expect(WhereLog.reminders.primaryScope.name == "reminders") + #expect(WhereLog.reminders.primaryScope.parentID == rootID) + #expect(WhereLog.backup.primaryScope.name == "backup") + #expect(WhereLog.widgets.primaryScope.name == "widgets") + #expect(WhereLog.session.primaryScope.name == "session") + #expect(WhereLog.evidence.primaryScope.name == "evidence") + #expect(WhereLog.recentActivity.primaryScope.name == "recentActivity") + } + + @Test func collaboratorLeavesDescendFromTheirGroup() { + let ingestor = WhereLog.location(LocationIngestorLog.self) + #expect(ingestor.primaryScope.name == "LocationIngestor") + #expect(ingestor.primaryScope.parentID == WhereLog.location.primaryScope.id) + } + + @Test func directLeavesDescendFromTheRoot() { + let store = WhereLog.root(SwiftDataStoreLog.self) + #expect(store.primaryScope.name == "SwiftDataStore") + #expect(store.primaryScope.parentID == WhereLog.root.primaryScope.id) + } +} + +// MARK: - Event rendering + +/// Spot-checks representative collaborator events: rendered message, severity, +/// stable persisted name, and `externalID` correlation. +struct WhereLogEventTests { + @Test func dayJournalStampsTheAffectedDayAsExternalID() { + // externalIDs are the canonical store:// identities (see WhereStoreIDTests + // for the exact URL strings), so inspect-by-object shares the store's keys. + #expect(DayJournalLog.addedManualDay(day: "2026-06-05", regionCount: 2) + .externalID == WhereStoreID.day("2026-06-05")) + #expect(DayJournalLog.clearedYear(year: 2025).externalID == WhereStoreID.year(2025)) + #expect(DayJournalLog.wroteEvidence(id: "abc", hasBlob: true) + .externalID == WhereStoreID.evidence("abc")) + #expect(DayJournalLog.erasedAllData.externalID == nil) + #expect(DayJournalLog.addedManualDay(day: "d", regionCount: 2).level == .info) + } + + @Test func swiftDataStoreCorruptionIsAFault() { + #expect(SwiftDataStoreLog.droppedCorruptRecord(type: "SDEvidence").level == .fault) + #expect(SwiftDataStoreLog.openedInMemory(mode: "inMemory").level == .info) + #expect( + SwiftDataStoreLog.ignoredUnknownTrackedRegions(ids: ["zz"]).level == .warning, + ) + #expect( + SwiftDataStoreLog.ignoredUnknownTrackedRegions(ids: ["us-CA", "us-NY"]) + .message.contains("us-CA, us-NY"), + ) + } + + @Test func locationIngestorTracesSampleFailuresByID() { + #expect( + LocationIngestorLog.persistFailed(sampleID: "abc", description: "x") + .externalID == WhereStoreID.sample("abc"), + ) + #expect(LocationIngestorLog.persistFailed(sampleID: "abc", description: "x") + .level == .error) + #expect(LocationIngestorLog.monitoringStarted.externalID == nil) + } + + @Test func schedulerAuthorizationOutcomesUseHonestLevels() { + #expect(LoggingReminderSchedulerLog.authorizationNotGranted.level == .warning) + #expect( + LoggingReminderSchedulerLog.authorizationRequestFailed(description: "x") + .level == .error, + ) + #expect(LoggingReminderSchedulerLog.reconciled(scheduled: 1, removed: 0, badge: 2) + .level == .info) + } + + @Test func widgetRefresherKeepsItsHistoricalEventName() { + #expect(WidgetTimelineRefresherLog.eventName == "WidgetRefresher") + } +} diff --git a/Where/WhereCore/Tests/WhereLogTests.swift b/Where/WhereCore/Tests/WhereLogTests.swift deleted file mode 100644 index 7a3e3e9e..00000000 --- a/Where/WhereCore/Tests/WhereLogTests.swift +++ /dev/null @@ -1,38 +0,0 @@ -import LogKit -import Testing -@testable import WhereCore - -@Test -func channelUsesSharedSubsystemAndCategory() throws { - let store = LogStore() - let channel = LogChannel( - subsystem: WhereLog.subsystem, - category: WhereLog.Category.locationIngestor.rawValue, - store: store, - ) - channel.error("boom") - - let entry = try #require(store.snapshot().first) - #expect(entry.subsystem == "com.stuff.where") - #expect(entry.category == "LocationIngestor") -} - -@Test -func categoryRawValuesMatchTypeNames() { - // Raw values must stay equal to the historical os.Logger category strings - // so Console.app filters keep working after the migration. - #expect(WhereLog.Category.swiftDataStore.rawValue == "SwiftDataStore") - #expect(WhereLog.Category.widgetRefresher.rawValue == "WidgetRefresher") - #expect(WhereLog.Category.recentActivitySummarizer.rawValue == "RecentActivitySummarizer") - #expect(WhereLog.Category.shareExtension.rawValue == "WhereShareExtension") - #expect(WhereLog.Category.whereIntents.rawValue == "WhereIntents") - #expect(WhereLog.Category.regionAttribution.rawValue == "RegionAttribution") - #expect(WhereLog.Category.allCases.count == 23) -} - -@Test -func channelFactoryRecordsIntoSharedStore() { - let before = WhereLog.store.snapshot().count - WhereLog.channel(.backupService).info("wrote backup") - #expect(WhereLog.store.snapshot().count == before + 1) -} diff --git a/Where/WhereCore/Tests/WhereStoreIDTests.swift b/Where/WhereCore/Tests/WhereStoreIDTests.swift new file mode 100644 index 00000000..cffd8652 --- /dev/null +++ b/Where/WhereCore/Tests/WhereStoreIDTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing +@testable import WhereCore + +/// Pins the exact `store://` identity strings ``WhereStoreID`` vends for log +/// `externalID`s, so the on-disk correlation key can't drift silently. +struct WhereStoreIDTests { + @Test func dayIsCollectionAndISODay() { + #expect(WhereStoreID.day("2026-06-05") == "store://days/2026-06-05") + } + + @Test func yearIsCollectionAndYear() { + #expect(WhereStoreID.year(2025) == "store://years/2025") + } + + @Test func evidenceIsCollectionAndID() { + let id = "5E1645CB-696E-4441-8154-5977E28251B8" + #expect(WhereStoreID.evidence(id) == "store://evidence/\(id)") + } + + @Test func sampleIsCollectionAndID() { + let id = "B12614F7-46AD-41CF-B3DE-6BD3C0C4822B" + #expect(WhereStoreID.sample(id) == "store://samples/\(id)") + } + + /// The identities are well-formed `store://` URLs `StoreURL` can parse back, + /// so the same scheme the store/backups use round-trips. + @Test func identitiesParseAsStoreURLs() throws { + let url = try #require(URL(string: WhereStoreID.day("2026-06-05"))) + let parts = try #require(StoreURL.parts(of: url)) + #expect(parts.collection == "days") + #expect(parts.type == "2026-06-05") + #expect(parts.items.isEmpty) + } +} diff --git a/Where/WhereIntents/README.md b/Where/WhereIntents/README.md index 4c28ebc2..aab77872 100644 --- a/Where/WhereIntents/README.md +++ b/Where/WhereIntents/README.md @@ -89,7 +89,7 @@ its day-count query. `WhereIntents` is an SPM library target in [`Package.swift`](../../Package.swift) (`Where/WhereIntents/Sources`) depending on **WhereCore**, **RegionKit**, -**LogKit**, and **WhereUI** (for the snippet cards). The **Where** app links it; +**PeriscopeCore**, and **WhereUI** (for the snippet cards). The **Where** app links it; the hosted `WhereIntentsTests` bundle is wired in [`Project.swift`](../../Project.swift) via the `unitTests` helper. diff --git a/Where/WhereIntents/Sources/Logging/WhereIntentsLog.swift b/Where/WhereIntents/Sources/Logging/WhereIntentsLog.swift new file mode 100644 index 00000000..a5e00fe9 --- /dev/null +++ b/Where/WhereIntents/Sources/Logging/WhereIntentsLog.swift @@ -0,0 +1,38 @@ +import PeriscopeCore + +/// Structured events for the Where App Intents surface — Spotlight indexing of +/// the tracked regions and the recent-activity summary intent. These run in the +/// app/intents process, which keeps `Periscope.shared` OSLog-only (no +/// persistent store of its own). +enum WhereIntentsLog: LogEvent { + /// The tracked regions were indexed into Spotlight. + case spotlightIndexed(regionCount: Int) + /// Indexing the tracked regions into Spotlight failed + /// (degraded-but-handled: search integration is a nicety). + case spotlightIndexFailed(description: String) + /// The recent-activity summary couldn't be produced (e.g. Apple + /// Intelligence is off or the model is warming). + case recentActivityUnavailable(reason: String) + + static let eventName = "WhereIntents" + + var level: LogLevel { + switch self { + case .spotlightIndexed: + .info + case .spotlightIndexFailed, .recentActivityUnavailable: + .warning + } + } + + var message: String { + switch self { + case let .spotlightIndexed(regionCount): + "Indexed \(regionCount) region(s) for Spotlight" + case let .spotlightIndexFailed(description): + "Failed to index regions for Spotlight: \(description)" + case let .recentActivityUnavailable(reason): + "Recent-activity summary unavailable: \(reason)" + } + } +} diff --git a/Where/WhereIntents/Sources/RecentActivitySummaryIntent.swift b/Where/WhereIntents/Sources/RecentActivitySummaryIntent.swift index f3f46f09..b8ea78a8 100644 --- a/Where/WhereIntents/Sources/RecentActivitySummaryIntent.swift +++ b/Where/WhereIntents/Sources/RecentActivitySummaryIntent.swift @@ -1,5 +1,6 @@ import AppIntents import Foundation +import PeriscopeCore import WhereCore /// "Summarize where I've been this week." — the on-device Foundation Models @@ -26,7 +27,7 @@ public struct RecentActivitySummaryIntent: AppIntent { self.window = window } - private static let logger = WhereLog.channel(.whereIntents) + private static let logger = WhereLog.root(WhereIntentsLog.self) public func perform() async throws -> some IntentResult & ProvidesDialog { let services = try await intentServices.current() @@ -41,9 +42,7 @@ public struct RecentActivitySummaryIntent: AppIntent { } catch let error as ActivitySummaryUnavailableError { // User-recoverable (Apple Intelligence off, model warming): surface // the reason in the dialog and log it — never a silent empty result. - Self.logger.warning( - "Recent-activity summary unavailable: \(String(describing: error.reason))", - ) + Self.logger { .recentActivityUnavailable(reason: String(describing: error.reason)) } return .result( dialog: IntentDialog("\(IntentStrings.recentActivityUnavailable(error.reason))"), ) diff --git a/Where/WhereIntents/Sources/RegionEntity+Spotlight.swift b/Where/WhereIntents/Sources/RegionEntity+Spotlight.swift index 8ba006b9..d677e66f 100644 --- a/Where/WhereIntents/Sources/RegionEntity+Spotlight.swift +++ b/Where/WhereIntents/Sources/RegionEntity+Spotlight.swift @@ -1,5 +1,6 @@ import AppIntents import CoreSpotlight +import PeriscopeCore import RegionKit import WhereCore @@ -13,7 +14,7 @@ extension RegionEntity: IndexedEntity {} /// (see the app's `AppDelegate`); indexing a handful of items is cheap and /// idempotent, and re-runs pick up any change to the tracked set. public enum RegionSpotlightIndexer { - private static let logger = WhereLog.channel(.whereIntents) + private static let logger = WhereLog.root(WhereIntentsLog.self) /// Not system-instantiated (the app calls this), so the services handoff /// arrives by plain injection rather than `@Dependency`. @@ -21,11 +22,13 @@ public enum RegionSpotlightIndexer { do { let entities = try await RegionEntity.tracked(from: intentServices.current()) try await CSSearchableIndex.default().indexAppEntities(entities) - logger.info("Indexed \(entities.count) region(s) for Spotlight") + logger { .spotlightIndexed(regionCount: entities.count) } } catch { // Degraded-but-handled: search integration is a nicety, so a failure // is logged and swallowed rather than surfaced to the user. - logger.warning("Failed to index regions for Spotlight: \(error)") + logger(attachments: [.error(error, name: "index-error")]) { + .spotlightIndexFailed(description: String(describing: error)) + } } } } diff --git a/Where/WhereShareExtension/AGENTS.md b/Where/WhereShareExtension/AGENTS.md index 798b7c16..8222e977 100644 --- a/Where/WhereShareExtension/AGENTS.md +++ b/Where/WhereShareExtension/AGENTS.md @@ -11,8 +11,10 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature - **Tuist app-extension target** ([`Project.swift`](../../Project.swift), bundle ID `com.stuff.where.share`), depending on **WhereCore**, **WhereUI**, - and **LogKit**. Embedded by the **Where** app; shares the - `group.com.stuff.where` App Group entitlement. + and **PeriscopeCore**. Embedded by the **Where** app; shares the + `group.com.stuff.where` App Group entitlement. Logs via the `WhereLog` facade + (typed `ShareExtensionLog` events); as a separate process its + `Periscope.shared` is OSLog-only (no store). - Presentation reuses WhereUI's public `EvidenceKind.symbolName`/`displayName`; only extension chrome lives in this target's `ShareStrings` + catalog. - No test bundle; the write path is covered from **WhereCore** store tests and diff --git a/Where/WhereShareExtension/README.md b/Where/WhereShareExtension/README.md index 6333923f..e151b72c 100644 --- a/Where/WhereShareExtension/README.md +++ b/Where/WhereShareExtension/README.md @@ -51,7 +51,7 @@ CloudKit container picks the write up from the shared store's history. `WhereShareExtension` is a Tuist app-extension target in [`Project.swift`](../../Project.swift) (bundle ID `com.stuff.where.share`), -depending on **WhereCore**, **WhereUI**, and **LogKit**. The main **Where** app +depending on **WhereCore**, **WhereUI**, and **PeriscopeCore**. The main **Where** app embeds the extension and shares the `group.com.stuff.where` App Group entitlement so both processes open the same SwiftData store. diff --git a/Where/WhereShareExtension/Sources/Logging/ShareExtensionLog.swift b/Where/WhereShareExtension/Sources/Logging/ShareExtensionLog.swift new file mode 100644 index 00000000..0ca081ee --- /dev/null +++ b/Where/WhereShareExtension/Sources/Logging/ShareExtensionLog.swift @@ -0,0 +1,44 @@ +import PeriscopeCore + +/// Structured events for the Where share extension — a short-lived, separate +/// process, so `Periscope.shared` stays OSLog-only (no persistent store). +enum ShareExtensionLog: LogEvent { + /// The extension was invoked with the given number of shared items. + case opened(itemCount: Int) + /// A shared item provider yielded no bytes for its offered type. + case attachmentLoadFailed(typeIdentifier: String) + /// A shared URL provider produced nothing readable. + case urlUnreadable + /// Shared evidence records were persisted to the App Group store. + case saved(evidenceCount: Int) + /// Persisting the shared evidence failed; the form stays open. + case saveFailed(description: String) + + static let eventName = "ShareExtension" + + var level: LogLevel { + switch self { + case .opened, .saved: + .info + case .attachmentLoadFailed, .urlUnreadable: + .warning + case .saveFailed: + .error + } + } + + var message: String { + switch self { + case let .opened(itemCount): + "Share extension opened with \(itemCount) item(s)" + case let .attachmentLoadFailed(typeIdentifier): + "Failed to load shared \(typeIdentifier)" + case .urlUnreadable: + "Shared URL provider yielded no readable URL" + case let .saved(evidenceCount): + "Saved \(evidenceCount) shared evidence record(s)" + case let .saveFailed(description): + "Failed to save shared evidence: \(description)" + } + } +} diff --git a/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift b/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift index fa6ed38b..67c9657b 100644 --- a/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift +++ b/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import WhereCore /// View-scoped model for the share-extension compose sheet. Pulls the shared @@ -42,7 +42,7 @@ final class ShareEvidenceModel { /// Storage the extension opens; injectable so a future test can point it at /// an in-memory store instead of the shared container. private let storage: SwiftDataStore.Storage - private static let logger = WhereLog.channel(.shareExtension) + private static let logger = WhereLog.root(ShareExtensionLog.self) init( items: [NSExtensionItem], @@ -99,11 +99,13 @@ final class ShareEvidenceModel { try await store.write(evidence: item.evidence, blob: item.blob) } } - Self.logger.info("Saved \(pending.count) shared evidence record(s)") + Self.logger { .saved(evidenceCount: pending.count) } return true } catch { phase = .failed(error.localizedDescription) - Self.logger.error("Failed to save shared evidence: \(error.localizedDescription)") + Self.logger(attachments: [.error(error, name: "save-error")]) { + .saveFailed(description: error.localizedDescription) + } return false } } diff --git a/Where/WhereShareExtension/Sources/ShareViewController.swift b/Where/WhereShareExtension/Sources/ShareViewController.swift index 0b55f11b..77db519e 100644 --- a/Where/WhereShareExtension/Sources/ShareViewController.swift +++ b/Where/WhereShareExtension/Sources/ShareViewController.swift @@ -1,4 +1,4 @@ -import LogKit +import PeriscopeCore import SwiftUI import UIKit import WhereCore @@ -8,7 +8,7 @@ import WhereCore /// `UIHostingController` and bridges its save/cancel actions to the extension /// request lifecycle. final class ShareViewController: UIViewController { - private static let logger = WhereLog.channel(.shareExtension) + private static let logger = WhereLog.root(ShareExtensionLog.self) /// The embedded SwiftUI host, re-framed to fill our bounds each layout pass. private var host: UIHostingController? @@ -17,7 +17,7 @@ final class ShareViewController: UIViewController { super.viewDidLoad() let items = (extensionContext?.inputItems as? [NSExtensionItem]) ?? [] - Self.logger.info("Share extension opened with \(items.count) item(s)") + Self.logger { .opened(itemCount: items.count) } let model = ShareEvidenceModel(items: items) let root = ShareEvidenceView( diff --git a/Where/WhereShareExtension/Sources/SharedItemLoader.swift b/Where/WhereShareExtension/Sources/SharedItemLoader.swift index e6586f94..ef55ea48 100644 --- a/Where/WhereShareExtension/Sources/SharedItemLoader.swift +++ b/Where/WhereShareExtension/Sources/SharedItemLoader.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import UniformTypeIdentifiers import WhereCore @@ -28,7 +28,7 @@ struct SharedAttachment { /// non-`Sendable` providers are never sent across actors. @MainActor enum SharedItemLoader { - private static let logger = WhereLog.channel(.shareExtension) + private static let logger = WhereLog.root(ShareExtensionLog.self) /// Load every attachment the providers in `items` can produce, one per /// provider, in share order. Empty when nothing yields bytes (the compose @@ -82,7 +82,7 @@ enum SharedItemLoader { } } guard let data else { - logger.warning("Failed to load shared \(type.identifier)") + logger { .attachmentLoadFailed(typeIdentifier: type.identifier) } return nil } return SharedAttachment( @@ -110,7 +110,7 @@ enum SharedItemLoader { } } guard let string else { - logger.warning("Shared URL provider yielded no readable URL") + logger { .urlUnreadable } return nil } return SharedAttachment( diff --git a/Where/WhereUI/Sources/Developer/DeveloperLogInspection.swift b/Where/WhereUI/Sources/Developer/DeveloperLogInspection.swift new file mode 100644 index 00000000..8acc17df --- /dev/null +++ b/Where/WhereUI/Sources/Developer/DeveloperLogInspection.swift @@ -0,0 +1,24 @@ +import PeriscopeCore +import SwiftUI +#if DEBUG + import PeriscopeTools +#endif + +extension View { + /// Make this view inspectable in Periscope's "log view mode" in debug + /// builds, and a no-op in release. Wraps PeriscopeTools' `logInspectable(_:)` + /// so production call sites stay clean and the tools code path compiles out + /// of release entirely. + /// + /// With the developer overlay's Log View Mode on, wrapped views gain a badge + /// that opens the newest events in `log`'s scope subtree — e.g. wrap an + /// evidence row in `WhereLog.evidence` to see everything logged under it. + @ViewBuilder + func debugLogInspectable(_ log: Log) -> some View { + #if DEBUG + logInspectable(log) + #else + self + #endif + } +} diff --git a/Where/WhereUI/Sources/Developer/DeveloperToast.swift b/Where/WhereUI/Sources/Developer/DeveloperToast.swift new file mode 100644 index 00000000..8eb99718 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/DeveloperToast.swift @@ -0,0 +1,136 @@ +#if DEBUG + import Observation + import PeriscopeCore + import PeriscopeTools + import SwiftUI + + /// The DEBUG-only in-app toast surface for high-severity log records. + /// + /// A `PeriscopeAlerter` (started at `RootView`, threshold `.warning`) feeds + /// `DeveloperToastAlertHandler`, which appends to this center; the + /// `DeveloperToastOverlay` mounted above the app renders the transient + /// banners. This keeps warning/error logs visible while developing without + /// routing through the notification center (which would collide with the + /// app's real reminders). Compiled out of release entirely. + @MainActor + @Observable + final class DeveloperToastCenter { + /// One transient banner for a logged record at or above the alert + /// threshold. + struct Toast: Identifiable, Equatable { + let id = UUID() + let level: LogLevel + let title: String + let message: String + } + + private(set) var toasts: [Toast] = [] + + /// How long a toast stays on screen before it auto-dismisses. + private let lifetime: Duration + + /// The most banners shown at once; older ones drop so an error storm + /// can't fill the screen. + private let maximumVisible = 3 + + init(lifetime: Duration = .seconds(4)) { + self.lifetime = lifetime + } + + func show(_ toast: Toast) { + toasts.append(toast) + if toasts.count > maximumVisible { + toasts.removeFirst(toasts.count - maximumVisible) + } + Task { + try? await Task.sleep(for: lifetime) + dismiss(toast.id) + } + } + + func dismiss(_ id: Toast.ID) { + toasts.removeAll { $0.id == id } + } + } + + /// Routes alerted records into a `DeveloperToastCenter`. `@MainActor` per the + /// `PeriscopeAlertHandler` contract; it never logs at or above the alerter's + /// threshold, so it can't alert itself in a loop. + @MainActor + struct DeveloperToastAlertHandler: PeriscopeAlertHandler { + let center: DeveloperToastCenter + + func handle(_ record: LogRecord) { + center.show(DeveloperToastCenter.Toast( + level: record.level, + title: record.eventName, + message: record.message, + )) + } + } + + /// Renders a `DeveloperToastCenter`'s banners stacked from the top edge. + /// Non-interactive except for a tap-to-dismiss on each banner, so the app + /// behind stays usable. + struct DeveloperToastOverlay: View { + let center: DeveloperToastCenter + + var body: some View { + VStack(spacing: 8) { + ForEach(center.toasts) { toast in + DeveloperToastBanner(toast: toast) { center.dismiss(toast.id) } + .transition(.move(edge: .top).combined(with: .opacity)) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.top, 8) + .animation(.snappy, value: center.toasts) + .allowsHitTesting(!center.toasts.isEmpty) + } + } + + private struct DeveloperToastBanner: View { + let toast: DeveloperToastCenter.Toast + let onDismiss: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: symbol) + .foregroundStyle(tint) + VStack(alignment: .leading, spacing: 2) { + Text(toast.title) + .font(.caption.weight(.semibold)) + Text(toast.message) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(3) + } + Spacer(minLength: 0) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: 12, style: .continuous), + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(tint.opacity(0.6), lineWidth: 1), + ) + .contentShape(Rectangle()) + .onTapGesture(perform: onDismiss) + .shadow(color: .black.opacity(0.2), radius: 8, y: 3) + } + + private var tint: Color { + toast.level >= .error ? .red : .orange + } + + private var symbol: String { + toast.level >= .error + ? "exclamationmark.octagon.fill" + : "exclamationmark.triangle.fill" + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/DeveloperToolsView.swift b/Where/WhereUI/Sources/Developer/DeveloperToolsView.swift index 6f8b838f..49f3de22 100644 --- a/Where/WhereUI/Sources/Developer/DeveloperToolsView.swift +++ b/Where/WhereUI/Sources/Developer/DeveloperToolsView.swift @@ -1,36 +1,43 @@ #if DEBUG - import LogViewerUI - import RegionKit + import PeriscopeCore + import PeriscopeTools import SwiftDataInspector import SwiftUI import WhereCore - /// The developer tools surface — the in-app log viewer over both process - /// buffers (`WhereLog` for the app/WhereCore facade and `RegionLog` for - /// RegionKit, merged chronologically), the generic SwiftData inspector (only - /// when the live session can vend a container — previews and non-SwiftData - /// fakes don't show it), and the region map. + /// The developer tools surface — the Periscope log viewer + open-spans + /// monitor, the "Log View Mode" toggle, the generic SwiftData inspector (only + /// when the live session can vend a container), and the region map. /// /// Owns its own `NavigationStack` so the generic viewers (which expect an - /// ambient stack) work wherever it's hosted. It reads `WhereSession` as an - /// *optional* because the developer overlay is reachable before login — the - /// inspector row simply hides until a live session exists. + /// ambient stack) work wherever it's hosted. It reads the app `WhereModel` + /// (for the process-global log store) and the `WhereSession` as *optionals* + /// because the developer overlay is reachable before login and in fixtures + /// that inject only one of them — the dependent rows just hide until their + /// source exists. /// /// Compiled out of release entirely (`#if DEBUG`). struct DeveloperToolsView: View { + @Environment(WhereModel.self) private var model: WhereModel? @Environment(WhereSession.self) private var session: WhereSession? + @Environment(\.periscopeInspector) private var inspector var body: some View { NavigationStack { List { Section { + if let store = model?.logStore { + NavigationLink { + PeriscopeViewer(store: store, title: Strings.developerLogsTitle) + } label: { + Label(Strings.developerLogsLink, systemImage: "ladybug") + } + } + NavigationLink { - LogViewer(configuration: LogViewerConfiguration( - stores: [WhereLog.store, RegionLog.store], - title: Strings.developerLogsTitle, - )) + OpenSpansView(system: .shared) } label: { - Label(Strings.developerLogsLink, systemImage: "ladybug") + Label(Strings.developerOpenSpansLink, systemImage: "timer") } if let configuration = session?.swiftDataInspectorConfiguration { @@ -52,6 +59,10 @@ } footer: { Text(Strings.developerFooter) } + + if let inspector { + LogViewModeSection(inspector: inspector) + } } .navigationTitle(Strings.developerTitle) .navigationBarTitleDisplayMode(.inline) @@ -59,8 +70,25 @@ } } + /// The "Log View Mode" toggle: binds straight to the injected + /// ``PeriscopeInspector`` (the source of truth is `Periscope.shared`'s + /// inspect flag, which the inspector mirrors both ways), so flipping it + /// reveals the inspect badges `debugLogInspectable(_:)` adds across the app. + private struct LogViewModeSection: View { + @Bindable var inspector: PeriscopeInspector + + var body: some View { + Section { + Toggle(Strings.developerLogViewMode, isOn: $inspector.isEnabled) + } footer: { + Text(Strings.developerLogViewModeFooter) + } + } + } + #Preview { DeveloperToolsView() + .environment(PreviewSupport.loadedModel()) .environment(PreviewSupport.loadedSession()) } #endif diff --git a/Where/WhereUI/Sources/Developer/RegionMapView.swift b/Where/WhereUI/Sources/Developer/RegionMapView.swift index e1e13f23..36de0c34 100644 --- a/Where/WhereUI/Sources/Developer/RegionMapView.swift +++ b/Where/WhereUI/Sources/Developer/RegionMapView.swift @@ -1,5 +1,5 @@ -import LogKit import MapKit +import PeriscopeCore import RegionKit import SwiftUI import WhereCore @@ -153,8 +153,9 @@ public struct RegionMapView: View { // Keep the failure observable in both the UI (the `.failure` // state renders an error) and the logs, rather than silently // showing an empty map. - RegionLog.channel(.geometryCatalog) - .warning("Region map viewer failed to load \(kind.rawValue) geometry: \(error)") + RegionLog.geometryCatalog { + .loadFailed(kind: kind.rawValue, description: String(describing: error)) + } outlines = .failure(error) } } diff --git a/Where/WhereUI/Sources/Evidence/AddEvidenceView.swift b/Where/WhereUI/Sources/Evidence/AddEvidenceView.swift index 2b94b0da..f2293ba3 100644 --- a/Where/WhereUI/Sources/Evidence/AddEvidenceView.swift +++ b/Where/WhereUI/Sources/Evidence/AddEvidenceView.swift @@ -1,3 +1,4 @@ +import PeriscopeCore import PhotosUI import SwiftUI import UniformTypeIdentifiers @@ -75,6 +76,9 @@ struct AddEvidenceView: View { } message: { message in Text(message) } + // Log View Mode: reveal an inspect badge for this compose form's + // events (attachment-pick / save). A no-op in release. + .debugLogInspectable(WhereLog.evidence(AddEvidenceModelLog.self)) } } diff --git a/Where/WhereUI/Sources/Evidence/EvidenceDetailView.swift b/Where/WhereUI/Sources/Evidence/EvidenceDetailView.swift index 99e47789..b4ec7a69 100644 --- a/Where/WhereUI/Sources/Evidence/EvidenceDetailView.swift +++ b/Where/WhereUI/Sources/Evidence/EvidenceDetailView.swift @@ -1,3 +1,4 @@ +import PeriscopeCore import SwiftUI import WhereCore @@ -36,6 +37,9 @@ struct EvidenceDetailView: View { .task { if model.blobState == .idle { await model.load() } } + // Log View Mode: reveal an inspect badge for this screen's + // evidence-detail events (blob-load failures). A no-op in release. + .debugLogInspectable(WhereLog.evidence(EvidenceDetailModelLog.self)) } private var header: some View { diff --git a/Where/WhereUI/Sources/Evidence/EvidenceListView.swift b/Where/WhereUI/Sources/Evidence/EvidenceListView.swift index d6ca1650..f11a4912 100644 --- a/Where/WhereUI/Sources/Evidence/EvidenceListView.swift +++ b/Where/WhereUI/Sources/Evidence/EvidenceListView.swift @@ -146,6 +146,9 @@ private struct EvidenceRow: View { .accessibilityLabel( Strings.evidenceRowAccessibility(kind: evidence.kind, date: evidence.capturedAt), ) + // Log View Mode: reveal an inspect badge that opens this archive's + // evidence-scope events. A no-op in release. + .debugLogInspectable(WhereLog.evidence) } } diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index 1523a96e..0b444841 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -1,6 +1,6 @@ import CoreLocation import LifecycleKit -import LogKit +import PeriscopeCore import SwiftUI import UIKit import UserNotifications @@ -56,7 +56,72 @@ public enum LaunchStepID: String { /// async steps. @MainActor public enum WhereLaunch { - private static let logger = WhereLog.channel(.launch) + private static let logger = WhereLog.root(WhereLaunchLog.self) + + /// How much log history the on-disk store keeps: 100 days. Older events are + /// pruned at launch so the database can't grow without bound. (A size cap to + /// bound heavy-logging devices within the window is tracked in `Where/TODOs.md`.) + private static let logRetention: TimeInterval = 100 * 24 * 60 * 60 + + /// Open the process-global Periscope store, attach it to `Periscope.shared` + /// as the durable sink, start the built-in ambient sources, and prune + /// history past `logRetention` — then hand the store to `model` so the DEBUG + /// developer surface can browse it. + /// + /// Runs off the launch critical path on its own task: opening the store + /// touches disk (and may run a lightweight migration), which must not block + /// `didFinishLaunching`. The OSLog sink already installed on + /// `Periscope.shared` covers the pre-attach window, and `add(sink:)` replays + /// every scope defined so far so the store resolves the records it sees. + /// (Fully closing that pre-attach window — a bootstrap journal from process + /// start — is tracked as a P0 in `Shared/Periscope/TODOs.md`.) + /// + /// Once the store is attached the developer surface can browse it + /// immediately: `.loggingStoreReady` fires right after `add(sink:)`, and + /// retention pruning runs *after* that on its own task, since trimming old + /// history isn't a readiness prerequisite. + /// + /// Degraded-but-handled on failure: if the store can't open, logging keeps + /// flowing through OSLog and the failure is recorded (with the error + /// attached) rather than crashing a launch over diagnostics. + /// + /// Called once from the app delegate at process launch. + public static func bootstrapLogging(model: WhereModel) { + Task { + let store: PeriscopeStore + do { + store = try await PeriscopeStore.make(storage: .onDisk, session: .current()) + } catch { + logger(attachments: [.error(error, name: "open-error")]) { + .loggingStoreUnavailable(description: String(describing: error)) + } + return + } + Periscope.shared.add(sink: store) + Periscope.shared.startDefaultAmbientSources() + model.attach(logStore: store) + logger { .loggingStoreReady } + pruneHistory(in: store) + } + } + + /// Trim log history past `logRetention` on its own task, so it never delays + /// `.loggingStoreReady`. The actual prune runs on the store actor (off the + /// main thread); a failure is degraded-but-handled — the store keeps its + /// last good history and stays usable, it just isn't trimmed this launch. + private static func pruneHistory(in store: PeriscopeStore) { + Task { + do { + let cutoff = Date().addingTimeInterval(-logRetention) + let pruned = try await store.pruneEvents(olderThan: cutoff) + logger { .historyPruned(prunedEventCount: pruned) } + } catch { + logger(attachments: [.error(error, name: "prune-error")]) { + .historyPruneFailed(description: String(describing: error)) + } + } + } + } /// Maps the process's launch-time application state to the lifecycle reason /// the runner consumes. An `.active`/`.inactive` launch is a user-visible @@ -108,7 +173,7 @@ public enum WhereLaunch { onServicesReady: @escaping @MainActor (WhereServices) async -> Void = { _ in }, ) -> LifecycleRunner { let bootstrap = WhereBootstrap() - logger.info("Lifecycle runner created (reason: \(reason))") + logger { .runnerCreated(reason: String(describing: reason)) } return LifecycleRunner( reason: reason, initializePrerequisites: { @@ -235,7 +300,7 @@ public enum WhereLaunch { /// off the main actor and assembles the services from the two. @MainActor public final class WhereBootstrap { - private static let logger = WhereLog.channel(.launch) + private static let logger = WhereLog.root(WhereLaunchLog.self) private var locationSource: CoreLocationSource? @@ -276,12 +341,12 @@ public final class WhereBootstrap { locationSource: source, locationOutbox: FileLocationOutbox.applicationSupport(), ) - Self.logger.info("WhereServices assembled") + Self.logger { .servicesAssembled } return services } catch { - Self.logger.error( - "Failed to assemble WhereServices: \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "assemble-error")]) { + .servicesAssemblyFailed(description: error.localizedDescription) + } throw error } } diff --git a/Where/WhereUI/Sources/Logging/AddEvidenceModelLog.swift b/Where/WhereUI/Sources/Logging/AddEvidenceModelLog.swift new file mode 100644 index 00000000..7a3fc0ac --- /dev/null +++ b/Where/WhereUI/Sources/Logging/AddEvidenceModelLog.swift @@ -0,0 +1,38 @@ +import PeriscopeCore +import WhereCore + +/// Structured events for `AddEvidenceModel`, the compose form. A save records +/// the evidence id (`externalID`); attachment-pick and save failures leave the +/// form open with an honest error, so they log at `.warning`. +enum AddEvidenceModelLog: LogEvent { + case attachmentPickFailed(description: String) + case saved(evidenceID: String) + case saveFailed(description: String) + + static let eventName = "AddEvidenceModel" + + var level: LogLevel { + switch self { + case .saved: .info + case .attachmentPickFailed, .saveFailed: .warning + } + } + + var message: String { + switch self { + case let .attachmentPickFailed(description): + "Evidence attachment pick failed: \(description)" + case let .saved(evidenceID): + "Saved evidence \(evidenceID) from compose form" + case let .saveFailed(description): + "Failed to save evidence: \(description)" + } + } + + var externalID: String? { + switch self { + case let .saved(evidenceID): WhereStoreID.evidence(evidenceID) + case .attachmentPickFailed, .saveFailed: nil + } + } +} diff --git a/Where/WhereUI/Sources/Logging/BackupModelLog.swift b/Where/WhereUI/Sources/Logging/BackupModelLog.swift new file mode 100644 index 00000000..b790fd6b --- /dev/null +++ b/Where/WhereUI/Sources/Logging/BackupModelLog.swift @@ -0,0 +1,44 @@ +import PeriscopeCore + +/// Structured events for `BackupModel`, the export/import view model. Failures +/// leave the UI in an honest error state, so they log at `.warning`. +enum BackupModelLog: LogEvent { + case exported + case exportFailed(description: String) + case imported( + sampleCount: Int, + evidenceCount: Int, + manualDayCount: Int, + dismissedIssueCount: Int, + trackedRegionCount: Int, + ) + case importFailed(description: String) + + static let eventName = "Backup" + + var level: LogLevel { + switch self { + case .exported, .imported: .info + case .exportFailed, .importFailed: .warning + } + } + + var message: String { + switch self { + case .exported: + "Exported backup archive" + case let .exportFailed(description): + "Backup export failed: \(description)" + case let .imported( + sampleCount, + evidenceCount, + manualDayCount, + dismissedIssueCount, + trackedRegionCount, + ): + "Imported backup (\(sampleCount) samples, \(evidenceCount) evidence, \(manualDayCount) manual days, \(dismissedIssueCount) dismissals, \(trackedRegionCount) tracked regions)" + case let .importFailed(description): + "Backup import failed: \(description)" + } + } +} diff --git a/Where/WhereUI/Sources/Logging/CalendarViewLog.swift b/Where/WhereUI/Sources/Logging/CalendarViewLog.swift new file mode 100644 index 00000000..56e0122c --- /dev/null +++ b/Where/WhereUI/Sources/Logging/CalendarViewLog.swift @@ -0,0 +1,23 @@ +import PeriscopeCore + +/// Structured events for `CalendarView`'s layout. Both cases are +/// degraded-but-handled presentation states, so they log at `.warning`. +enum CalendarViewLog: LogEvent { + case openedWithoutReport(loadState: String) + case layoutFailed(description: String) + + static let eventName = "CalendarView" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .openedWithoutReport(loadState): + "Calendar opened without a year report (loadState: \(loadState))" + case let .layoutFailed(description): + "Calendar layout failed: \(description)" + } + } +} diff --git a/Where/WhereUI/Sources/Logging/EvidenceDetailModelLog.swift b/Where/WhereUI/Sources/Logging/EvidenceDetailModelLog.swift new file mode 100644 index 00000000..0b543f1e --- /dev/null +++ b/Where/WhereUI/Sources/Logging/EvidenceDetailModelLog.swift @@ -0,0 +1,27 @@ +import PeriscopeCore +import WhereCore + +/// Structured events for `EvidenceDetailModel`. The evidence id rides on +/// `externalID` so blob-load failures trace to their row. +enum EvidenceDetailModelLog: LogEvent { + case blobLoadFailed(evidenceID: String, description: String) + + static let eventName = "EvidenceDetailModel" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .blobLoadFailed(evidenceID, description): + "Failed to load evidence blob for \(evidenceID): \(description)" + } + } + + var externalID: String? { + switch self { + case let .blobLoadFailed(evidenceID, _): WhereStoreID.evidence(evidenceID) + } + } +} diff --git a/Where/WhereUI/Sources/Logging/EvidenceListModelLog.swift b/Where/WhereUI/Sources/Logging/EvidenceListModelLog.swift new file mode 100644 index 00000000..4b7beab6 --- /dev/null +++ b/Where/WhereUI/Sources/Logging/EvidenceListModelLog.swift @@ -0,0 +1,27 @@ +import PeriscopeCore +import WhereCore + +/// Structured events for `EvidenceListModel`. A read failure leaves the list in +/// an honest error state, so it logs at `.warning`. +enum EvidenceListModelLog: LogEvent { + case loadFailed(year: Int, description: String) + + static let eventName = "EvidenceListModel" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .loadFailed(year, description): + "Failed to load evidence for \(year): \(description)" + } + } + + var externalID: String? { + switch self { + case let .loadFailed(year, _): WhereStoreID.year(year) + } + } +} diff --git a/Where/WhereUI/Sources/Logging/LoggedDaysModelLog.swift b/Where/WhereUI/Sources/Logging/LoggedDaysModelLog.swift new file mode 100644 index 00000000..fb965083 --- /dev/null +++ b/Where/WhereUI/Sources/Logging/LoggedDaysModelLog.swift @@ -0,0 +1,27 @@ +import PeriscopeCore +import WhereCore + +/// Structured events for `LoggedDaysModel`. A read failure leaves the list in an +/// honest error state, so it logs at `.warning`. The year rides on `externalID`. +enum LoggedDaysModelLog: LogEvent { + case loadFailed(year: Int, description: String) + + static let eventName = "LoggedDaysModel" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .loadFailed(year, description): + "Failed to load logged days for \(year): \(description)" + } + } + + var externalID: String? { + switch self { + case let .loadFailed(year, _): WhereStoreID.year(year) + } + } +} diff --git a/Where/WhereUI/Sources/Logging/ManualDayViewLog.swift b/Where/WhereUI/Sources/Logging/ManualDayViewLog.swift new file mode 100644 index 00000000..4834d10c --- /dev/null +++ b/Where/WhereUI/Sources/Logging/ManualDayViewLog.swift @@ -0,0 +1,21 @@ +import PeriscopeCore + +/// Structured events for `ManualDayView`, the manual add/edit form. A failure to +/// load regions for grouping degrades to an ungrouped list, so it logs at +/// `.warning`. +enum ManualDayViewLog: LogEvent { + case regionGroupingLoadFailed(description: String) + + static let eventName = "ManualDayView" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .regionGroupingLoadFailed(description): + "Manual-day form couldn't load regions for grouping: \(description)" + } + } +} diff --git a/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift b/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift new file mode 100644 index 00000000..ea5b4b09 --- /dev/null +++ b/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift @@ -0,0 +1,24 @@ +import PeriscopeCore + +/// Structured events for `OnboardingView`. Neither failure should strand the +/// user in onboarding — each is logged and the flow continues — so both log at +/// `.warning`. +enum OnboardingViewLog: LogEvent { + case regionCommitFailed(description: String) + case backupRestoreFailed(description: String) + + static let eventName = "Onboarding" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .regionCommitFailed(description): + "Failed to commit onboarding region picks: \(description)" + case let .backupRestoreFailed(description): + "Onboarding backup restore failed: \(description)" + } + } +} diff --git a/Where/WhereUI/Sources/Logging/RecentActivityModelLog.swift b/Where/WhereUI/Sources/Logging/RecentActivityModelLog.swift new file mode 100644 index 00000000..f7926ca8 --- /dev/null +++ b/Where/WhereUI/Sources/Logging/RecentActivityModelLog.swift @@ -0,0 +1,24 @@ +import PeriscopeCore + +/// Structured events for `RecentActivityModel`. Both an unavailable on-device +/// model and a generation failure leave an honest UI error, so they log at +/// `.warning`. +enum RecentActivityModelLog: LogEvent { + case summaryUnavailable(reason: String) + case summaryFailed(description: String) + + static let eventName = "RecentActivityModel" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .summaryUnavailable(reason): + "Recent-activity summary unavailable: \(reason)" + case let .summaryFailed(description): + "Recent-activity summary failed: \(description)" + } + } +} diff --git a/Where/WhereUI/Sources/Logging/RegionPickerViewLog.swift b/Where/WhereUI/Sources/Logging/RegionPickerViewLog.swift new file mode 100644 index 00000000..97a43ffc --- /dev/null +++ b/Where/WhereUI/Sources/Logging/RegionPickerViewLog.swift @@ -0,0 +1,20 @@ +import PeriscopeCore + +/// Structured events for `RegionPickerView`. A geometry-load failure leaves the +/// map in an honest error state (not a blank map), so it logs at `.warning`. +enum RegionPickerViewLog: LogEvent { + case mapGeometryLoadFailed(description: String) + + static let eventName = "RegionPicker" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .mapGeometryLoadFailed(description): + "Region picker failed to load map geometry: \(description)" + } + } +} diff --git a/Where/WhereUI/Sources/Logging/RegionsSettingsViewLog.swift b/Where/WhereUI/Sources/Logging/RegionsSettingsViewLog.swift new file mode 100644 index 00000000..a45b3cf5 --- /dev/null +++ b/Where/WhereUI/Sources/Logging/RegionsSettingsViewLog.swift @@ -0,0 +1,24 @@ +import PeriscopeCore + +/// Structured events for `RegionsSettingsView`, the post-onboarding primary-region +/// editor. Both failures leave an honest fallback (an empty picker / staying +/// open) rather than stranding the user, so they log at `.warning`. +enum RegionsSettingsViewLog: LogEvent { + case primaryRegionsLoadFailed(description: String) + case primaryRegionsSaveFailed(description: String) + + static let eventName = "RegionsSettings" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .primaryRegionsLoadFailed(description): + "Failed to load primary regions for editing: \(description)" + case let .primaryRegionsSaveFailed(description): + "Failed to save primary region edits: \(description)" + } + } +} diff --git a/Where/WhereUI/Sources/Logging/ResolveModelLog.swift b/Where/WhereUI/Sources/Logging/ResolveModelLog.swift new file mode 100644 index 00000000..97c5a29a --- /dev/null +++ b/Where/WhereUI/Sources/Logging/ResolveModelLog.swift @@ -0,0 +1,31 @@ +import PeriscopeCore + +/// Structured events for `ResolveModel`, the data-issue resolution flow. Read / +/// dismiss failures leave an honest UI error, so they log at `.warning`. A +/// dismissed issue's id rides on `externalID`. +enum ResolveModelLog: LogEvent { + case dataIssueScanFailed(description: String) + case dismissFailed(issueID: String, description: String) + + static let eventName = "Resolve" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .dataIssueScanFailed(description): + "Failed to scan for data issues: \(description)" + case let .dismissFailed(issueID, description): + "Failed to dismiss data issue \(issueID): \(description)" + } + } + + var externalID: String? { + switch self { + case let .dismissFailed(issueID, _): issueID + case .dataIssueScanFailed: nil + } + } +} diff --git a/Where/WhereUI/Sources/Logging/WhereLaunchLog.swift b/Where/WhereUI/Sources/Logging/WhereLaunchLog.swift new file mode 100644 index 00000000..8f4cd5b9 --- /dev/null +++ b/Where/WhereUI/Sources/Logging/WhereLaunchLog.swift @@ -0,0 +1,63 @@ +import PeriscopeCore + +/// Structured events for the app launch sequence (`WhereLaunch` / +/// `WhereBootstrap`), including the process-global log-store bootstrap. +enum WhereLaunchLog: LogEvent { + /// Names the launch spans — one bounded span per launch step. + enum SpanName: Hashable { + case step + } + + case runnerCreated(reason: String) + case servicesAssembled + /// Assembling the service layer (store open + `WhereServices.make`) failed; + /// the `open-store` step surfaces it and the launch parks in `.failed`. + case servicesAssemblyFailed(description: String) + /// The durable log store opened and attached to `Periscope.shared`. Fired + /// as soon as the store is browsable — retention pruning runs after, off the + /// ready path (see ``historyPruned``). + case loggingStoreReady + /// Opening the durable log store failed; logging continues through the + /// OSLog sink only, with no persisted history this launch. + case loggingStoreUnavailable(description: String) + /// Retention pruning finished, removing `prunedEventCount` events past the + /// window. Runs after ``loggingStoreReady``, so it never delays readiness. + case historyPruned(prunedEventCount: Int) + /// Retention pruning failed; the store is still usable (last good history + /// preserved), it just isn't trimmed this launch. + case historyPruneFailed(description: String) + + static let eventName = "WhereLaunch" + + var level: LogLevel { + switch self { + case .runnerCreated, .servicesAssembled, .loggingStoreReady, .historyPruned: + .info + // The store is still usable when pruning fails (degraded-but-handled), + // unlike an outright open failure. + case .historyPruneFailed: + .warning + case .servicesAssemblyFailed, .loggingStoreUnavailable: + .error + } + } + + var message: String { + switch self { + case let .runnerCreated(reason): + "Lifecycle runner created (reason: \(reason))" + case .servicesAssembled: + "WhereServices assembled" + case let .servicesAssemblyFailed(description): + "Failed to assemble WhereServices: \(description)" + case .loggingStoreReady: + "Log store ready" + case let .loggingStoreUnavailable(description): + "Log store unavailable: \(description)" + case let .historyPruned(prunedEventCount): + "Pruned \(prunedEventCount) log event(s) past retention" + case let .historyPruneFailed(description): + "Failed to prune log history: \(description)" + } + } +} diff --git a/Where/WhereUI/Sources/Logging/WhereModelLog.swift b/Where/WhereUI/Sources/Logging/WhereModelLog.swift new file mode 100644 index 00000000..0d2c6b93 --- /dev/null +++ b/Where/WhereUI/Sources/Logging/WhereModelLog.swift @@ -0,0 +1,25 @@ +import PeriscopeCore + +/// Structured events for `WhereModel`, the app's top-level session/onboarding +/// coordinator. All are successful-lifecycle `.info` events. +enum WhereModelLog: LogEvent { + case onboardingCompleted + case startedSession(year: Int) + case endedSession + case resetPreferences + + static let eventName = "WhereModel" + + var message: String { + switch self { + case .onboardingCompleted: + "Onboarding completed" + case let .startedSession(year): + "Started session (year: \(year))" + case .endedSession: + "Ended session" + case .resetPreferences: + "Reset preferences to first-install defaults" + } + } +} diff --git a/Where/WhereUI/Sources/Logging/WhereSessionLog.swift b/Where/WhereUI/Sources/Logging/WhereSessionLog.swift new file mode 100644 index 00000000..2e716a08 --- /dev/null +++ b/Where/WhereUI/Sources/Logging/WhereSessionLog.swift @@ -0,0 +1,61 @@ +import PeriscopeCore + +/// Structured events for `WhereSession`, the always-on tracking/authorization +/// coordinator. Degraded-but-handled authorization states log at `.warning`; +/// successful lifecycle transitions at `.info`. +enum WhereSessionLog: LogEvent { + case whenInUseOnly + case locationAccessDenied(status: String) + case backgroundTrackingStarted + case backgroundTrackingStopped + case permissionGranted(status: String) + case trackingEnabled + case stoppedBackgroundTracking + case remindersUnauthorized + case summaryUnauthorized + case issueAlertsUnauthorized + case regionStylesLoadFailed(description: String) + case erasedSession + + static let eventName = "WhereSession" + + var level: LogLevel { + switch self { + case .whenInUseOnly, .locationAccessDenied, .remindersUnauthorized, + .summaryUnauthorized, .issueAlertsUnauthorized, .regionStylesLoadFailed: + .warning + case .backgroundTrackingStarted, .backgroundTrackingStopped, .permissionGranted, + .trackingEnabled, .stoppedBackgroundTracking, .erasedSession: + .info + } + } + + var message: String { + switch self { + case .whenInUseOnly: + "Location authorized for When-In-Use only; background tracking unavailable" + case let .locationAccessDenied(status): + "Location access \(status); background tracking unavailable" + case .backgroundTrackingStarted: + "Background tracking started" + case .backgroundTrackingStopped: + "Background tracking stopped" + case let .permissionGranted(status): + "Location permission granted (\(status))" + case .trackingEnabled: + "Tracking enabled with background authorization" + case .stoppedBackgroundTracking: + "Stopped background tracking" + case .remindersUnauthorized: + "Logging reminders enabled but notifications not authorized" + case .summaryUnauthorized: + "Daily summary enabled but notifications not authorized" + case .issueAlertsUnauthorized: + "Issue alerts enabled but notifications not authorized" + case let .regionStylesLoadFailed(description): + "Failed to load region appearances for styling: \(description)" + case .erasedSession: + "Erased session and reset state" + } + } +} diff --git a/Where/WhereUI/Sources/Logging/YearReportModelLog.swift b/Where/WhereUI/Sources/Logging/YearReportModelLog.swift new file mode 100644 index 00000000..05532e20 --- /dev/null +++ b/Where/WhereUI/Sources/Logging/YearReportModelLog.swift @@ -0,0 +1,66 @@ +import PeriscopeCore +import WhereCore + +/// Structured events for `YearReportModel`. The affected year rides on +/// `externalID`. A successful load is `.info`; read failures that leave a +/// degraded UI state are `.warning`. +enum YearReportModelLog: LogEvent { + case selectedYear(year: Int) + case reportLoaded(year: Int, dayCount: Int) + case reportLoadFailed(year: Int, description: String) + case evidenceDayKeysLoadFailed(year: Int, description: String) + case dataIssueScanFailed(description: String) + case clearYearFailed(year: Int, description: String) + case locationsLoadFailed(region: String, year: Int, description: String) + case dayLocationsLoadFailed(day: String, year: Int, description: String) + case representativeCoordinatesLoadFailed(year: Int, description: String) + + static let eventName = "YearReport" + + var level: LogLevel { + switch self { + case .selectedYear, .reportLoaded: .info + case .reportLoadFailed, .evidenceDayKeysLoadFailed, .dataIssueScanFailed, + .clearYearFailed, .locationsLoadFailed, .dayLocationsLoadFailed, + .representativeCoordinatesLoadFailed: + .warning + } + } + + var message: String { + switch self { + case let .selectedYear(year): + "Selected year \(year)" + case let .reportLoaded(year, dayCount): + "Year report loaded for \(year) (\(dayCount) day(s))" + case let .reportLoadFailed(year, description): + "Failed to load year report for \(year): \(description)" + case let .evidenceDayKeysLoadFailed(year, description): + "Failed to load evidence day keys for \(year): \(description)" + case let .dataIssueScanFailed(description): + "Failed to scan for data issues: \(description)" + case let .clearYearFailed(year, description): + "Failed to clear year \(year): \(description)" + case let .locationsLoadFailed(region, year, description): + "Failed to load locations for \(region) in \(year): \(description)" + case let .dayLocationsLoadFailed(day, year, description): + "Failed to load locations for day \(day) in \(year): \(description)" + case let .representativeCoordinatesLoadFailed(year, description): + "Failed to load representative coordinates for \(year): \(description)" + } + } + + var externalID: String? { + switch self { + case let .selectedYear(year), let .reportLoaded(year, _), + let .reportLoadFailed(year, _), let .evidenceDayKeysLoadFailed(year, _), + let .clearYearFailed(year, _), let .locationsLoadFailed(_, year, _), + let .representativeCoordinatesLoadFailed(year, _): + WhereStoreID.year(year) + case let .dayLocationsLoadFailed(day, _, _): + WhereStoreID.day(day) + case .dataIssueScanFailed: + nil + } + } +} diff --git a/Where/WhereUI/Sources/Manual/LoggedDaysView.swift b/Where/WhereUI/Sources/Manual/LoggedDaysView.swift index cfd0ada5..d2f4b5ee 100644 --- a/Where/WhereUI/Sources/Manual/LoggedDaysView.swift +++ b/Where/WhereUI/Sources/Manual/LoggedDaysView.swift @@ -1,3 +1,4 @@ +import PeriscopeCore import RegionKit import SwiftUI import WhereCore @@ -89,6 +90,9 @@ struct LoggedDaysView: View { Text(message) } } + // Log View Mode: reveal an inspect badge for the logged-days list's + // events. A no-op in release. + .debugLogInspectable(WhereLog.root(LoggedDaysModelLog.self)) } @ViewBuilder diff --git a/Where/WhereUI/Sources/Manual/ManualDayView.swift b/Where/WhereUI/Sources/Manual/ManualDayView.swift index cf11ac85..d5b351ef 100644 --- a/Where/WhereUI/Sources/Manual/ManualDayView.swift +++ b/Where/WhereUI/Sources/Manual/ManualDayView.swift @@ -1,4 +1,5 @@ import Observation +import PeriscopeCore import RegionKit import SwiftUI import WhereCore @@ -43,7 +44,7 @@ struct ManualDayView: View { @State private var showDeleteConfirmation = false @State private var pending: PendingWrite? - private static let logger = WhereLog.channel(.model) + private static let logger = WhereLog.session(ManualDayViewLog.self) init(report: YearReportModel, mode: Mode, showsCancelButton: Bool = false) { self.report = report @@ -111,6 +112,9 @@ struct ManualDayView: View { Text(message) } } + // Log View Mode: reveal an inspect badge for the manual-day form's + // events (region grouping load). A no-op in release. + .debugLogInspectable(WhereLog.session(ManualDayViewLog.self)) } // MARK: - Mode-specific content @@ -252,7 +256,9 @@ struct ManualDayView: View { let tracked = try await report.services.primaryRegions() activeRegions.applyGrouping(tracked: tracked, usedThisYear: usedThisYear) } catch { - Self.logger.warning("Manual-day form couldn't load regions for grouping") + Self.logger(attachments: [.error(error, name: "grouping-error")]) { + .regionGroupingLoadFailed(description: error.localizedDescription) + } } } diff --git a/Where/WhereUI/Sources/Model/AddEvidenceModel.swift b/Where/WhereUI/Sources/Model/AddEvidenceModel.swift index 1f177d4a..041756a4 100644 --- a/Where/WhereUI/Sources/Model/AddEvidenceModel.swift +++ b/Where/WhereUI/Sources/Model/AddEvidenceModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import WhereCore /// Bytes the user picked to attach to a new piece of evidence, plus the hints @@ -50,7 +50,7 @@ public final class AddEvidenceModel { public private(set) var attachmentError: String? private let services: WhereServices - private static let logger = WhereLog.channel(.evidence) + private static let logger = WhereLog.evidence(AddEvidenceModelLog.self) init(services: WhereServices, now: @Sendable () -> Date = { Date() }) { self.services = services @@ -93,7 +93,7 @@ public final class AddEvidenceModel { public func reportAttachmentError(_ message: String) { attachmentError = message - Self.logger.warning("Evidence attachment pick failed: \(message)") + Self.logger { .attachmentPickFailed(description: message) } } /// Build the `Evidence` from the form and persist it (with any attachment @@ -105,11 +105,11 @@ public final class AddEvidenceModel { let evidence = buildEvidence() do { try await services.journal.addEvidence(evidence, blob: attachment?.data) - Self.logger.info("Saved evidence \(evidence.id) from compose form") + Self.logger { .saved(evidenceID: String(describing: evidence.id)) } return true } catch { saveState = .failed(error.localizedDescription) - Self.logger.warning("Failed to save evidence: \(error.localizedDescription)") + Self.logger { .saveFailed(description: error.localizedDescription) } return false } } diff --git a/Where/WhereUI/Sources/Model/EvidenceDetailModel.swift b/Where/WhereUI/Sources/Model/EvidenceDetailModel.swift index d628147f..fc791f6a 100644 --- a/Where/WhereUI/Sources/Model/EvidenceDetailModel.swift +++ b/Where/WhereUI/Sources/Model/EvidenceDetailModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import WhereCore /// View-scoped model for a single evidence record's detail: loads the @@ -24,7 +24,7 @@ public final class EvidenceDetailModel { public private(set) var blobState: BlobState = .idle private let services: WhereServices - private static let logger = WhereLog.channel(.evidence) + private static let logger = WhereLog.evidence(EvidenceDetailModelLog.self) init(evidence: Evidence, services: WhereServices) { self.evidence = evidence @@ -40,9 +40,12 @@ public final class EvidenceDetailModel { blobState = .loaded(blob) } catch { blobState = .failed(error.localizedDescription) - Self.logger.warning( - "Failed to load evidence blob for \(evidence.id): \(error.localizedDescription)", - ) + Self.logger { + .blobLoadFailed( + evidenceID: String(describing: evidence.id), + description: error.localizedDescription, + ) + } } } diff --git a/Where/WhereUI/Sources/Model/EvidenceListModel.swift b/Where/WhereUI/Sources/Model/EvidenceListModel.swift index 4cf67041..c8cf5440 100644 --- a/Where/WhereUI/Sources/Model/EvidenceListModel.swift +++ b/Where/WhereUI/Sources/Model/EvidenceListModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import WhereCore /// View-scoped model backing the "all evidence" sheet: loads the selected @@ -24,7 +24,7 @@ public final class EvidenceListModel { public private(set) var loadState: LoadState = .idle private let services: WhereServices - private static let logger = WhereLog.channel(.evidence) + private static let logger = WhereLog.evidence(EvidenceListModelLog.self) init(services: WhereServices) { self.services = services @@ -49,9 +49,7 @@ public final class EvidenceListModel { loadState = evidence.isEmpty ? .empty : .loaded(evidence) } catch { loadState = .failed(error.localizedDescription) - Self.logger.warning( - "Failed to load evidence for \(year): \(error.localizedDescription)", - ) + Self.logger { .loadFailed(year: year, description: error.localizedDescription) } } } diff --git a/Where/WhereUI/Sources/Model/LoggedDaysModel.swift b/Where/WhereUI/Sources/Model/LoggedDaysModel.swift index 4cfc48f1..7c7653d4 100644 --- a/Where/WhereUI/Sources/Model/LoggedDaysModel.swift +++ b/Where/WhereUI/Sources/Model/LoggedDaysModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import WhereCore /// View-scoped model backing the "logged days" sheet: loads the selected year's @@ -25,7 +25,7 @@ public final class LoggedDaysModel { public private(set) var loadState: LoadState = .idle private let services: WhereServices - private static let logger = WhereLog.channel(.dayJournal) + private static let logger = WhereLog.root(LoggedDaysModelLog.self) init(services: WhereServices) { self.services = services @@ -62,9 +62,7 @@ public final class LoggedDaysModel { loadState = days.isEmpty ? .empty : .loaded(days) } catch { loadState = .failed(error.localizedDescription) - Self.logger.warning( - "Failed to load logged days for \(year): \(error.localizedDescription)", - ) + Self.logger { .loadFailed(year: year, description: error.localizedDescription) } } } diff --git a/Where/WhereUI/Sources/Model/RecentActivityModel.swift b/Where/WhereUI/Sources/Model/RecentActivityModel.swift index 82231b44..b063fa22 100644 --- a/Where/WhereUI/Sources/Model/RecentActivityModel.swift +++ b/Where/WhereUI/Sources/Model/RecentActivityModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import WhereCore /// View-scoped model for the "last 24 hours" on-device summary. Mirrors the @@ -31,7 +31,7 @@ public final class RecentActivityModel { public var window: RecentActivityWindow = .day private let services: WhereServices - private static let logger = WhereLog.channel(.recentActivitySummarizer) + private static let logger = WhereLog.recentActivity(RecentActivityModelLog.self) init(services: WhereServices) { self.services = services @@ -51,14 +51,10 @@ public final class RecentActivityModel { } } catch let error as ActivitySummaryUnavailableError { loadState = .unavailable(error.reason) - Self.logger.warning( - "Recent-activity summary unavailable: \(String(describing: error.reason))", - ) + Self.logger { .summaryUnavailable(reason: String(describing: error.reason)) } } catch { loadState = .failed(error.localizedDescription) - Self.logger.warning( - "Recent-activity summary failed: \(error.localizedDescription)", - ) + Self.logger { .summaryFailed(description: error.localizedDescription) } } } diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index 0e871f1f..6e1e3604 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import WhereCore /// The long-lived, app-level model: the onboarding gate, the persisted @@ -28,6 +28,15 @@ public final class WhereModel { /// `@Environment(WhereSession.self)`. public private(set) var session: WhereSession? + /// The process-global Periscope log store, opened at launch and attached to + /// `Periscope.shared` as its durable sink (see `WhereLaunch.bootstrapLogging`). + /// Held here — not on `WhereSession` — because logging spans the whole + /// process, not a login: it exists before the store opens and survives a + /// reset. `nil` until the bootstrap opens it, and in previews/tests, which + /// log only through the in-memory pipeline. The DEBUG developer surface reads + /// it to browse persisted history. + public private(set) var logStore: PeriscopeStore? + /// The persisted user intent (onboarding, tracking, reminder/summary /// schedules). Owns the defaults keys and the `reset()` the erase flow runs; /// shared by reference with the `WhereSession` so both halves read/write the @@ -44,7 +53,7 @@ public final class WhereModel { /// (the scene loads from the store once it appears). let initialReport: YearReport? - private static let logger = WhereLog.channel(.model) + private static let logger = WhereLog.root(WhereModelLog.self) /// Whether first-run onboarding has been completed. Persisted so onboarding /// shows exactly once; the launch flow gates its onboarding step on this, @@ -58,7 +67,7 @@ public final class WhereModel { /// user finishes the intro (after the permission prompt resolves). public func completeOnboarding() { hasOnboarded = true - Self.logger.info("Onboarding completed") + Self.logger { .onboardingCompleted } } public static var currentYear: Int { @@ -107,6 +116,13 @@ public final class WhereModel { services != nil } + /// Retain the process-global log store the launch bootstrap opened and + /// attached to `Periscope.shared`. Called once, off the launch critical + /// path, so the developer surface can browse persisted history. + public func attach(logStore: PeriscopeStore) { + self.logStore = logStore + } + /// Retain the service layer the launch's `open-store` step assembled (see /// `WhereBootstrap`). Idempotent — a no-op once services exist, so an /// injected preview/test value is never clobbered. `WhereBootstrap` owns @@ -129,7 +145,7 @@ public final class WhereModel { preferences: preferences, now: now, ) - Self.logger.info("Started session (year: \(initialSelectedYear))") + Self.logger { .startedSession(year: initialSelectedYear) } } /// Drop the logged-in session (the services stay retained). Run by the @@ -137,7 +153,7 @@ public final class WhereModel { /// fresh session over the erased store. public func endSession() { session = nil - Self.logger.info("Ended session") + Self.logger { .endedSession } } // MARK: - Reset / erase all @@ -162,6 +178,6 @@ public final class WhereModel { /// again; the re-driven launch's fresh session reads those defaults back. public func resetPreferences() { preferences.reset() - Self.logger.info("Reset preferences to first-install defaults") + Self.logger { .resetPreferences } } } diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index 75e01f3b..1a79b399 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore #if DEBUG import SwiftDataInspector #endif @@ -88,7 +88,7 @@ public final class WhereSession { /// from another device restyles the UI without a relaunch. public private(set) var regionStyles: RegionStyleResolver = .default - private static let logger = WhereLog.channel(.session) + private static let logger = WhereLog.session(WhereSessionLog.self) /// The authorization the degradation warning was last evaluated against. /// `syncAuthorization()` runs on every foreground, so warning only on a @@ -219,13 +219,11 @@ public final class WhereSession { case .always, .notDetermined: break case .whenInUse: - Self.logger.warning( - "Location authorized for When-In-Use only; background tracking unavailable", - ) + Self.logger { .whenInUseOnly } case .denied, .restricted: - Self.logger.warning( - "Location access \(authorizationStatus); background tracking unavailable", - ) + Self.logger { + .locationAccessDenied(status: String(describing: authorizationStatus)) + } } } @@ -257,7 +255,9 @@ public final class WhereSession { let primary = try await services.primaryRegions() regionStyles = RegionStyleResolver(primaryRegions: primary) } catch { - Self.logger.warning("Failed to load region appearances for styling") + Self.logger(attachments: [.error(error, name: "region-styles-error")]) { + .regionStylesLoadFailed(description: error.localizedDescription) + } } } @@ -283,11 +283,11 @@ public final class WhereSession { if wantsTracking, authorizationStatus.allowsBackgroundTracking { await services.ingestor.start() isTracking = true - if !wasTracking { Self.logger.info("Background tracking started") } + if !wasTracking { Self.logger { .backgroundTrackingStarted } } } else { await services.ingestor.stop() isTracking = false - if wasTracking { Self.logger.info("Background tracking stopped") } + if wasTracking { Self.logger { .backgroundTrackingStopped } } } } @@ -319,7 +319,7 @@ public final class WhereSession { await syncAuthorization() await reconcileTracking() if authorizationStatus.allowsBackgroundTracking { - Self.logger.info("Location permission granted (\(authorizationStatus))") + Self.logger { .permissionGranted(status: String(describing: authorizationStatus)) } } } @@ -339,7 +339,7 @@ public final class WhereSession { await syncAuthorization() await reconcileTracking() if authorizationStatus.allowsBackgroundTracking { - Self.logger.info("Tracking enabled with background authorization") + Self.logger { .trackingEnabled } } } @@ -347,7 +347,7 @@ public final class WhereSession { wantsTracking = false await services.ingestor.stop() isTracking = false - Self.logger.info("Stopped background tracking") + Self.logger { .stoppedBackgroundTracking } } /// Push the persisted reminder intent to the reminder reconciler and warn if @@ -369,7 +369,7 @@ public final class WhereSession { let authorized = await services.reminders.isAuthorized() if enabled, !authorized { if !warnedRemindersUnauthorized { - Self.logger.warning("Logging reminders enabled but notifications not authorized") + Self.logger { .remindersUnauthorized } warnedRemindersUnauthorized = true } } else { @@ -387,7 +387,7 @@ public final class WhereSession { let authorized = await services.reminders.isAuthorized() if enabled, !authorized { if !warnedSummaryUnauthorized { - Self.logger.warning("Daily summary enabled but notifications not authorized") + Self.logger { .summaryUnauthorized } warnedSummaryUnauthorized = true } } else { @@ -410,7 +410,7 @@ public final class WhereSession { let authorized = await services.reminders.isAuthorized() if enabled, !authorized { if !warnedIssueAlertsUnauthorized { - Self.logger.warning("Issue alerts enabled but notifications not authorized") + Self.logger { .issueAlertsUnauthorized } warnedIssueAlertsUnauthorized = true } } else { @@ -430,7 +430,7 @@ public final class WhereSession { public func eraseSession() async throws { try await services.reset() isTracking = false - Self.logger.info("Erased session and reset state") + Self.logger { .erasedSession } } /// Drives the background-tracking `Toggle`. Reads the live `isTracking` diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 4d89bb28..fdd281c0 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import RegionKit import WhereCore @@ -104,7 +104,7 @@ public final class YearReportModel { /// the main actor, and `deinit` runs with no other live references. @ObservationIgnored private nonisolated(unsafe) var dataChangeTask: Task? - private static let logger = WhereLog.channel(.session) + private static let logger = WhereLog.session(YearReportModelLog.self) /// Observed mirror of `preferences.driftThresholdMeters`, which isn't itself /// observable (`WherePreferences` is a plain defaults wrapper — callers that @@ -266,7 +266,7 @@ public final class YearReportModel { public func select(year: Int) async { guard year != selectedYear else { return } - Self.logger.info("Selected year \(year)") + Self.logger { .selectedYear(year: year) } selectedYear = year // Drop the previous year's report so views fall back to their loading // state instead of rendering stale data under the new year's label. @@ -303,9 +303,12 @@ public final class YearReportModel { guard requestedYear == selectedYear else { return } if evidenceDayKeys != keys { evidenceDayKeys = keys } } catch { - Self.logger.warning( - "Failed to load evidence day keys for \(requestedYear): \(error.localizedDescription)", - ) + Self.logger { + .evidenceDayKeysLoadFailed( + year: requestedYear, + description: error.localizedDescription, + ) + } } } @@ -341,9 +344,7 @@ public final class YearReportModel { } catch { // Surface the failure and keep the last good count rather than // silently blanking the badge. - Self.logger.warning( - "Failed to scan for data issues: \(error.localizedDescription)", - ) + Self.logger { .dataIssueScanFailed(description: error.localizedDescription) } } } @@ -364,15 +365,16 @@ public final class YearReportModel { if changed { self.report = report } if loadState != .loaded { loadState = .loaded } if changed { - Self.logger - .info("Year report loaded for \(requestedYear) (\(report.days.count) day(s))") + Self.logger { + .reportLoaded(year: requestedYear, dayCount: report.days.count) + } } } catch { guard requestedYear == selectedYear else { return } loadState = .failed(.reportUnavailable(message: error.localizedDescription)) - Self.logger.warning( - "Failed to load year report for \(requestedYear): \(error.localizedDescription)", - ) + Self.logger { + .reportLoadFailed(year: requestedYear, description: error.localizedDescription) + } } } @@ -454,9 +456,9 @@ public final class YearReportModel { try await services.journal.clearYear(selectedYear) } catch { loadState = .failed(.clearFailed(message: error.localizedDescription)) - Self.logger.warning( - "Failed to clear year \(selectedYear): \(error.localizedDescription)", - ) + Self.logger { + .clearYearFailed(year: selectedYear, description: error.localizedDescription) + } } } @@ -475,9 +477,13 @@ public final class YearReportModel { do { return try await services.reports.locations(in: region, year: selectedYear) } catch { - Self.logger.warning( - "Failed to load locations for \(region.rawValue) in \(selectedYear): \(error.localizedDescription)", - ) + Self.logger { + .locationsLoadFailed( + region: region.rawValue, + year: selectedYear, + description: error.localizedDescription, + ) + } return [] } } @@ -489,9 +495,13 @@ public final class YearReportModel { do { return try await services.reports.locations(onDay: day) } catch { - Self.logger.warning( - "Failed to load locations for day \(day) in \(selectedYear): \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "day-locations-error")]) { + .dayLocationsLoadFailed( + day: day.description, + year: selectedYear, + description: error.localizedDescription, + ) + } return [:] } } @@ -503,9 +513,12 @@ public final class YearReportModel { do { return try await services.reports.representativeCoordinates(for: selectedYear) } catch { - Self.logger.warning( - "Failed to load representative coordinates for \(selectedYear): \(error.localizedDescription)", - ) + Self.logger { + .representativeCoordinatesLoadFailed( + year: selectedYear, + description: error.localizedDescription, + ) + } return [:] } } diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index 43929c2d..e4559401 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -1,4 +1,5 @@ import LifecycleKit +import PeriscopeCore import SwiftUI import UniformTypeIdentifiers import WhereCore @@ -45,7 +46,7 @@ public struct OnboardingView: View { @State private var showRestoreError = false @State private var restoreError: String? - private static let logger = WhereLog.channel(.model) + private static let logger = WhereLog.session(OnboardingViewLog.self) public init(bridge: LifecycleStepUIBridge) { self.bridge = bridge @@ -75,6 +76,9 @@ public struct OnboardingView: View { .ignoresSafeArea(), ) .animation(stylesheet.motion.reducedReveal, value: phase) + // Log View Mode: reveal an inspect badge for onboarding events (region + // commit / backup restore). A no-op in release. + .debugLogInspectable(WhereLog.session(OnboardingViewLog.self)) } // MARK: - Intro @@ -257,7 +261,9 @@ public struct OnboardingView: View { } catch { // Don't strand the user in onboarding on a write failure — // log it and continue; they can re-pick in Settings. - Self.logger.warning("Failed to commit onboarding region picks") + Self.logger(attachments: [.error(error, name: "commit-error")]) { + .regionCommitFailed(description: error.localizedDescription) + } } } model.completeOnboarding() @@ -292,7 +298,9 @@ public struct OnboardingView: View { isRestoring = false restoreError = error.localizedDescription showRestoreError = true - Self.logger.warning("Onboarding backup restore failed") + Self.logger(attachments: [.error(error, name: "restore-error")]) { + .backupRestoreFailed(description: error.localizedDescription) + } } } } diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 4e41dc96..78ab172b 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -1,5 +1,6 @@ #if DEBUG import Foundation + import PeriscopeCore import RegionKit @_spi(Testing) import WhereCore @@ -367,6 +368,23 @@ WhereModel(services: previewServices(), report: sampleReport(), selectedYear: year) } + /// An in-memory Periscope log store for the developer-surface previews and + /// hosting tests — the same durable-sink type the app opens at launch, + /// but backed by memory so nothing touches disk. + @MainActor + public static func previewLogStore() async throws -> PeriscopeStore { + try await PeriscopeStore.make(storage: .inMemory, session: .current()) + } + + /// A `loadedModel()` with an in-memory log store attached, so the + /// developer tools' log-viewer and Log View Mode rows render. + @MainActor + public static func loadedModel(withLogStore store: PeriscopeStore) -> WhereModel { + let model = loadedModel() + model.attach(logStore: store) + return model + } + /// A widget snapshot built from the sample year totals, for widget /// previews and tests. public static func sampleWidgetSnapshot( diff --git a/Where/WhereUI/Sources/Primary/CalendarView.swift b/Where/WhereUI/Sources/Primary/CalendarView.swift index 789b7419..dfa300eb 100644 --- a/Where/WhereUI/Sources/Primary/CalendarView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarView.swift @@ -1,3 +1,4 @@ +import PeriscopeCore import RegionKit import SwiftUI import WhereCore @@ -21,7 +22,7 @@ struct CalendarView: View { @State private var timelineTarget: TimelineMonthTarget? @State private var monthsLoad: Result<[CalendarMonth], Error>? - private static let logger = WhereLog.channel(.session) + private static let logger = WhereLog.session(CalendarViewLog.self) /// Inputs that invalidate a cached month grid. private struct CalendarLoadID: Equatable { @@ -73,9 +74,9 @@ struct CalendarView: View { Text(Strings.calendarUnavailableDescription) } .onAppear { - Self.logger.warning( - "Calendar opened without a year report (loadState: \(report.loadState))", - ) + Self.logger { + .openedWithoutReport(loadState: String(describing: report.loadState)) + } } } } @@ -92,6 +93,9 @@ struct CalendarView: View { .navigationBarTitleDisplayMode(.inline) } } + // Log View Mode: reveal an inspect badge for this calendar's events. A + // no-op in release. + .debugLogInspectable(WhereLog.session(CalendarViewLog.self)) } private var navigationTitle: String { @@ -131,7 +135,7 @@ struct CalendarView: View { Text(Strings.calendarUnavailableDescription) } .onAppear { - Self.logger.warning("Calendar layout failed: \(error)") + Self.logger { .layoutFailed(description: String(describing: error)) } } } diff --git a/Where/WhereUI/Sources/Primary/PrimaryView.swift b/Where/WhereUI/Sources/Primary/PrimaryView.swift index d69cd51e..a2e32384 100644 --- a/Where/WhereUI/Sources/Primary/PrimaryView.swift +++ b/Where/WhereUI/Sources/Primary/PrimaryView.swift @@ -1,3 +1,4 @@ +import PeriscopeCore import RegionKit import SwiftUI import WhereCore @@ -117,6 +118,9 @@ struct PrimaryView: View { .sheet(item: $calendarFocus) { focus in CalendarView(focusedRegion: focus.region, report: report) } + // Log View Mode: reveal an inspect badge for the year-report events + // backing this screen. A no-op in release. + .debugLogInspectable(WhereLog.session(YearReportModelLog.self)) } /// A deep, near-black gradient that makes the Primary tab read like a diff --git a/Where/WhereUI/Sources/Primary/RecentActivitySummaryView.swift b/Where/WhereUI/Sources/Primary/RecentActivitySummaryView.swift index bc2f7d7f..2b5767ab 100644 --- a/Where/WhereUI/Sources/Primary/RecentActivitySummaryView.swift +++ b/Where/WhereUI/Sources/Primary/RecentActivitySummaryView.swift @@ -1,3 +1,4 @@ +import PeriscopeCore import SwiftUI import WhereCore @@ -54,6 +55,9 @@ struct RecentActivitySummaryView: View { Task { await model.load() } } } + // Log View Mode: reveal an inspect badge for recent-activity summary + // events. A no-op in release. + .debugLogInspectable(WhereLog.recentActivity(RecentActivityModelLog.self)) } /// Segmented control for the summary window, pinned under the navigation diff --git a/Where/WhereUI/Sources/Regions/RegionPickerView.swift b/Where/WhereUI/Sources/Regions/RegionPickerView.swift index e7e5041c..9b7b7759 100644 --- a/Where/WhereUI/Sources/Regions/RegionPickerView.swift +++ b/Where/WhereUI/Sources/Regions/RegionPickerView.swift @@ -1,4 +1,5 @@ import MapKit +import PeriscopeCore import RegionKit import SwiftUI import WhereCore @@ -29,7 +30,7 @@ struct RegionPickerView: View { @Environment(\.stylesheet) private var stylesheet @Environment(\.regionStyles) private var regionStyles - private static let logger = WhereLog.channel(.regionAttribution) + private static let logger = WhereLog.session(RegionPickerViewLog.self) var body: some View { VStack(spacing: stylesheet.spacing.medium) { @@ -71,6 +72,9 @@ struct RegionPickerView: View { guard mode == .map, mapData == nil else { return } await loadMap() } + // Log View Mode: reveal an inspect badge for region-picker events (map + // geometry load). A no-op in release. + .debugLogInspectable(WhereLog.session(RegionPickerViewLog.self)) } private var modePicker: some View { @@ -219,7 +223,9 @@ struct RegionPickerView: View { guard !Task.isCancelled else { return } // Keep the failure observable in both the UI (error state) and the // logs rather than showing a blank map. - Self.logger.warning("Region picker failed to load map geometry: \(error)") + Self.logger(attachments: [.error(error, name: "geometry-error")]) { + .mapGeometryLoadFailed(description: error.localizedDescription) + } mapData = .failure(error) } } diff --git a/Where/WhereUI/Sources/Regions/RegionsSettingsView.swift b/Where/WhereUI/Sources/Regions/RegionsSettingsView.swift index 402325e2..8dc4e709 100644 --- a/Where/WhereUI/Sources/Regions/RegionsSettingsView.swift +++ b/Where/WhereUI/Sources/Regions/RegionsSettingsView.swift @@ -1,3 +1,4 @@ +import PeriscopeCore import RegionKit import SwiftUI import WhereCore @@ -24,7 +25,7 @@ struct RegionsSettingsView: View { case customize } - private static let logger = WhereLog.channel(.model) + private static let logger = WhereLog.session(RegionsSettingsViewLog.self) var body: some View { NavigationStack { @@ -44,6 +45,9 @@ struct RegionsSettingsView: View { } } .task { await loadIfNeeded() } + // Log View Mode: reveal an inspect badge for the region-editor events. A + // no-op in release. + .debugLogInspectable(WhereLog.session(RegionsSettingsViewLog.self)) } @ViewBuilder @@ -79,7 +83,9 @@ struct RegionsSettingsView: View { let existing = try await session.services.primaryRegions() built = PrimaryRegionSelectionModel(existing: existing) } catch { - Self.logger.warning("Failed to load primary regions for editing") + Self.logger(attachments: [.error(error, name: "load-error")]) { + .primaryRegionsLoadFailed(description: error.localizedDescription) + } // Fall back to an empty picker rather than a stuck spinner. built = PrimaryRegionSelectionModel() } @@ -95,7 +101,9 @@ struct RegionsSettingsView: View { do { try await model.commit(using: session) } catch { - Self.logger.warning("Failed to save primary region edits") + Self.logger(attachments: [.error(error, name: "save-error")]) { + .primaryRegionsSaveFailed(description: error.localizedDescription) + } } dismiss() } diff --git a/Where/WhereUI/Sources/Resolution/ResolutionView.swift b/Where/WhereUI/Sources/Resolution/ResolutionView.swift index 368df82d..1dd357b0 100644 --- a/Where/WhereUI/Sources/Resolution/ResolutionView.swift +++ b/Where/WhereUI/Sources/Resolution/ResolutionView.swift @@ -1,3 +1,4 @@ +import PeriscopeCore import RegionKit import SwiftUI import WhereCore @@ -41,6 +42,9 @@ struct ResolutionView: View { ) } } + // Log View Mode: reveal an inspect badge for data-issue resolution + // events. A no-op in release. + .debugLogInspectable(WhereLog.session(ResolveModelLog.self)) } @ViewBuilder diff --git a/Where/WhereUI/Sources/Resolution/ResolveModel.swift b/Where/WhereUI/Sources/Resolution/ResolveModel.swift index d86139fa..394ce88e 100644 --- a/Where/WhereUI/Sources/Resolution/ResolveModel.swift +++ b/Where/WhereUI/Sources/Resolution/ResolveModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import RegionKit import WhereCore @@ -33,7 +33,7 @@ public final class ResolveModel { private let services: WhereServices private let preferences: WherePreferences - private static let logger = WhereLog.channel(.session) + private static let logger = WhereLog.session(ResolveModelLog.self) #if DEBUG /// Set by the `@_spi(Testing)` seeder so `load(...)` doesn't clobber @@ -65,9 +65,7 @@ public final class ResolveModel { } catch { // Surface the failure and keep the last good list rather than // silently blanking the tab (which would read as "all clear"). - Self.logger.warning( - "Failed to scan for data issues: \(error.localizedDescription)", - ) + Self.logger { .dataIssueScanFailed(description: error.localizedDescription) } } // Mark loaded even on failure so the view leaves the spinner (the error // was logged and the last good list preserved); a stuck spinner would be @@ -84,9 +82,12 @@ public final class ResolveModel { // recomputes the badge count a beat later. dataIssues.removeAll { $0.id == issue.id } } catch { - Self.logger.warning( - "Failed to dismiss data issue \(issue.id.storeURL): \(error.localizedDescription)", - ) + Self.logger { + .dismissFailed( + issueID: issue.id.storeURL.absoluteString, + description: error.localizedDescription, + ) + } } } } diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 903ba4cd..c1490076 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -166,6 +166,17 @@ } } }, + "calendar.day.hasEvidence.accessibility" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@, has evidence" + } + } + } + }, "calendar.day.needsAttention.accessibility" : { "comment" : "Accessibility label for a calendar day that needs attention.", "extractionState" : "extracted_with_value", @@ -218,6 +229,19 @@ } } }, + "common.cancel" : { + "comment" : "Cancel button text.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel" + } + } + } + }, "common.day" : { "extractionState" : "manual", "localizations" : { @@ -307,486 +331,1313 @@ } } }, - "launch.accessibilityLabel" : { - "comment" : "Spoken by VoiceOver while the launch splash is on screen (the icon and radar animation are decorative and hidden from accessibility).", + "common.save" : { "extractionState" : "extracted_with_value", - "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Loading" + "value" : "Save" } } } }, - "launch.caption.subtitle" : { - "comment" : "Subtitle of the splash's slow-launch caption.", - "extractionState" : "extracted_with_value", + "developer.button.label" : { + "comment" : "Accessibility label for the floating developer button.", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { - "state" : "new", - "value" : "This only takes a moment." + "state" : "translated", + "value" : "Developer tools" } } } }, - "launch.caption.title" : { - "comment" : "Title of the splash's slow-launch caption; deliberately launch-neutral (a migration, a first install's store creation, or plain slowness).", - "extractionState" : "extracted_with_value", + "developer.close" : { + "comment" : "Accessibility label for closing the developer panel.", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { - "state" : "new", - "value" : "Getting things ready…" + "state" : "translated", + "value" : "Close" } } } }, - "manual.day" : { + "developer.collapse" : { + "comment" : "Accessibility label for shrinking the developer panel back to a floating window.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Day" + "value" : "Exit full screen" } } } }, - "manual.entry.pickerLabel" : { + "developer.expand" : { + "comment" : "Accessibility label for growing the developer panel to full screen.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Entry" + "value" : "Enter full screen" } } } }, - "manual.from" : { + "developer.footer" : { + "comment" : "Footer for the developer tools list.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "From" + "value" : "On-device logs and data tools. Debug builds only." } } } }, - "manual.mode.range" : { + "developer.inspectorLink" : { + "comment" : "Label for the navigation link that opens the SwiftData inspector.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Date range" + "value" : "SwiftData Inspector" } } } }, - "manual.mode.singleDay" : { + "developer.inspectorTitle" : { + "comment" : "Navigation title for the SwiftData inspector screen.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Single day" + "value" : "SwiftData" } } } }, - "manual.note.footer" : { + "developer.logsLink" : { + "comment" : "Label for the button that opens the logs screen.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Saved with this entry for auditing — explain why you made this change." + "value" : "Logs" } } } }, - "manual.note.header" : { + "developer.logsTitle" : { + "comment" : "Title of the screen that shows the user's logs.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Reason" + "value" : "Logs" } } } }, - "manual.note.placeholder" : { - "extractionState" : "manual", + "developer.logViewMode" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Add a note (optional)" + "state" : "new", + "value" : "Log View Mode" } } } }, - "manual.range.footer" : { - "extractionState" : "manual", + "developer.logViewMode.footer" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "Backfilling %lld day." - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "Backfilling %lld days." - } - } - } + "stringUnit" : { + "state" : "new", + "value" : "Reveal an inspect badge on tagged views to open their recent logs." } } } }, - "manual.regions.footer" : { - "extractionState" : "manual", + "developer.openSpansLink" : { + "comment" : "Label for a button that opens the developer's spans.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Saving replaces any manual regions you previously set for those days." + "state" : "new", + "value" : "Open spans" } } } }, - "manual.regions.header" : { + "developer.regionMapLink" : { + "comment" : "Label for the navigation link that opens the region map.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Regions" + "value" : "Region map" } } } }, - "manual.save" : { + "developer.title" : { + "comment" : "Navigation title of the developer tools list.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Save" + "value" : "Developer" } } } }, - "manual.saveError.title" : { - "extractionState" : "manual", + "evidence.add" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Couldn't save that day" + "state" : "new", + "value" : "Add evidence" } } } }, - "manual.saving.status" : { - "extractionState" : "manual", + "evidence.detail.noAttachment" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Capturing location…" + "state" : "new", + "value" : "No attachment" } } } }, - "manual.singleDay.footer" : { - "extractionState" : "manual", + "evidence.detail.noPreview.description" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Time travel: tell Where where you really were." + "state" : "new", + "value" : "This attachment can't be previewed here." } } } }, - "manual.through" : { - "extractionState" : "manual", + "evidence.detail.noPreview.title" : { + "comment" : "Title of an alert when an evidence doesn't have a preview.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Through" + "state" : "new", + "value" : "No preview" } } } }, - "manual.title" : { - "extractionState" : "manual", + "evidence.detail.noteHeader" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Log a Day" + "state" : "new", + "value" : "Note" } } } }, - "missing.banner.accessibilityHint" : { - "extractionState" : "manual", + "evidence.detail.previewFailed" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Opens the list of days that still need logging." + "state" : "new", + "value" : "Couldn't load this attachment." } } } }, - "missing.banner.compact.one" : { - "extractionState" : "manual", + "evidence.empty.description" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "1 day needs a location" + "state" : "new", + "value" : "Attach a boarding pass, receipt, or screenshot to back up where you were. Add one here, or share it into Where from another app." } } } }, - "missing.banner.compact.other" : { - "extractionState" : "manual", + "evidence.empty.title" : { + "comment" : "Title of the empty state when there is no evidence.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "%lld days need a location" + "state" : "new", + "value" : "No evidence yet" } } } }, - "missingDays.done" : { - "extractionState" : "manual", + "evidence.failed.title" : { + "comment" : "Title of an alert when loading evidence fails.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Done" + "state" : "new", + "value" : "Couldn't load evidence" } } } }, - "missingDays.empty.description" : { - "extractionState" : "manual", + "evidence.form.attachmentHeader" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Every day this year has something logged." + "state" : "new", + "value" : "Attachment" } } } }, - "missingDays.empty.title" : { - "extractionState" : "manual", + "evidence.form.chooseFile" : { + "comment" : "Label for choosing a file from the file picker in the evidence form.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "All caught up" + "state" : "new", + "value" : "Choose file" } } } }, - "missingDays.footer" : { - "extractionState" : "manual", + "evidence.form.choosePhoto" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Tap a stretch to record where you were. Today is included until something logs it." + "state" : "new", + "value" : "Choose photo" } } } }, - "missingDays.header" : { - "extractionState" : "manual", + "evidence.form.date" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Days to backfill" + "state" : "new", + "value" : "Date" } } } }, - "missingDays.title" : { - "extractionState" : "manual", + "evidence.form.kind" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Missing days" + "state" : "new", + "value" : "Kind" } } } }, - "onboarding.automatic.description" : { - "comment" : "Description of the automatic location logging feature in the onboarding flow.", + "evidence.form.note" : { "extractionState" : "extracted_with_value", - "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "With background location, Where quietly notes the regions you pass through. You can always add or correct days by hand." + "value" : "Note" } } } }, - "onboarding.automatic.title" : { - "comment" : "Title of the first onboarding screen, describing how Where automatically logs your location.", + "evidence.form.notePlaceholder" : { + "comment" : "Placeholder text for the note field in the evidence form.", "extractionState" : "extracted_with_value", "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "It logs itself" + "value" : "Add a note (optional)" } } } }, - "onboarding.continue" : { - "comment" : "Button text for continuing to the next onboarding step.", + "evidence.form.otherLabel" : { + "comment" : "Label for the \"Other\" field in the evidence form.", "extractionState" : "extracted_with_value", "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Continue" + "value" : "Label" } } } }, - "onboarding.enableLocation" : { - "comment" : "Button text to prompt the user to enable location services.", + "evidence.form.remove" : { + "comment" : "Label for removing an attached file or photo.", "extractionState" : "extracted_with_value", "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Enable Location" + "value" : "Remove attachment" } } } }, - "onboarding.notNow" : { - "comment" : "Button title for skipping the onboarding flow.", + "evidence.form.save" : { "extractionState" : "extracted_with_value", - "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Not Now" + "value" : "Save" } } } }, - "onboarding.privacy.description" : { - "comment" : "Description of the privacy policy of the app.", + "evidence.form.saveError.title" : { "extractionState" : "extracted_with_value", - "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Your location stays on your device and in your own iCloud. Turn on background location to start your passport." + "value" : "Couldn't save evidence" } } } }, - "onboarding.privacy.title" : { - "comment" : "Title of the privacy section of the onboarding flow.", + "evidence.kind.boardingPass" : { + "comment" : "\"Boarding pass\" is a generic term for a travel document that shows a passenger's name, destination, and departure/arrival times.", "extractionState" : "extracted_with_value", "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Private by design" + "value" : "Boarding pass" } } } }, - "onboarding.welcome.description" : { - "comment" : "A longer description of Where, for the onboarding welcome screen.", + "evidence.kind.carRental" : { + "comment" : "The display name for a \"car rental\" evidence kind.", "extractionState" : "extracted_with_value", "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Where keeps a private passport of which regions you spend your days in — built for residency and day-count questions." + "value" : "Car rental" } } } }, - "onboarding.welcome.title" : { - "comment" : "Title of the onboarding welcome screen.", + "evidence.kind.document" : { "extractionState" : "extracted_with_value", - "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Where have you been?" + "value" : "Document" } } } }, - "primary.calendar" : { - "comment" : "Label for the primary calendar.", + "evidence.kind.email" : { + "comment" : "\"Email\" is a generic term for an", "extractionState" : "extracted_with_value", "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Calendar" + "value" : "Email" } } } }, - "primary.card.calendarHint" : { - "comment" : "Accessibility hint on a Primary region card: tapping it opens that region's calendar, filtered to the days spent there.", + "evidence.kind.hotelReceipt" : { "extractionState" : "extracted_with_value", - "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Opens the calendar of days spent here." + "value" : "Hotel receipt" } } } }, - "primary.elsewhereOnly.description" : { - "extractionState" : "manual", + "evidence.kind.other" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Other" + } + } + } + }, + "evidence.kind.photo" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Photo" + } + } + } + }, + "evidence.kind.planeTicket" : { + "comment" : "\"Plane ticket\"", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Plane ticket" + } + } + } + }, + "evidence.kind.rideshare" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Rideshare" + } + } + } + }, + "evidence.list.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Evidence · %@" + } + } + } + }, + "evidence.row.accessibility" : { + "comment" : "Accessibility summary for a single evidence row: its kind and captured date, e.g. \"Plane ticket, March 4, 2026\".", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@, %2$@" + } + } + } + }, + "launch.accessibilityLabel" : { + "comment" : "Spoken by VoiceOver while the launch splash is on screen (the icon and radar animation are decorative and hidden from accessibility).", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loading" + } + } + } + }, + "launch.caption.subtitle" : { + "comment" : "Subtitle of the splash's slow-launch caption.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This only takes a moment." + } + } + } + }, + "launch.caption.title" : { + "comment" : "Title of the splash's slow-launch caption; deliberately launch-neutral (a migration, a first install's store creation, or plain slowness).", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Getting things ready…" + } + } + } + }, + "loggedDays.add" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log a day" + } + } + } + }, + "loggedDays.delete" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete entry" + } + } + } + }, + "loggedDays.delete.footer" : { + "comment" : "Explanation of what deleting a logged day does.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Removes this manual entry and restores the day's GPS-detected location." + } + } + } + }, + "loggedDays.deleteError.title" : { + "comment" : "Title of an alert when a logged-day deletion fails.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't delete entry" + } + } + } + }, + "loggedDays.edit.date" : { + "comment" : "Label for the date row in the logged-day editor.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Day" + } + } + } + }, + "loggedDays.edit.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Edit day" + } + } + } + }, + "loggedDays.empty.description" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Backfill a trip the GPS missed, or correct a day by hand — your manual entries for this year show up here." + } + } + } + }, + "loggedDays.empty.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No logged days" + } + } + } + }, + "loggedDays.failed.title" : { + "comment" : "Title of the alert when loading logged days fails.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't load logged days" + } + } + } + }, + "loggedDays.filter.all" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All" + } + } + } + }, + "loggedDays.filter.label" : { + "comment" : "Accessibility label for the Logged/Overridden/All segmented filter.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Filter" + } + } + } + }, + "loggedDays.kind.logged" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Logged" + } + } + } + }, + "loggedDays.kind.overridden" : { + "comment" : "Label for a logged-day row that's been manually overridden.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Overridden" + } + } + } + }, + "loggedDays.noMatches.description" : { + "comment" : "Description of a message that appears when there are no logged days that match the current filter.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No days match this filter." + } + } + } + }, + "loggedDays.noMatches.title" : { + "comment" : "Title of a screen that shows when there are no logged days that match the filter.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No matching days" + } + } + } + }, + "loggedDays.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Logged Days · %@" + } + } + } + }, + "manual.day" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Day" + } + } + } + }, + "manual.entry.pickerLabel" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entry" + } + } + } + }, + "manual.from" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "From" + } + } + } + }, + "manual.mode.range" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Date range" + } + } + } + }, + "manual.mode.singleDay" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Single day" + } + } + } + }, + "manual.note.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saved with this entry for auditing — explain why you made this change." + } + } + } + }, + "manual.note.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reason" + } + } + } + }, + "manual.note.placeholder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add a note (optional)" + } + } + } + }, + "manual.range.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Backfilling %lld day." + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Backfilling %lld days." + } + } + } + } + } + } + }, + "manual.regions.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saving replaces any manual regions you previously set for those days." + } + } + } + }, + "manual.regions.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Regions" + } + } + } + }, + "manual.save" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save" + } + } + } + }, + "manual.saveError.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't save that day" + } + } + } + }, + "manual.saving.status" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capturing location…" + } + } + } + }, + "manual.singleDay.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time travel: tell Where where you really were." + } + } + } + }, + "manual.through" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Through" + } + } + } + }, + "manual.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log a Day" + } + } + } + }, + "missing.banner.accessibilityHint" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opens the list of days that still need logging." + } + } + } + }, + "missing.banner.compact.one" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 day needs a location" + } + } + } + }, + "missing.banner.compact.other" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld days need a location" + } + } + } + }, + "missingDays.done" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + } + } + }, + "missingDays.empty.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Every day this year has something logged." + } + } + } + }, + "missingDays.empty.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All caught up" + } + } + } + }, + "missingDays.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap a stretch to record where you were. Today is included until something logs it." + } + } + } + }, + "missingDays.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Days to backfill" + } + } + } + }, + "missingDays.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Missing days" + } + } + } + }, + "onboarding.automatic.description" : { + "comment" : "Description of the automatic location logging feature in the onboarding flow.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "With background location, Where quietly notes the regions you pass through. You can always add or correct days by hand." + } + } + } + }, + "onboarding.automatic.title" : { + "comment" : "Title of the first onboarding screen, describing how Where automatically logs your location.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "It logs itself" + } + } + } + }, + "onboarding.back" : { + "comment" : "Back button label in the onboarding flow.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Back" + } + } + } + }, + "onboarding.continue" : { + "comment" : "Button text for continuing to the next onboarding step.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Continue" + } + } + } + }, + "onboarding.enableLocation" : { + "comment" : "Button text to prompt the user to enable location services.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enable Location" + } + } + } + }, + "onboarding.location.description" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Where uses background location to log the regions you pass through. You can change this anytime in Settings." + } + } + } + }, + "onboarding.location.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Turn on location" + } + } + } + }, + "onboarding.next" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Next" + } + } + } + }, + "onboarding.notNow" : { + "comment" : "Button title for skipping the onboarding flow.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Not Now" + } + } + } + }, + "onboarding.privacy.description" : { + "comment" : "Description of the privacy policy of the app.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your location stays on your device and in your own iCloud. Turn on background location to start your passport." + } + } + } + }, + "onboarding.privacy.title" : { + "comment" : "Title of the privacy section of the onboarding flow.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Private by design" + } + } + } + }, + "onboarding.regions.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Where do you spend your time?" + } + } + } + }, + "onboarding.restoreBackup" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Restore from a backup" + } + } + } + }, + "onboarding.restoreError.title" : { + "comment" : "Title of an alert that appears when a backup can't be restored.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't restore backup" + } + } + } + }, + "onboarding.restoring" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Restoring…" + } + } + } + }, + "onboarding.welcome.description" : { + "comment" : "A longer description of Where, for the onboarding welcome screen.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Where keeps a private passport of which regions you spend your days in — built for residency and day-count questions." + } + } + } + }, + "onboarding.welcome.title" : { + "comment" : "Title of the onboarding welcome screen.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Where have you been?" + } + } + } + }, + "primary.calendar" : { + "comment" : "Label for the primary calendar.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Calendar" + } + } + } + }, + "primary.card.calendarHint" : { + "comment" : "Accessibility hint on a Primary region card: tapping it opens that region's calendar, filtered to the days spent there.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Opens the calendar of days spent here." + } + } + } + }, + "primary.elsewhereOnly.description" : { + "extractionState" : "manual", "localizations" : { "en" : { "variations" : { @@ -841,13 +1692,37 @@ } } }, + "primary.evidence" : { + "comment" : "Label for the \"Evidence\" tab in the primary view.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Evidence" + } + } + } + }, "primary.loading" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Charting your year…" + "state" : "translated", + "value" : "Charting your year…" + } + } + } + }, + "primary.loggedDays" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Logged days" } } } @@ -1160,6 +2035,120 @@ } } }, + "regionCustomize.color" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Color" + } + } + } + }, + "regionCustomize.color.accessibility" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Color %@" + } + } + } + }, + "regionCustomize.emoji" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Emoji" + } + } + } + }, + "regionCustomize.step" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Region %1$lld of %2$lld" + } + } + } + }, + "regionCustomize.subtitle" : { + "comment" : "Subtitle for the region customization screen.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Choose a look for %@" + } + } + } + }, + "regionCustomize.symbol" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Icon" + } + } + } + }, + "regionCustomize.title" : { + "comment" : "Title of the screen that lets the user customize a region.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Make it yours" + } + } + } + }, + "regionGroup.more" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "More regions" + } + } + } + }, + "regionGroup.usedThisYear" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Used this year" + } + } + } + }, + "regionGroup.yours" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your regions" + } + } + } + }, "regionMap.empty.description" : { "extractionState" : "manual", "localizations" : { @@ -1281,13 +2270,212 @@ } } }, - "regionMap.title" : { - "extractionState" : "manual", + "regionMap.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Region Map" + } + } + } + }, + "regionPicker.atCapacity" : { + "comment" : "Shown when the selection is full, so an ignored tap on a new region reads \"at capacity\" rather than \"unresponsive\".", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "That's the maximum of %lld — deselect one to choose another." + } + } + } + }, + "regionPicker.empty.title" : { + "comment" : "Title of the empty state when there are no matching regions.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No matching regions" + } + } + } + }, + "regionPicker.loadError.title" : { + "comment" : "Title of an alert when the map fails to load.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't load the map" + } + } + } + }, + "regionPicker.map.accessibility" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Map for picking your regions" + } + } + } + }, + "regionPicker.mode.list" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "List" + } + } + } + }, + "regionPicker.mode.map" : { + "comment" : "Title of the \"Map\" tab in the region picker.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Map" + } + } + } + }, + "regionPicker.mode.picker" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "View" + } + } + } + }, + "regionPicker.search.prompt" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Search regions" + } + } + } + }, + "regionPicker.selectionCount" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld of %2$lld selected" + } + } + } + }, + "regionPicker.subtitle" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Pick up to 5 regions where you spend your time." + } + } + } + }, + "regionPicker.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your regions" + } + } + } + }, + "regions.manage.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your regions" + } + } + } + }, + "relabel.reason.borderDrift" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Some points look like GPS drift near the %@ border." + } + } + } + }, + "relabel.reason.borderDrift.distance" : { + "comment" : "Reason for relabeling a day as a border drift, including a distance.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Some points sit about %1$@ outside %2$@ — likely GPS drift near the border." + } + } + } + }, + "relabel.reason.flight" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This looked like a flight — high-speed points added %@. Set where you actually were." + } + } + } + }, + "relabel.reason.title" : { + "comment" : "Title of a banner that appears when a day's regions need to be re-labeled.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Why this needs a look" + } + } + } + }, + "relabel.reason.travelDay" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Region Map" + "state" : "new", + "value" : "The days around this one don't overlap — set where you were if you were traveling." } } } @@ -1503,6 +2691,89 @@ } } }, + "resolution.flight.apply" : { + "comment" : "Label for a button that applies a resolution that keeps a set of regions.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Keep %@" + } + } + } + }, + "resolution.flight.bothRight" : { + "comment" : "Label for a button that lets the user keep all the regions.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "These are all correct" + } + } + } + }, + "resolution.flight.detail.explanation" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Some GPS points crossed the map at about %1$@ — that usually means a flight, not somewhere you actually stopped. Applying this keeps where you took off and landed and drops %2$@." + } + } + } + }, + "resolution.flight.detail.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Looks like a flight" + } + } + } + }, + "resolution.flight.manualFix" : { + "comment" : "Label for a button that lets the user manually fix a \"looks like a flight\" resolution.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Not what you expected?" + } + } + } + }, + "resolution.flight.manualFix.footer" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "If this wasn't a flight, or you'd rather set the regions yourself, fix the day by hand." + } + } + } + }, + "resolution.flight.rowSubtitle" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Looks like a flight" + } + } + } + }, "resolution.section.abruptChange" : { "comment" : "Label for a section of the resolution screen that lists days with abrupt location changes.", "extractionState" : "extracted_with_value", @@ -1529,6 +2800,17 @@ } } }, + "resolution.section.flightDay" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Flights" + } + } + } + }, "resolution.section.missingDays" : { "comment" : "Label for a section of the resolution screen that lists missing days.", "extractionState" : "extracted_with_value", @@ -1929,134 +3211,103 @@ } } }, - "developer.button.label" : { - "comment" : "Accessibility label for the floating developer button.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Developer tools" - } - } - } - }, - "developer.close" : { - "comment" : "Accessibility label for closing the developer panel.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Close" - } - } - } - }, - "developer.collapse" : { - "comment" : "Accessibility label for shrinking the developer panel back to a floating window.", - "extractionState" : "manual", + "settings.findIssues" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Exit full screen" + "state" : "new", + "value" : "Find issues now" } } } }, - "developer.expand" : { - "comment" : "Accessibility label for growing the developer panel to full screen.", - "extractionState" : "manual", + "settings.findIssues.result.many" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Enter full screen" + "state" : "new", + "value" : "%lld issues to resolve" } } } }, - "developer.footer" : { - "comment" : "Footer for the developer tools list.", - "extractionState" : "manual", + "settings.findIssues.result.none" : { + "comment" : "Result of a manual \"Find issues now\" scan — the current unresolved count for the year, worded as present state.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "On-device logs and data tools. Debug builds only." + "state" : "new", + "value" : "No issues to resolve" } } } }, - "developer.inspectorLink" : { - "comment" : "Label for the navigation link that opens the SwiftData inspector.", - "extractionState" : "manual", + "settings.findIssues.result.one" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "SwiftData Inspector" + "state" : "new", + "value" : "1 issue to resolve" } } } }, - "developer.inspectorTitle" : { - "comment" : "Navigation title for the SwiftData inspector screen.", - "extractionState" : "manual", + "settings.findIssues.scanning" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "SwiftData" + "state" : "new", + "value" : "Scanning…" } } } }, - "developer.logsLink" : { - "comment" : "Label for the button that opens the logs screen.", + "settings.issueAlerts.deniedFooter" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Logs" + "value" : "Notifications are turned off for Where, so issue alerts can't appear. Turn them on in Settings." } } } }, - "developer.logsTitle" : { - "comment" : "Title of the screen that shows the user's logs.", + "settings.issueAlerts.footer" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Logs" + "value" : "Badge the app icon and send a reminder when there are data issues waiting in the Resolve tab." } } } }, - "developer.regionMapLink" : { - "comment" : "Label for the navigation link that opens the region map.", + "settings.issueAlerts.header" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Region map" + "value" : "Issue alerts" } } } }, - "developer.title" : { - "comment" : "Navigation title of the developer tools list.", + "settings.issueAlerts.toggle" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Developer" + "value" : "Issue alerts" } } } @@ -2149,6 +3400,41 @@ } } }, + "settings.regions.empty" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "None yet" + } + } + } + }, + "settings.regions.row" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Primary regions" + } + } + } + }, + "settings.regions.section" : { + "comment" : "Label for the settings option to manage your regions.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Regions" + } + } + } + }, "settings.reminders.deniedFooter" : { "extractionState" : "manual", "localizations" : { @@ -2359,145 +3645,134 @@ } } }, - "settings.issueAlerts.deniedFooter" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Notifications are turned off for Where, so issue alerts can't appear. Turn them on in Settings." - } - } - } - }, - "settings.issueAlerts.footer" : { + "settings.summary.deniedFooter" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Badge the app icon and send a reminder when there are data issues waiting in the Resolve tab." + "value" : "Notifications are turned off for Where, so the daily summary can't appear. Turn them on in Settings." } } } }, - "settings.issueAlerts.header" : { + "settings.summary.footer" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Issue alerts" + "value" : "Get a morning recap of how many days you've logged in each region so far this year." } } } }, - "settings.issueAlerts.toggle" : { + "settings.summary.header" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Issue alerts" + "value" : "Daily summary" } } } }, - "settings.summary.deniedFooter" : { + "settings.summary.time" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Notifications are turned off for Where, so the daily summary can't appear. Turn them on in Settings." + "value" : "Send at" } } } }, - "settings.summary.footer" : { + "settings.summary.toggle" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Get a morning recap of how many days you've logged in each region so far this year." + "value" : "Daily summary" } } } }, - "settings.summary.header" : { + "settings.tabs.footer" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Daily summary" + "value" : "Hide the Elsewhere and Resolve tabs while they have nothing to show. Turn this off to always keep them in the tab bar." } } } }, - "settings.summary.time" : { + "settings.tabs.header" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Send at" + "value" : "Tabs" } } } }, - "settings.summary.toggle" : { + "settings.tabs.toggle" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Daily summary" + "value" : "Hide empty tabs" } } } }, - "settings.tabs.footer" : { + "settings.title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Hide the Elsewhere and Resolve tabs while they have nothing to show. Turn this off to always keep them in the tab bar." + "value" : "Settings" } } } }, - "settings.tabs.header" : { - "extractionState" : "manual", + "settings.year.footer" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Tabs" + "state" : "new", + "value" : "Choose which year your reports, calendar, and logged days cover." } } } }, - "settings.tabs.toggle" : { - "extractionState" : "manual", + "settings.year.header" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Hide empty tabs" + "state" : "new", + "value" : "Report year" } } } }, - "settings.title" : { - "extractionState" : "manual", + "settings.year.label" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Settings" + "state" : "new", + "value" : "Year" } } } diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index 65eccd50..c4ce738a 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -1,6 +1,11 @@ import LifecycleKit +import PeriscopeUI import SwiftUI import WhereCore +#if DEBUG + import PeriscopeCore + import PeriscopeTools +#endif /// The app's root: the launch sequence gated in front of a Liquid Glass tab bar /// over the four top-level screens (Primary, Elsewhere, Resolve, Settings). @@ -20,6 +25,16 @@ public struct RootView: View { /// handed to the sibling `DeveloperOverlay` so its button rests clear of the /// tab bar. Zero when logged out (no tab bar in the tree). @State private var developerTabBarInset: CGFloat = 0 + /// Periscope's "log view mode" mirror, built once the launch bootstrap has + /// opened the log store. Injected into the environment so + /// `debugLogInspectable(_:)` badges across the app can reveal their scopes; + /// the developer overlay binds its toggle to it. + @State private var inspector: PeriscopeInspector? + /// Watches the shared pipeline for `.warning`+ records and shows them as + /// in-app toasts while developing. Retained for the process; started once + /// the store is available. + @State private var alerter: PeriscopeAlerter? + @State private var toastCenter = DeveloperToastCenter() #endif private let launcher: LifecycleRunner @@ -67,59 +82,70 @@ public struct RootView: View { // DEBUG-only and compiled out of release entirely. #if DEBUG DeveloperOverlay(tabBarInset: developerTabBarInset) + // High-severity log toasts float above everything, including the + // developer overlay, so a warning/error is visible wherever it fires. + DeveloperToastOverlay(center: toastCenter) #endif } #if DEBUG .onPreferenceChange(DeveloperTabBarInsetKey.self) { developerTabBarInset = $0 } + .environment(\.periscopeInspector, inspector) + .task { configureDeveloperLogging() } + .onChange(of: model.logStore.map(ObjectIdentifier.init)) { _, _ in + configureDeveloperLogging() + } #endif - .environment(model) - // The logged-in session appears once `open-store` builds it. Injected - // as an optional `Observable`, so the `TabView`'s `@Environment(WhereSession.self)` - // views resolve it (they only render at `.ready`, by which point it's - // present) and re-inject when a reset rebuilds it. The DEBUG developer - // overlay reads it optionally — it can appear before login, where the - // SwiftData inspector row simply hides. - .environment(model.session) - // Settings' "Erase all data & reset" runs the teardown through the - // `LifecycleRunner` that `LifecycleContainer` publishes into the - // environment, which wipes data + preferences and re-drives the launch - // sequence back to onboarding. - // - // `run()` is idempotent: in the app the delegate already kicked it off, - // so this is a no-op there; in previews/tests it's what drives the - // launch. - // - // Promote a background launch only once the scene is genuinely active. - // SwiftUI may build this view (and run `.task`) for a scene that iOS - // connected in the background; promoting then would flip the launcher to - // foreground and build the heavy `TabView` for a launch nobody sees, - // defeating the headless path. The `.onChange` below handles the later - // background→foreground transition; this initial check covers a launch - // that is already active when the view first appears — and it must run - // *before* awaiting `run()`: when the scene arrives already active, - // `.onChange` never fires, and promoting only after `run()` returns - // would leave the user staring at the empty background surface for the - // entire (possibly slow) headless drive instead of the splash. - .task { - if scenePhase == .active { - await launcher.enterForeground() + // Seed the app's root logging context so any view that logs freeform via + // `\.logContext` emits under the "Where" scope rather than a bare root. + .logContext(WhereLog.root) + .environment(model) + // The logged-in session appears once `open-store` builds it. Injected + // as an optional `Observable`, so the `TabView`'s `@Environment(WhereSession.self)` + // views resolve it (they only render at `.ready`, by which point it's + // present) and re-inject when a reset rebuilds it. The DEBUG developer + // overlay reads it optionally — it can appear before login, where the + // SwiftData inspector row simply hides. + .environment(model.session) + // Settings' "Erase all data & reset" runs the teardown through the + // `LifecycleRunner` that `LifecycleContainer` publishes into the + // environment, which wipes data + preferences and re-drives the launch + // sequence back to onboarding. + // + // `run()` is idempotent: in the app the delegate already kicked it off, + // so this is a no-op there; in previews/tests it's what drives the + // launch. + // + // Promote a background launch only once the scene is genuinely active. + // SwiftUI may build this view (and run `.task`) for a scene that iOS + // connected in the background; promoting then would flip the launcher to + // foreground and build the heavy `TabView` for a launch nobody sees, + // defeating the headless path. The `.onChange` below handles the later + // background→foreground transition; this initial check covers a launch + // that is already active when the view first appears — and it must run + // *before* awaiting `run()`: when the scene arrives already active, + // `.onChange` never fires, and promoting only after `run()` returns + // would leave the user staring at the empty background surface for the + // entire (possibly slow) headless drive instead of the splash. + .task { + if scenePhase == .active { + await launcher.enterForeground() + } + await launcher.run() } - await launcher.run() - } - .onChange(of: scenePhase) { _, newPhase in - guard newPhase == .active else { return } - Task { - await launcher.enterForeground() - await model.session?.appBecameActive() + .onChange(of: scenePhase) { _, newPhase in + guard newPhase == .active else { return } + Task { + await launcher.enterForeground() + await model.session?.appBecameActive() + } } - } - // Seed the Broadway context at the app root so descendants resolve - // `WhereStylesheet` (via `@Environment(\.stylesheet)`) against the live - // system traits and the app's themes, plus the session's live region - // styles (`\.regionStyles`) so cards/calendar/onboarding render the - // user's picked looks. `.default` before the session exists (splash) and - // reactive after, since reading `session.regionStyles` tracks it. - .whereBroadwayRoot(regionStyles: model.session?.regionStyles ?? .default) + // Seed the Broadway context at the app root so descendants resolve + // `WhereStylesheet` (via `@Environment(\.stylesheet)`) against the live + // system traits and the app's themes, plus the session's live region + // styles (`\.regionStyles`) so cards/calendar/onboarding render the + // user's picked looks. `.default` before the session exists (splash) and + // reactive after, since reading `session.regionStyles` tracks it. + .whereBroadwayRoot(regionStyles: model.session?.regionStyles ?? .default) } /// How the launch splash gives way to the app once the runner is `.ready`: @@ -139,6 +165,26 @@ public struct RootView: View { private var revealAnimation: Animation { reduceMotion ? stylesheet.motion.reducedReveal : stylesheet.motion.reveal } + + #if DEBUG + /// Build the log-view-mode inspector and start the toast alerter once the + /// launch bootstrap has opened the process-global store. Idempotent: it's + /// driven from both the initial `.task` (fixtures that inject a store up + /// front) and an `.onChange` (the app, where the store opens off the + /// launch path), and does nothing until the store exists or after it's + /// wired once. + private func configureDeveloperLogging() { + guard inspector == nil, let store = model.logStore else { return } + inspector = PeriscopeInspector(system: .shared, store: store) + let alerter = PeriscopeAlerter( + system: .shared, + threshold: .warning, + handler: DeveloperToastAlertHandler(center: toastCenter), + ) + alerter.start() + self.alerter = alerter + } + #endif } #if DEBUG diff --git a/Where/WhereUI/Sources/Secondary/SecondaryView.swift b/Where/WhereUI/Sources/Secondary/SecondaryView.swift index 97ff5d10..718b628b 100644 --- a/Where/WhereUI/Sources/Secondary/SecondaryView.swift +++ b/Where/WhereUI/Sources/Secondary/SecondaryView.swift @@ -1,3 +1,4 @@ +import PeriscopeCore import RegionKit import SwiftUI import WhereCore @@ -20,6 +21,10 @@ struct SecondaryView: View { .navigationTitle(Strings.secondaryTitle) } .task(id: report.report) { await loadPlaceNames() } + // Log View Mode: reveal an inspect badge for the year-report events + // backing the Elsewhere tab (representative-coordinate loads). A no-op + // in release. + .debugLogInspectable(WhereLog.session(YearReportModelLog.self)) } /// Pick each secondary region's most-sampled spot and reverse-geocode it, diff --git a/Where/WhereUI/Sources/Settings/BackupModel.swift b/Where/WhereUI/Sources/Settings/BackupModel.swift index e332e132..07ad3d33 100644 --- a/Where/WhereUI/Sources/Settings/BackupModel.swift +++ b/Where/WhereUI/Sources/Settings/BackupModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import WhereCore /// View-scoped model for the Settings backup section: export/import progress and @@ -38,7 +38,7 @@ public final class BackupModel { } private let services: WhereServices - private static let logger = WhereLog.channel(.session) + private static let logger = WhereLog.session(BackupModelLog.self) public init(services: WhereServices) { self.services = services @@ -72,12 +72,12 @@ public final class BackupModel { let url = try await services.backup.exportBackup { continuation.yield($0) } continuation.finish() await observer.value - Self.logger.info("Exported backup archive") + Self.logger { .exported } return url } catch { continuation.finish() backupError = error.localizedDescription - Self.logger.warning("Backup export failed: \(error.localizedDescription)") + Self.logger { .exportFailed(description: error.localizedDescription) } return nil } } @@ -121,14 +121,20 @@ public final class BackupModel { } continuation.finish() await observer.value - Self.logger.info( - "Imported backup (\(summary.sampleCount) samples, \(summary.evidenceCount) evidence, \(summary.manualDayCount) manual days, \(summary.dismissedIssueCount) dismissals, \(summary.trackedRegionCount) tracked regions)", - ) + Self.logger { + .imported( + sampleCount: summary.sampleCount, + evidenceCount: summary.evidenceCount, + manualDayCount: summary.manualDayCount, + dismissedIssueCount: summary.dismissedIssueCount, + trackedRegionCount: summary.trackedRegionCount, + ) + } return summary } catch { continuation.finish() backupError = error.localizedDescription - Self.logger.warning("Backup import failed: \(error.localizedDescription)") + Self.logger { .importFailed(description: error.localizedDescription) } return nil } } diff --git a/Where/WhereUI/Sources/Settings/LocationStatusRow.swift b/Where/WhereUI/Sources/Settings/LocationStatusRow.swift index de920319..be53125b 100644 --- a/Where/WhereUI/Sources/Settings/LocationStatusRow.swift +++ b/Where/WhereUI/Sources/Settings/LocationStatusRow.swift @@ -25,6 +25,9 @@ struct LocationStatusRow: View { } .accessibilityElement(children: .combine) .accessibilityLabel(presentation.title) + // Log View Mode: reveal an inspect badge that opens the session's + // location/authorization events. A no-op in release. + .debugLogInspectable(WhereLog.session) } private struct Presentation { diff --git a/Where/WhereUI/Sources/Settings/RemindersSettingsModel.swift b/Where/WhereUI/Sources/Settings/RemindersSettingsModel.swift index b2e1a736..32a7870b 100644 --- a/Where/WhereUI/Sources/Settings/RemindersSettingsModel.swift +++ b/Where/WhereUI/Sources/Settings/RemindersSettingsModel.swift @@ -1,5 +1,4 @@ import Foundation -import LogKit import Observation import WhereCore @@ -141,7 +140,6 @@ public final class RemindersSettingsModel { private let preferences: WherePreferences private let now: @Sendable () -> Date private let calendar: Calendar - private static let logger = WhereLog.channel(.session) public init( services: WhereServices, diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index f1dc1799..e1938c09 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -1,4 +1,5 @@ import LifecycleKit +import PeriscopeCore import RegionKit import SwiftUI import UIKit @@ -461,6 +462,9 @@ struct SettingsView: View { .task(id: exportedArchiveURL) { await expireExportIfNeeded() } + // Log View Mode: reveal an inspect badge for backup export/import + // events on this section. A no-op in release. + .debugLogInspectable(WhereLog.session(BackupModelLog.self)) } /// Determinate progress for an in-flight export or import, driven by diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index 6cc7202c..53bc2935 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -695,6 +695,22 @@ enum Strings { localized("developer.logsTitle") } + static var developerOpenSpansLink: String { + String(localized: "developer.openSpansLink", defaultValue: "Open spans", bundle: .module) + } + + static var developerLogViewMode: String { + String(localized: "developer.logViewMode", defaultValue: "Log View Mode", bundle: .module) + } + + static var developerLogViewModeFooter: String { + String( + localized: "developer.logViewMode.footer", + defaultValue: "Reveal an inspect badge on tagged views to open their recent logs.", + bundle: .module, + ) + } + static var developerInspectorLink: String { localized("developer.inspectorLink") } diff --git a/Where/WhereUI/Tests/ScreenHostingTests.swift b/Where/WhereUI/Tests/ScreenHostingTests.swift index ba928e9d..8631b3f0 100644 --- a/Where/WhereUI/Tests/ScreenHostingTests.swift +++ b/Where/WhereUI/Tests/ScreenHostingTests.swift @@ -1,4 +1,5 @@ -import LogViewerUI +import PeriscopeCore +import PeriscopeTools import RegionKit import SwiftUI import TestHostSupport @@ -247,20 +248,35 @@ struct ScreenHostingTests { } } - @Test func debugLogViewerHostsWithSharedStore() throws { - // The developer tools surface pushes this viewer over WhereLog's buffer. + @Test func periscopeViewerHostsOverTheLogStore() async throws { + // The developer tools surface pushes this viewer over the process-global + // Periscope store. + let store = try await PeriscopeStore.make(storage: .inMemory, session: .current()) let rootView = NavigationStack { - LogViewer(configuration: LogViewerConfiguration(store: WhereLog.store, title: "Logs")) + PeriscopeViewer(store: store, title: "Logs") } try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) } } - @Test func developerToolsViewHosts() throws { - // Reads the logged-in session from the environment for the SwiftData - // inspector row; owns its own navigation stack for the pushed viewers. + @Test func openSpansViewHosts() throws { + // The open-spans monitor reads the shared system directly. + let rootView = NavigationStack { + OpenSpansView(system: .shared) + } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + + @Test func developerToolsViewHosts() async throws { + // Reads the app model (for the log store) and the logged-in session (for + // the SwiftData inspector row) from the environment; owns its own + // navigation stack for the pushed viewers. + let store = try await PeriscopeStore.make(storage: .inMemory, session: .current()) let rootView = DeveloperToolsView() + .environment(PreviewSupport.loadedModel(withLogStore: store)) .environment(PreviewSupport.loadedSession()) try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) @@ -271,6 +287,7 @@ struct ScreenHostingTests { // The floating overlay mounts (collapsed) with a session available for the // tools it can expand into. let rootView = DeveloperOverlay() + .environment(PreviewSupport.loadedModel()) .environment(PreviewSupport.loadedSession()) try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) diff --git a/Where/WhereUI/Tests/StringsTests.swift b/Where/WhereUI/Tests/StringsTests.swift index 86c44d70..03723d86 100644 --- a/Where/WhereUI/Tests/StringsTests.swift +++ b/Where/WhereUI/Tests/StringsTests.swift @@ -146,6 +146,12 @@ struct StringsTests { #expect(Strings.developerTitle == "Developer") #expect(Strings.developerLogsLink == "Logs") #expect(Strings.developerLogsTitle == "Logs") + #expect(Strings.developerOpenSpansLink == "Open spans") + #expect(Strings.developerLogViewMode == "Log View Mode") + #expect( + Strings.developerLogViewModeFooter + == "Reveal an inspect badge on tagged views to open their recent logs.", + ) #expect(Strings.developerInspectorLink == "SwiftData Inspector") #expect(Strings.developerInspectorTitle == "SwiftData") #expect(Strings.developerRegionMapLink == "Region map") diff --git a/Where/WhereUI/Tests/WhereLaunchTests.swift b/Where/WhereUI/Tests/WhereLaunchTests.swift index 5a4df600..c4b4f1e3 100644 --- a/Where/WhereUI/Tests/WhereLaunchTests.swift +++ b/Where/WhereUI/Tests/WhereLaunchTests.swift @@ -1,5 +1,6 @@ import Foundation import LifecycleKit +@_spi(Testing) import PeriscopeCore import RegionKit import SwiftData import TestHostSupport @@ -189,6 +190,17 @@ struct WhereLaunchTests { #expect(launcher.phase.isReady) } + @Test func attachingLogStoreExposesItOnTheModel() async throws { + // The launch bootstrap opens the process-global store off the critical + // path and hands it to the model so the developer surface can browse it. + // A fresh model has none until then. + let model = try makeModel(preferences: makePreferences()) + #expect(model.logStore == nil) + let store = try await PeriscopeStore.inMemory(session: .current()) + model.attach(logStore: store) + #expect(model.logStore === store) + } + @Test func openStoreHandsTheSessionsServicesToTheOnServicesReadyHook() async throws { // A model with services attached but no session yet — the app's shape // when the open-store step runs (the preview/test init pre-builds the diff --git a/Where/WhereWidgets/AGENTS.md b/Where/WhereWidgets/AGENTS.md index 7fc10db3..5d5da2e9 100644 --- a/Where/WhereWidgets/AGENTS.md +++ b/Where/WhereWidgets/AGENTS.md @@ -11,9 +11,11 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature - **Tuist app-extension target** ([`Project.swift`](../../Project.swift), bundle ID `com.stuff.where.widgets`), depending on **WhereCore**, - **WhereUI**, **RegionKit**, and **LogKit**. + **WhereUI**, **RegionKit**, and **PeriscopeCore**. - Must **not** import SwiftData, open the user's store, or duplicate aggregation logic — the app publishes; the extension only reads and renders. +- Logs via the `WhereLog` facade (typed `WhereWidgetsLog` events); as a + separate WidgetKit process its `Periscope.shared` is OSLog-only (no store). - No test bundle; behavior is covered from **WhereCore** and **WhereUI**. ## Refresh contract diff --git a/Where/WhereWidgets/README.md b/Where/WhereWidgets/README.md index 054c57d3..3ca9cbc1 100644 --- a/Where/WhereWidgets/README.md +++ b/Where/WhereWidgets/README.md @@ -44,7 +44,7 @@ app never wakes. `WhereWidgets` is a Tuist app-extension target in [`Project.swift`](../../Project.swift) (bundle ID `com.stuff.where.widgets`). It depends on **WhereCore**, **WhereUI**, **RegionKit** (for the `Region` model -its snapshot fixtures use), and **LogKit**. The main **Where** app embeds the +its snapshot fixtures use), and **PeriscopeCore**. The main **Where** app embeds the extension and shares the App Group entitlement. ## Previews diff --git a/Where/WhereWidgets/Sources/Logging/WhereWidgetsLog.swift b/Where/WhereWidgets/Sources/Logging/WhereWidgetsLog.swift new file mode 100644 index 00000000..802e15db --- /dev/null +++ b/Where/WhereWidgets/Sources/Logging/WhereWidgetsLog.swift @@ -0,0 +1,31 @@ +import PeriscopeCore + +/// Structured events for the Where widget timeline provider — a separate +/// WidgetKit process, so `Periscope.shared` stays OSLog-only (no store). +enum WhereWidgetsLog: LogEvent { + /// No snapshot has been published yet (fresh install, unreadable file); the + /// provider renders the empty state. + case noPublishedSnapshot + /// The shared App Group container couldn't be opened. + case appGroupUnavailable(description: String) + + static let eventName = "WhereWidgets" + + var level: LogLevel { + switch self { + case .noPublishedSnapshot: + .warning + case .appGroupUnavailable: + .error + } + } + + var message: String { + switch self { + case .noPublishedSnapshot: + "No published widget snapshot; rendering empty state" + case let .appGroupUnavailable(description): + "Widget App Group unavailable: \(description)" + } + } +} diff --git a/Where/WhereWidgets/Sources/WhereWidgetProvider.swift b/Where/WhereWidgets/Sources/WhereWidgetProvider.swift index cb61a610..3cc6022f 100644 --- a/Where/WhereWidgets/Sources/WhereWidgetProvider.swift +++ b/Where/WhereWidgets/Sources/WhereWidgetProvider.swift @@ -1,4 +1,4 @@ -import LogKit +import PeriscopeCore import WhereCore import WidgetKit @@ -14,7 +14,7 @@ struct WhereWidgetEntry: TimelineEntry { /// even if the app never wakes; the snapshot's data is refreshed by the app /// process after each committed write (see `WidgetTimelineRefreshing`). struct WhereWidgetProvider: TimelineProvider { - private static let logger = WhereLog.channel(.whereWidgets) + private static let logger = WhereLog.root(WhereWidgetsLog.self) private static let calendar = WidgetSnapshotFixtures.calendar func placeholder(in _: Context) -> WhereWidgetEntry { @@ -52,9 +52,11 @@ struct WhereWidgetProvider: TimelineProvider { if let snapshot = store.read() { return WhereWidgetEntry(date: now, snapshot: snapshot) } - Self.logger.warning("No published widget snapshot; rendering empty state") + Self.logger { .noPublishedSnapshot } } catch { - Self.logger.error("Widget App Group unavailable: \(error)") + Self.logger(attachments: [.error(error, name: "app-group-error")]) { + .appGroupUnavailable(description: String(describing: error)) + } } return WhereWidgetEntry( date: now,