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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
| SwiftFormat | 0.60.1 | `.mise.toml` |
| Swift PM | 6.2 | `Package.swift` (`swift-tools-version`) |

**Libraries** (**StuffCore**, **LifecycleKit**, **LogKit**, **LogViewerUI**, **WhereCore**, **WhereUI**, **WhereTesting**) are defined in the root [`Package.swift`](Package.swift) — local package for libraries, Tuist for apps and test bundles.
**Libraries** (**StuffCore**, **LifecycleKit**, **LogKit**, **LogViewerUI**, **RegionKit**, **WhereCore**, **WhereUI**, **WhereTesting**) are defined in the root [`Package.swift`](Package.swift) — local package for libraries, Tuist for apps and test bundles.

Tuist manifests live at the repo root ([`Project.swift`](Project.swift), [`Tuist.swift`](Tuist.swift)). `Project.swift` references `Package.local(path: .relativeToRoot("."))` and declares the **Where** app, **StuffTestHost**, and unit-test targets that depend on package products.

Expand Down Expand Up @@ -53,8 +53,8 @@ by `./sync-agents`.

## Targets

- **Package products** ([`Package.swift`](Package.swift)) — **StuffCore** ([`Shared/StuffCore/Sources/`](Shared/StuffCore/Sources/)), **LifecycleKit** ([`Shared/LifecycleKit/Sources/`](Shared/LifecycleKit/Sources/)), **LogKit** ([`Shared/LogKit/Sources/`](Shared/LogKit/Sources/), the logging facade), **LogViewerUI** ([`Shared/LogViewerUI/Sources/`](Shared/LogViewerUI/Sources/), the generic SwiftUI log viewer), and **SwiftDataInspector** ([`Shared/SwiftDataInspector/Sources/`](Shared/SwiftDataInspector/Sources/), the generic SwiftData browser) under [`Shared/`](Shared/); **WhereCore** / **WhereUI** / **WhereTesting** under [`Where/`](Where/); **ForemanCore** ([`Foreman/ForemanCore/Sources/`](Foreman/ForemanCore/Sources/), the only **macOS-only** package library, which processes a generated-symbol `Resources/Localizable.xcstrings`) under [`Foreman/`](Foreman/).
- **Tuist targets** ([`Project.swift`](Project.swift)) — **Where** app ([`Where/Where/`](Where/Where/)), **RegionViewer** ([`Where/RegionViewer/`](Where/RegionViewer/), a thin standalone **Mac Catalyst** host for the WhereUI region-map developer tool — the only target with a `.macCatalyst` destination), **Foreman** ([`Foreman/Foreman/`](Foreman/Foreman/), the **native-macOS** menu bar app that runs Cursor local agent workers per repo; its user-facing copy is a generated-symbol `Resources/Localizable.xcstrings` via `STRING_CATALOG_GENERATE_SYMBOLS`), **StuffTestHost** ([`Shared/StuffTestHost/`](Shared/StuffTestHost/)), **WhereTests** (app tests, no host), hosted **\*Tests** bundles (**StuffCoreTests**, **LifecycleKitTests**, **LogKitTests**, **LogViewerUITests**, **SwiftDataInspectorTests**, **WhereCoreTests**, **WhereUITests**) that depend on **StuffTestHost** + **WhereTesting** + the relevant package product, and the hostless macOS bundle **ForemanCoreTests**.
- **Package products** ([`Package.swift`](Package.swift)) — **StuffCore** ([`Shared/StuffCore/Sources/`](Shared/StuffCore/Sources/)), **LifecycleKit** ([`Shared/LifecycleKit/Sources/`](Shared/LifecycleKit/Sources/)), **LogKit** ([`Shared/LogKit/Sources/`](Shared/LogKit/Sources/), the logging facade), **LogViewerUI** ([`Shared/LogViewerUI/Sources/`](Shared/LogViewerUI/Sources/), the generic SwiftUI log viewer), and **SwiftDataInspector** ([`Shared/SwiftDataInspector/Sources/`](Shared/SwiftDataInspector/Sources/), the generic SwiftData browser) under [`Shared/`](Shared/); **RegionKit** ([`Where/RegionKit/Sources/`](Where/RegionKit/Sources/), the geometry + GeoJSON + region-lookup engine WhereCore builds on) / **WhereCore** / **WhereUI** / **WhereTesting** under [`Where/`](Where/); **ForemanCore** ([`Foreman/ForemanCore/Sources/`](Foreman/ForemanCore/Sources/), the only **macOS-only** package library, which processes a generated-symbol `Resources/Localizable.xcstrings`) under [`Foreman/`](Foreman/).
- **Tuist targets** ([`Project.swift`](Project.swift)) — **Where** app ([`Where/Where/`](Where/Where/)), **RegionViewer** ([`Where/RegionViewer/`](Where/RegionViewer/), a thin standalone **Mac Catalyst** host for the WhereUI region-map developer tool — the only target with a `.macCatalyst` destination), **Foreman** ([`Foreman/Foreman/`](Foreman/Foreman/), the **native-macOS** menu bar app that runs Cursor local agent workers per repo; its user-facing copy is a generated-symbol `Resources/Localizable.xcstrings` via `STRING_CATALOG_GENERATE_SYMBOLS`), **StuffTestHost** ([`Shared/StuffTestHost/`](Shared/StuffTestHost/)), **WhereTests** (app tests, no host), hosted **\*Tests** bundles (**StuffCoreTests**, **LifecycleKitTests**, **LogKitTests**, **LogViewerUITests**, **SwiftDataInspectorTests**, **RegionKitTests**, **WhereCoreTests**, **WhereUITests**) that depend on **StuffTestHost** + **WhereTesting** + the relevant package product, and the hostless macOS bundle **ForemanCoreTests**.
- Add SPM library targets in `Package.swift` and wire apps/tests in `Project.swift` (see existing `unitTests` helper; macOS test bundles are declared directly, like `ForemanCoreTests`). A new module also ships a root `README.md` and `AGENTS.md` — see [Per-module docs](#per-module-docs).
- **CI schemes**: the workspace mixes iOS and macOS targets, which no single xcodebuild destination can build, so CI runs two explicit shared schemes — **Stuff-iOS-Tests** (all iOS bundles) and **Foreman-macOS-Tests** (Foreman app + ForemanCoreTests). New iOS test bundles must be added to the `Stuff-iOS-Tests` scheme in `Project.swift` or CI won't run them.

Expand Down
13 changes: 13 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ let package = Package(
.library(name: "LogKit", targets: ["LogKit"]),
.library(name: "LogViewerUI", targets: ["LogViewerUI"]),
.library(name: "SwiftDataInspector", targets: ["SwiftDataInspector"]),
.library(name: "RegionKit", targets: ["RegionKit"]),
.library(name: "WhereCore", targets: ["WhereCore"]),
.library(name: "WhereUI", targets: ["WhereUI"]),
.library(name: "WhereTesting", targets: ["WhereTesting"]),
Expand Down Expand Up @@ -59,10 +60,21 @@ let package = Package(
name: "SwiftDataInspector",
path: "Shared/SwiftDataInspector/Sources",
),
.target(
name: "RegionKit",
dependencies: [
.target(name: "LogKit"),
],
path: "Where/RegionKit/Sources",
resources: [
.process("Resources"),
],
),
.target(
name: "WhereCore",
dependencies: [
.target(name: "LogKit"),
.target(name: "RegionKit"),
.product(name: "ZIPFoundation", package: "ZIPFoundation"),
],
path: "Where/WhereCore/Sources",
Expand All @@ -77,6 +89,7 @@ let package = Package(
.target(name: "LifecycleKit"),
.target(name: "LogKit"),
.target(name: "LogViewerUI"),
.target(name: "RegionKit"),
.target(name: "SwiftDataInspector"),
],
path: "Where/WhereUI/Sources",
Expand Down
34 changes: 27 additions & 7 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ let project = Project(
dependencies: [
.package(product: "LifecycleKit"),
.package(product: "LogKit"),
.package(product: "RegionKit"),
.package(product: "WhereCore"),
.package(product: "WhereUI"),
.target(name: "WhereWidgets"),
Expand Down Expand Up @@ -130,6 +131,7 @@ let project = Project(
entitlements: whereAppGroupEntitlements,
dependencies: [
.package(product: "LogKit"),
.package(product: "RegionKit"),
.package(product: "WhereCore"),
.package(product: "WhereUI"),
],
Expand All @@ -151,9 +153,10 @@ let project = Project(
sources: ["Where/RegionViewer/Sources/**"],
resources: ["Where/RegionViewer/Resources/**"],
// No App Group entitlement — the viewer only reads bundled GeoJSON
// (embedded via the WhereCore dependency), never the app's store.
// (embedded via the RegionKit dependency), never the app's store.
dependencies: [
.package(product: "LogKit"),
.package(product: "RegionKit"),
.package(product: "WhereCore"),
.package(product: "WhereUI"),
],
Expand Down Expand Up @@ -256,13 +259,15 @@ let project = Project(
]),
]),
sources: ["Shared/StuffTestHost/Sources/**"],
// Hosted Swift Testing bundles run inside StuffTestHost, so WhereCore's
// Hosted Swift Testing bundles run inside StuffTestHost, so a package's
// `Bundle.module` resolves against the host app's main bundle at runtime.
// Depending on WhereCore here makes Tuist embed `Stuff_WhereCore.bundle`
// (its GeoJSON region data) into the host, so code the tests touch — e.g.
// the lazy `RegionAttributor.shared` — finds its resources instead of
// trapping in the `Bundle.module` accessor.
// Depending on RegionKit and WhereCore here makes Tuist embed their
// resource bundles (`Stuff_RegionKit.bundle`, which holds the GeoJSON
// region data, and `Stuff_WhereCore.bundle`) into the host, so code the
// tests touch — e.g. the lazy `RegionAttributor.shared` — finds its
// resources instead of trapping in the `Bundle.module` accessor.
dependencies: [
.package(product: "RegionKit"),
.package(product: "WhereCore"),
],
),
Expand Down Expand Up @@ -296,18 +301,30 @@ let project = Project(
productDependency: "SwiftDataInspector",
sources: ["Shared/SwiftDataInspector/Tests/**"],
),
unitTests(
name: "RegionKitTests",
bundleIdSuffix: "regionkit",
productDependency: "RegionKit",
sources: ["Where/RegionKit/Tests/**"],
),
unitTests(
name: "WhereCoreTests",
bundleIdSuffix: "wherecore",
productDependency: "WhereCore",
sources: ["Where/WhereCore/Tests/**"],
extraPackageProducts: ["RegionKit"],
),
unitTests(
name: "WhereUITests",
bundleIdSuffix: "whereui",
productDependency: "WhereUI",
sources: ["Where/WhereUI/Tests/**"],
extraPackageProducts: ["LifecycleKit", "LogViewerUI", "SwiftDataInspector"],
extraPackageProducts: [
"LifecycleKit",
"LogViewerUI",
"RegionKit",
"SwiftDataInspector",
],
),
],
// Tuist's autogeneration doesn't emit working standalone test actions for
Expand Down Expand Up @@ -347,6 +364,7 @@ let project = Project(
"LogKitTests",
"LogViewerUITests",
"SwiftDataInspectorTests",
"RegionKitTests",
"WhereCoreTests",
"WhereTests",
"WhereUITests",
Expand All @@ -357,6 +375,7 @@ let project = Project(
"LogKitTests",
"LogViewerUITests",
"SwiftDataInspectorTests",
"RegionKitTests",
"WhereCoreTests",
"WhereTests",
"WhereUITests",
Expand All @@ -374,6 +393,7 @@ let project = Project(
testScheme(name: "LogKitTests"),
testScheme(name: "LogViewerUITests"),
testScheme(name: "SwiftDataInspectorTests"),
testScheme(name: "RegionKitTests"),
testScheme(name: "WhereCoreTests"),
testScheme(name: "WhereTests"),
testScheme(name: "WhereUITests"),
Expand Down
10 changes: 7 additions & 3 deletions Shared/LogViewerUI/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# LogViewerUI – Module Shape

LogViewerUI is an app-agnostic SwiftUI **log viewer** over a
[`LogKit`](../LogKit) `LogStore`: hand it a `LogViewerConfiguration` (store,
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.
Expand All @@ -22,7 +22,11 @@ build system, formatting, and global conventions. Read that first.

- **Read-only mirror.** Recording lives in `LogKit`; this module only
consumes snapshots on the main actor (the one write is `clear()`, which
updates `entries` synchronously so the list empties immediately).
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
Expand Down
44 changes: 27 additions & 17 deletions Shared/LogViewerUI/README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# LogViewerUI

A small, app-agnostic SwiftUI **log viewer** over a [`LogKit`](../LogKit)
`LogStore`. Point it at a buffer 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.
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 this session.
buffer(s) this session.

LogViewerUI depends only on **SwiftUI + Observation + UIKit (pasteboard) +
LogKit** — no app code.
Expand Down Expand Up @@ -60,6 +61,13 @@ screen.

```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",
Expand All @@ -68,32 +76,34 @@ public struct LogViewerConfiguration: Sendable {
}
```

- **`store`** — the `LogKit` buffer to read and observe.
- **`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
into `entries` and derives cached `filteredEntries` (newest-first, after
`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
`LogStore.changes()` until the model is deallocated — so the list stays live
without the view touching the lock-guarded store directly. Recording stays off
the main actor in `LogKit`; this module only consumes snapshots on the main
actor for display.
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 Settings, pointed at the
process-wide buffer its `WhereLog` facade writes to, mapping raw categories
through its typed enum:
The Where app exposes it behind a DEBUG-only entry in Settings, 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(
store: WhereLog.store,
stores: [WhereLog.store, RegionLog.store],
title: Strings.settingsDebugLogsTitle,
))
} label: {
Expand Down
2 changes: 1 addition & 1 deletion Shared/LogViewerUI/Sources/LogViewer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public struct LogViewer: View {
public init(configuration: LogViewerConfiguration) {
self.configuration = configuration
_model = State(initialValue: LogViewerModel(
store: configuration.store,
stores: configuration.stores,
categoryDisplayName: configuration.categoryDisplayName,
))
}
Expand Down
27 changes: 21 additions & 6 deletions Shared/LogViewerUI/Sources/LogViewerConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ import LogKit
import SwiftUI

/// Host-supplied configuration for ``LogViewer``. Keeps the viewer generic: the
/// host points it at a ``LogStore`` and supplies display strings (e.g. a title
/// and a mapping from raw category identifiers to human-readable names).
/// 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 buffer to read and observe.
public var store: LogStore
/// The buffers to read and observe, merged chronologically for display.
public var stores: [LogStore]

/// Navigation title for the viewer.
public var title: String
Expand All @@ -15,12 +21,21 @@ public struct LogViewerConfiguration: Sendable {
public var categoryDisplayName: @Sendable (String) -> String

public init(
store: LogStore,
stores: [LogStore],
title: String = "Logs",
categoryDisplayName: @escaping @Sendable (String) -> String = { $0 },
) {
self.store = store
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)
}
}
Loading
Loading