diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79368aa6..481aa8d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,13 @@ jobs: # check names. - name: String Catalog lint run: ./xcstrings --lint + # The only thing that actually stops a stale attribution report from + # shipping: nothing about adding or bumping a dependency forces a re-run, + # and the app can't check itself (its test bundle can't read Package.swift). + # Offline and sub-second, so it rides along here for the same reason the + # catalog lint does. + - name: Attribution report check + run: ./attribution --check architecture: name: Bumper Bowling diff --git a/AGENTS.md b/AGENTS.md index 8a233873..9af9f0a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,11 +49,12 @@ Xcode project](#generating-the-xcode-project)). A fresh machine needs `./ide generating; plain `./ide` fails fast pointing at it. The executables in the repo root are the dev scripts — `ide`, `swiftformat`, -`sync-agents`, `profile`, `icons`, `flaky`, `simulator`, `xcstrings` — and each -takes `--help`. Reach for one rather than hand-rolling its job: `icons` and -`simulator` in particular own state that is easy to corrupt by hand — the -latter owns a simulator device per checkout (see [Managing app -icons](#managing-app-icons) and [Selecting a +`sync-agents`, `profile`, `icons`, `flaky`, `simulator`, `xcstrings`, +`attribution` — and each takes `--help`. Reach for one rather than hand-rolling +its job: `icons`, `attribution`, and `simulator` in particular own state that is +easy to corrupt by hand — the last owns a simulator device per checkout (see +[Managing app icons](#managing-app-icons), [Attribution](#attribution), and +[Selecting a simulator](#selecting-a-simulator--one-device-per-checkout-addressed-by-udid)). ### Managing app icons @@ -63,6 +64,25 @@ simulator](#selecting-a-simulator--one-device-per-checkout-addressed-by-udid)). `AppIcons.json` manifest in sync — never hand-edit those or add icon Swift. Run `./ide --no-open` after adding one. +### Version and build metadata + +The Where app's `CFBundleShortVersionString` / `CFBundleVersion` are stated +**explicitly** in [`Project.swift`](Project.swift) rather than left to Tuist's +`.extendingDefault` values, because Settings > About shows them — bump them +there. The commit is *not* a manifest value: a post-build script phase +([`Where/Where/Scripts/stamp-build-info.sh`](Where/Where/Scripts/stamp-build-info.sh)) +writes `WhereGitSHA` and `WhereGitStatus` into the built product's Info.plist, +which `WhereCore`'s `BuildInfo` reads back. + +The two constraints worth knowing before touching it: it must stay a **post** +script (it edits the plist "Process Info.plist" already wrote, and must land +before signing seals the bundle), and it must keep +`basedOnDependencyAnalysis: false`, or an unchanged source tree ships the +previous commit's SHA. It reads `.git`, so it also depends on +`ENABLE_USER_SCRIPT_SANDBOXING` staying unset (Xcode defaults it off; new +project templates set it on). Only the app is stamped — the extensions never +read these keys. + ## Formatting - **SwiftFormat** uses [`.swiftformat`](.swiftformat). Run `./swiftformat` to @@ -78,6 +98,49 @@ Run `./ide --no-open` after adding one. churn around the one real entry. Write a catalog through Xcode, or normalize it afterwards; the script only touches formatting, never content. +## Attribution + +An app ships an **attribution report**: every third-party work it is built with, +each carrying its license notice inline. Regenerate every app's report with: + +```bash +./attribution +``` + +The split matters. [`Shared/CreditKit`](Shared/CreditKit/AGENTS.md) owns the +*types and the reporting tool* and holds **no credits of its own**; each app +declares its sources in an `attribution-sources.json` and ships the resulting +manifest in **its own resources** (for Where, +`Where/Where/Resources/attribution.json`). A report describes one app's +dependency graph, so it is that app's data — and CreditKit stays a Foundation-only +leaf that anything may depend on, so a package pulled in by *any* module gets +credited without inverting the dependency that introduced it. + +The report is derived from what the repo already declares — packages a target +links via `.product(name:package:)` (pinned by `Package.resolved`) and the agent +skills in `.agents/external-skills.json` — with each notice read at the **pinned +revision**. So a new dependency is credited wherever it lands, and a package +resolved only for tooling (BumperBowling, swift-syntax) correctly isn't. +**Re-run `./attribution` and commit the result whenever you add or bump a +package or a skill.** `./attribution --check` fails CI if you forget: it +re-derives the expected report from `Package.swift`, `Package.resolved`, and the +skills manifest and diffs it against the committed one. It runs offline (a +notice is fetched at the pinned revision, so a matching revision means matching +text) and takes well under a second. An app's own tests can't do this job — a +test bundle can't read `Package.swift`, so all it can assert is a literal that a +stale report satisfies just as happily as a fresh one. + +`SoftwareCredit.Kind` keeps a **shipped library** apart from a **development +tool**, and that distinction must survive into any UI: a tool is credited +because the repo uses it, not because it reaches a device. **Kind is derived, +not declared** — the config's `shippedFrom` names the package targets the app +and its extensions link, and anything reachable from that closure is a library +while any other linked package is a tool. Linking is not shipping: the +snapshot-testing engine is linked by a test-support target and never reaches a +binary, and crediting it as a library would tell a reader their app contains it. +Data-source provenance for bundled geometry is separate and stays with its data, +in [`RegionKit`](Where/RegionKit/AGENTS.md). + ## Architecture lint Bumper Bowling enforces the production Where module graph and selected @@ -114,6 +177,12 @@ SwiftData) — and `.agents/skills/.gitignore` excludes those fetched copies, so anything else under `.agents/skills/` is a **repo-owned** skill and is committed (currently `todo-triage`). +That manifest is also an **attribution** input: external skills are third-party +work the repo vendors, so an app's report credits them (as *development tools*, +distinct from libraries the app links). After adding a skill or running +`./sync-agents --update`, re-run `./attribution` and commit the regenerated +reports — see [`Attribution`](#attribution). + **Cursor reads `.agents/skills/` natively** — that directory is the real home. The `.claude/skills/` mirror exists for Claude Code, so run `./sync-agents` after adding or editing a skill, and edit the source rather than the mirror. (Cursor diff --git a/Package.swift b/Package.swift index 9dad7516..076a0858 100644 --- a/Package.swift +++ b/Package.swift @@ -9,6 +9,7 @@ let package = Package( ], products: [ .library(name: "StuffCore", targets: ["StuffCore"]), + .library(name: "CreditKit", targets: ["CreditKit"]), .library(name: "LifecycleKit", targets: ["LifecycleKit"]), .library(name: "LifecycleKitUI", targets: ["LifecycleKitUI"]), .library(name: "JournalKit", targets: ["JournalKit"]), @@ -43,6 +44,10 @@ let package = Package( name: "StuffCore", path: "Shared/StuffCore/Sources", ), + .target( + name: "CreditKit", + path: "Shared/CreditKit/Sources", + ), .target( name: "LifecycleKit", path: "Shared/LifecycleKit/Sources", @@ -121,6 +126,7 @@ let package = Package( .target( name: "WhereCore", dependencies: [ + .target(name: "CreditKit"), .target(name: "PeriscopeCore"), .target(name: "RegionKit"), .product(name: "ZIPFoundation", package: "ZIPFoundation"), @@ -136,6 +142,7 @@ let package = Package( .target(name: "WhereCore"), .target(name: "BroadwayCore"), .target(name: "BroadwayUI"), + .target(name: "CreditKit"), .target(name: "LifecycleKit"), .target(name: "LifecycleKitUI"), .target(name: "PeriscopeCore"), diff --git a/Project.swift b/Project.swift index f4e30f9f..dc3d1567 100644 --- a/Project.swift +++ b/Project.swift @@ -106,6 +106,11 @@ let project = Project( infoPlist: .extendingDefault(with: [ "UILaunchScreen": .dictionary([:]), "UIApplicationSupportsIndirectInputEvents": .boolean(true), + // Stated explicitly rather than left to Tuist's `1.0` / `1` + // defaults, because Settings > About shows them: the version a + // user reads off the screen should be one this manifest chose. + "CFBundleShortVersionString": .string("1.0"), + "CFBundleVersion": .string("1"), "NSLocationWhenInUseUsageDescription": .string( "Where uses your location to figure out which region you're in.", ), @@ -116,6 +121,17 @@ let project = Project( sources: ["Where/Where/Sources/**"], resources: ["Where/Where/Resources/**"], entitlements: whereAppGroupEntitlements, + // Writes `WhereGitSHA` / `WhereGitStatus` into the built Info.plist + // for Settings > About. A *post* script so it lands after "Process + // Info.plist" and before signing, and `basedOnDependencyAnalysis: + // false` so an unchanged source tree still re-stamps a new commit. + scripts: [ + .post( + path: "Where/Where/Scripts/stamp-build-info.sh", + name: "Stamp Build Info", + basedOnDependencyAnalysis: false, + ), + ], dependencies: [ .package(product: "LifecycleKit"), .package(product: "RegionKit"), @@ -286,6 +302,12 @@ let project = Project( productDependency: "StuffCore", sources: ["Shared/StuffCore/Tests/**"], ), + unitTests( + name: "CreditKitTests", + bundleIdSuffix: "creditkit", + productDependency: "CreditKit", + sources: ["Shared/CreditKit/Tests/**"], + ), unitTests( name: "LifecycleKitTests", bundleIdSuffix: "lifecyclekit", @@ -530,6 +552,7 @@ let project = Project( "RegionViewer", "StuffTestHost", "StuffCoreTests", + "CreditKitTests", "LifecycleKitTests", "LifecycleKitUITests", "JournalKitTests", @@ -551,6 +574,7 @@ let project = Project( ]), testAction: .targets([ "StuffCoreTests", + "CreditKitTests", "LifecycleKitTests", "LifecycleKitUITests", "JournalKitTests", @@ -571,6 +595,7 @@ let project = Project( ]), ), testScheme(name: "StuffCoreTests"), + testScheme(name: "CreditKitTests"), testScheme(name: "LifecycleKitTests"), testScheme(name: "LifecycleKitUITests"), testScheme(name: "JournalKitTests"), diff --git a/Shared/CreditKit/AGENTS.md b/Shared/CreditKit/AGENTS.md new file mode 100644 index 00000000..cf6f05f6 --- /dev/null +++ b/Shared/CreditKit/AGENTS.md @@ -0,0 +1,80 @@ +# CreditKit — Module Shape + +Tools and types for working out what an app owes attribution to, and for +shipping that answer inside the app. See [`README.md`](README.md) for the API +and the report format; the repo-wide build, format, and convention rules are in +the root [`AGENTS.md`](../../AGENTS.md). + +## Scope & dependencies + +- **May import:** Foundation. Nothing else — not even logging. +- **Must not import:** any app or feature module (`WhereCore`, `WhereUI`, …), or + any UI framework. CreditKit is a leaf that anything may depend on; an edge + pointing *out* of it would defeat the reason it exists. +- **Wired in:** `Package.swift` (`CreditKit` product, consumed by `WhereCore` + and `WhereUI`) and `Project.swift` (`CreditKitTests`, in the + `Stuff-iOS-Tests` scheme). + +## Layering + +`SoftwareCredit` and `LicenseNotice` are the values; `AttributionManifest` is a +decoded report plus the two ways to get one (`decode(from:)`, +`load(from:resource:)`). Nothing holds state, nothing is a singleton, and +nothing knows how a report is presented — grouping, labeling, and translation +belong to the consuming UI. + +`Tools/generate-attribution.rb` is the other half of the module and is the only +thing that writes a report. + +## Invariants + +- **CreditKit ships no credits and no notices.** A report describes one app's + dependency graph, so it belongs in that app's resources — for Where, in + `Where/Where/Resources/attribution.json`. If a license file or a `credits.json` + ever reappears under `Sources/`, the module has drifted back into being one + app's data. +- **Nothing here may name a real dependency.** `CreditKitTests` covers the + format and the API with fixtures only; asserting that some package is credited + is the *app's* test to write, because only the app knows what it links. See + `AppAttributionTests` in `Where/Where/Tests/`. +- **Failure is thrown, never logged or defaulted.** The module has no logger by + design, and a missing report is not inherently an error — only the app knows + which of its bundles are expected to carry one. Returning an empty manifest + instead of throwing would render as "nothing to credit", which is the one + wrong answer. +- **`Kind` is load-bearing, not decoration.** `.developmentTool` credits are not + in the shipping binary. A UI that renders both kinds in one undifferentiated + list would misrepresent the app, so the distinction must survive into the + presentation. Its raw values are a wire format the generator writes; renaming + a case silently invalidates every committed report. The generator validates + each source's `kind` against them before it does any work, so a config typo + fails there rather than as a decode fault inside the app. +- **Credit names are unique across a report.** `SoftwareCredit` is `Identifiable` + by `name`, so a duplicate hands a SwiftUI `ForEach` two rows with one id — and + a library's name is its repo basename, so two orgs publishing the same repo + name is all it takes. The generator enforces this (case-insensitively) over the + assembled report, since that is the only place holding every credit at once. +- **Notices are read at the pinned revision.** Not the default branch — upstream + edits a notice between releases, and shipping HEAD's text would attribute + terms that don't govern the code in the binary. +- **The generator keys off `.product(name:package:)`, not the `dependencies:` + list.** That is what keeps a package resolved for tooling alone (BumperBowling, + and swift-syntax beneath it) out of a report by construction. +- **Linking is not shipping, so `kind` is derived from reachability.** The + config's `shippedFrom` names the app's root package targets; the generator + walks the manifest's target graph from there, and a package inside that + closure is a `library` while any other linked package is a `developmentTool`. + A test-support target's dependencies (the snapshot engine, the accessibility + parser) are linked by the package but never reach a device — crediting them as + libraries would misdescribe the binary, which is the one thing `Kind` exists to + prevent. `shippedFrom` is the only hand-set part; everything downstream of it + follows from the graph, so a new dependency can't be silently misfiled. + +## Testing + +`CreditKitTests` covers the manifest as a format and an API: decoding the exact +JSON the generator writes, rejecting a malformed report or an unknown `kind`, +round-tripping, filtering by kind, and `load` throwing for a bundle with no +report. Shared fixtures live in `CreditKitTestSupport.swift`, and +`SampleReport.json` is a literal rather than an encoder round-trip so a Swift-side +change that breaks the wire format fails a test. diff --git a/Shared/CreditKit/README.md b/Shared/CreditKit/README.md new file mode 100644 index 00000000..7004ef0c --- /dev/null +++ b/Shared/CreditKit/README.md @@ -0,0 +1,133 @@ +# CreditKit + +Tools and types for working out what an app owes attribution to, and for +shipping that answer inside the app. + +CreditKit holds no credits of its own. It defines the shape of an **attribution +report** and provides the reporting tool that produces one; each app runs the +report over its own declared sources and ships the result in its own resources. +That split is deliberate — a report describes one app's dependency graph, so it +is that app's data, and a second app can adopt CreditKit without inheriting the +first one's credits. + +## Install + +Add the `CreditKit` product to a target in the root `Package.swift`. It has no +dependencies beyond Foundation. + +## Quick start + +Run the report (see [Generating a report](#generating-a-report)), then decode it +wherever the app wants to show it: + +```swift +import CreditKit + +let report = try AttributionManifest.load(from: .main, resource: "attribution") + +for credit in report.credits(ofKind: .library) { + print(credit.name, credit.version, credit.license.name) + print(credit.license.text) +} +``` + +In the Where app that load is wrapped by `WhereCore.AppAttribution`, which knows +which of its bundles are expected to carry a report, and `WhereUI`'s +`AboutSettingsView` renders one section per kind. + +## Public API + +- **`AttributionManifest`** — a decoded report. `credits` in report order, + `credits(ofKind:)` to filter, `decode(from:)` for raw JSON, and + `load(from:resource:)` to read one out of a bundle. +- **`SoftwareCredit`** — one credited work: `name`, `kind`, `version`, + `homepageURL`, and its `license`. +- **`SoftwareCredit.Kind`** — `.library` (compiled into the binary) or + `.developmentTool` (used to build the project, absent from the shipped app). +- **`LicenseNotice`** — a license's `name` and its verbatim `text`. +- **`AttributionError`** — `.reportMissing(resource:)` when a bundle carries no + report. Decoding failures surface as `DecodingError` unwrapped, so the coding + path still names the offending field. + +## Generating a report + +An app declares its sources in an `attribution-sources.json`, and the generator +turns that into a manifest: + +```bash +./attribution # every configured app +ruby Shared/CreditKit/Tools/generate-attribution.rb # just one +``` + +```json +{ + "output": "Where/Where/Resources/attribution.json", + "sources": [ + { "type": "swiftPackageManager", "manifest": "Package.swift", + "resolved": "Package.resolved", "shippedFrom": ["WhereUI"] }, + { "type": "agentSkills", "kind": "developmentTool", + "manifest": ".agents/external-skills.json" } + ] +} +``` + +Paths are relative to the repository root. Two source types are understood: + +| Type | Reads | Credits | +|------|-------|---------| +| `swiftPackageManager` | packages a target links via `.product(name:package:)`, pinned by the resolved file | one per linked package | +| `agentSkills` | a `./sync-agents` manifest of `name -> { repo, ref }` | one per vendored skill | + +Deriving the list rather than maintaining it is the point: a package linked by +*any* module shows up the next time the report runs, so no module has to +remember to vend a credit — and a package declared for tooling alone is never +linked, so it is correctly left out. + +`swiftPackageManager` derives each credit's **kind** the same way, from +`shippedFrom`: it names the package targets the shipping app and its extensions +link, the generator walks the manifest's target graph out from them, and a +package inside that closure is a `library` while any other linked package is a +`developmentTool`. Linking is not shipping — a snapshot-testing engine linked by +a test-support target is credited (the repo depends on it) but must not be +described as being in the binary. `shippedFrom` is the only part set by hand, so +adding a dependency can't quietly land under the wrong kind. + +The tool needs network and an authenticated `gh`. It is idempotent: re-running +with nothing changed rewrites the same bytes. + +## How it works + +Each notice is read from the project's GitHub repository **at the pinned +revision**, not the default branch, so the text shipped is the one governing the +code actually in use. Upstream edits notices between releases — a bumped +copyright year, a relicense — and reading HEAD would attribute the wrong terms. + +Notices are stored **inline** in the manifest rather than as sidecar files. One +decode then yields everything needed to discharge the attribution, with no +second lookup that can come back empty, and no missing-file failure path to +handle at runtime. + +## Contracts and limitations + +- **A report goes stale silently unless something checks it.** Nothing about + adding or bumping a dependency forces a re-run, so `--check` exists to fail + the build: it re-derives the expected report from the same manifests and diffs + it against the committed one, offline. Reach for that rather than asserting + credit names in a test — a test bundle can't read the manifests, so it can + only compare the report to a literal, which a stale report matches too. +- **Development tools are not in the binary.** They are credited because the + repository makes copies of them, which permissive licenses ask us to + attribute. Any UI must keep the two kinds visually distinct so a reader isn't + told something untrue about the app they are running. +- **A missing report is not automatically an error.** Only the app target ships + one, so `load` throwing `.reportMissing` is routine in a developer tool or + test host. CreditKit reports it and leaves the judgement to the caller. +- **Credit names must be unique within a report.** `SoftwareCredit` is + `Identifiable` by `name`, so a duplicate breaks list identity in any UI that + iterates credits. The generator enforces it — a library's name is its repo + basename, and two orgs can publish the same one — but a hand-written manifest + is on its own; the type can't check what it can't see. +- **Names, versions, and license titles are never localized.** They are proper + nouns and legal terms; a UI supplies the translated framing around them. +- **GitHub-hosted sources only.** Both source types resolve notices through the + GitHub API; a dependency hosted elsewhere would need a new source type. diff --git a/Shared/CreditKit/Sources/AttributionManifest.swift b/Shared/CreditKit/Sources/AttributionManifest.swift new file mode 100644 index 00000000..35cde87b --- /dev/null +++ b/Shared/CreditKit/Sources/AttributionManifest.swift @@ -0,0 +1,59 @@ +import Foundation + +/// A generated attribution report for one app: every third-party work it is +/// built with, each carrying its own license notice. +/// +/// A manifest is **produced by tooling and read at runtime**, never assembled +/// by hand. `Tools/generate-attribution.rb` runs a report over an app's +/// declared sources — its Swift package graph, the agent skills the repository +/// vendors — and writes the result into that app's own resources, which is +/// where it belongs: the report describes one app's dependency graph, so it is +/// the app's data rather than this module's. +/// +/// Deriving it is what keeps it honest. A hand-kept list silently goes stale +/// the moment some *other* module adds a dependency, whereas a report re-run +/// picks that up wherever it landed. +public struct AttributionManifest: Sendable, Hashable, Codable { + /// Every credit, in the order the report generated them. + public let credits: [SoftwareCredit] + + public init(credits: [SoftwareCredit]) { + self.credits = credits + } + + /// The credits of one kind, in report order. + public func credits(ofKind kind: SoftwareCredit.Kind) -> [SoftwareCredit] { + credits.filter { $0.kind == kind } + } +} + +extension AttributionManifest { + /// Decodes a manifest from the JSON a report wrote. + public static func decode(from data: Data) throws -> AttributionManifest { + try JSONDecoder().decode(AttributionManifest.self, from: data) + } + + /// Loads the report `resource`.json from `bundle`. + /// + /// Throws rather than logging: CreditKit has no opinion about how a missing + /// report should be reported, and an app knows things this module can't — + /// notably that some of its bundles (a developer tool, a test host) are + /// *expected* to carry no report, while a malformed one is always a defect. + public static func load( + from bundle: Bundle, + resource: String, + ) throws -> AttributionManifest { + guard let url = bundle.url(forResource: resource, withExtension: "json") else { + throw AttributionError.reportMissing(resource: resource) + } + return try decode(from: Data(contentsOf: url)) + } +} + +/// A failure loading an attribution report. Decoding failures surface as +/// `DecodingError` from the underlying decoder rather than being wrapped, so a +/// caller keeps the coding path that names the bad field. +public enum AttributionError: Error, Hashable { + /// The bundle carries no report under that resource name. + case reportMissing(resource: String) +} diff --git a/Shared/CreditKit/Sources/SoftwareCredit.swift b/Shared/CreditKit/Sources/SoftwareCredit.swift new file mode 100644 index 00000000..647ae120 --- /dev/null +++ b/Shared/CreditKit/Sources/SoftwareCredit.swift @@ -0,0 +1,80 @@ +import Foundation + +/// A third-party work an app is built with, and the license it is used under. +/// +/// Credits cover two different relationships, which ``Kind`` keeps apart: a +/// ``Kind/library`` is compiled into the shipping binary, while a +/// ``Kind/developmentTool`` is only used to build the project and never reaches +/// a user's device. Both need attribution — permissive licenses ask for the +/// notice to travel with every copy — but conflating them would tell a reader +/// something untrue about the app they are running, so the distinction is +/// modeled rather than left to a comment. +/// +/// Names, versions, and license titles are proper nouns and legal terms, so +/// they are deliberately **not** localized; a UI supplies the translated +/// framing around them. +public struct SoftwareCredit: Sendable, Hashable, Identifiable, Codable { + /// How a credited work relates to the shipping app. + public enum Kind: String, Sendable, Hashable, Codable, CaseIterable { + /// Linked into the app binary — the user is running this code. + case library + /// Used to build or develop the app; absent from the shipped bundle. + case developmentTool + } + + /// The work's name as its author publishes it. + public let name: String + /// Whether this ships in the binary or only builds it. + public let kind: Kind + /// The pinned version: a semantic version for a package, a short commit for + /// a work pinned by revision. + public let version: String + /// Where the project lives, for a "learn more" link. + public let homepageURL: URL? + /// The license, carrying its own notice text. + public let license: LicenseNotice + + /// The name, which a report **must** keep unique across the whole manifest. + /// + /// Nothing in this type can enforce that, so the generator does: names come + /// from a repo basename, two orgs can publish the same one, and a collision + /// would hand a SwiftUI `ForEach` two rows sharing an id. Uniqueness is + /// checked where the report is assembled, not here, because that is the only + /// place holding every credit at once. + public var id: String { + name + } + + public init( + name: String, + kind: Kind, + version: String, + homepageURL: URL?, + license: LicenseNotice, + ) { + self.name = name + self.kind = kind + self.version = version + self.homepageURL = homepageURL + self.license = license + } +} + +/// A license and the notice that has to travel with it. +/// +/// The text is carried inline rather than referenced by filename so a manifest +/// is self-contained: one decode yields everything needed to discharge the +/// attribution, with no second lookup that can come back empty. Permissive +/// licenses require the notice verbatim, which is why it is stored rather than +/// summarized or reflowed. +public struct LicenseNotice: Sendable, Hashable, Codable { + /// The license's title, e.g. "MIT License". + public let name: String + /// The full notice, verbatim. + public let text: String + + public init(name: String, text: String) { + self.name = name + self.text = text + } +} diff --git a/Shared/CreditKit/TODOs.md b/Shared/CreditKit/TODOs.md new file mode 100644 index 00000000..c5b317f6 --- /dev/null +++ b/Shared/CreditKit/TODOs.md @@ -0,0 +1,12 @@ +# CreditKit todos + +The item format and the placement rule live in the root +[`TODOs.md`](../../TODOs.md); raw notes go in [`INBOX.md`](../../INBOX.md), not +here. + +# Open issues + +## P2s (Nice to have) +- fix [quick-win]: `github_slug` accepts anything after the host, so a malformed pin becomes a malformed API path instead of a clear error. It captures `.+?` (`Tools/generate-attribution.rb:76`) and the result is interpolated straight into `repos/#{slug}/license?ref=#{ref}` (`:67`), so a `location` of `https://github.com/foo/bar?x=y` asks for `repos/foo/bar?x=y/license?ref=…` and fails with whatever `gh` makes of that. Not a security issue: both inputs are repo-controlled (`Package.resolved`, `.agents/external-skills.json`) and `Open3.capture3` passes argv with no shell, so nothing is injectable. Constrain the capture to `[\w.-]+/[\w.-]+` so a bad pin fails as a bad pin. (pr#140 review) + +# Completed issues diff --git a/Shared/CreditKit/Tests/AttributionManifestTests.swift b/Shared/CreditKit/Tests/AttributionManifestTests.swift new file mode 100644 index 00000000..0f8c854c --- /dev/null +++ b/Shared/CreditKit/Tests/AttributionManifestTests.swift @@ -0,0 +1,96 @@ +import CreditKit +import Foundation +import Testing + +/// Covers the manifest as a *format* and an API. Deliberately names no real +/// dependency: which packages an app links is that app's fact to assert, not +/// this library's — see `AppAttributionTests` in the Where app's bundle. +struct AttributionManifestTests { + // MARK: Decoding the generated shape + + @Test func decodesTheShapeTheReportWrites() throws { + let manifest = try AttributionManifest.decode(from: Data(SampleReport.json.utf8)) + + #expect(manifest.credits.map(\.name) == ["Linked", "Tool"]) + let linked = try #require(manifest.credits.first) + #expect(linked.kind == .library) + #expect(linked.version == "0.9.20") + #expect(linked.homepageURL == URL(string: "https://github.com/example/linked")) + #expect(linked.license.name == "MIT License") + #expect(linked.license.text == "Linked notice.") + } + + @Test func decodingRejectsAMalformedReport() { + #expect(throws: (any Error).self) { + try AttributionManifest.decode(from: Data(#"{"credits":[{"name":"Nope"}]}"#.utf8)) + } + } + + @Test func decodingRejectsAnUnknownKind() { + // `Kind` is a closed vocabulary: a report naming something else is a + // generator/runtime mismatch and must fail loudly, not decode to a + // silent default. + let json = """ + {"credits":[{"name":"X","kind":"plugin","version":"1", + "license":{"name":"MIT","text":"t"}}]} + """ + #expect(throws: (any Error).self) { + try AttributionManifest.decode(from: Data(json.utf8)) + } + } + + @Test func decodesAReportWithNoCredits() throws { + // Structurally valid and distinct from a *missing* report, which is the + // case a caller is expected to treat differently. + let manifest = try AttributionManifest.decode(from: Data(#"{"credits":[]}"#.utf8)) + #expect(manifest.credits.isEmpty) + } + + @Test func aCreditWithoutAHomepageIsAllowed() throws { + let json = """ + {"credits":[{"name":"X","kind":"library","version":"1", + "license":{"name":"MIT","text":"t"}}]} + """ + let manifest = try AttributionManifest.decode(from: Data(json.utf8)) + #expect(try #require(manifest.credits.first).homepageURL == nil) + } + + // MARK: Round trip + + @Test func survivesAnEncodeDecodeRoundTrip() throws { + let original = AttributionManifest(credits: [ + .fixture(name: "Linked", kind: .library), + .fixture(name: "Tool", kind: .developmentTool), + ]) + let data = try JSONEncoder().encode(original) + #expect(try AttributionManifest.decode(from: data) == original) + } + + // MARK: Filtering + + @Test func filtersToASingleKind() { + let manifest = AttributionManifest(credits: [ + .fixture(name: "Linked", kind: .library), + .fixture(name: "Tool", kind: .developmentTool), + .fixture(name: "AlsoLinked", kind: .library), + ]) + #expect(manifest.credits(ofKind: .library).map(\.name) == ["Linked", "AlsoLinked"]) + #expect(manifest.credits(ofKind: .developmentTool).map(\.name) == ["Tool"]) + } + + @Test func filteringPreservesReportOrder() { + let names = ["c", "a", "b"] + let manifest = AttributionManifest( + credits: names.map { .fixture(name: $0, kind: .library) }, + ) + #expect(manifest.credits(ofKind: .library).map(\.name) == names) + } + + // MARK: Loading from a bundle + + @Test func loadingThrowsWhenTheBundleCarriesNoReport() { + #expect(throws: AttributionError.reportMissing(resource: "attribution")) { + try AttributionManifest.load(from: .main, resource: "attribution") + } + } +} diff --git a/Shared/CreditKit/Tests/CreditKitTestSupport.swift b/Shared/CreditKit/Tests/CreditKitTestSupport.swift new file mode 100644 index 00000000..514909ae --- /dev/null +++ b/Shared/CreditKit/Tests/CreditKitTestSupport.swift @@ -0,0 +1,46 @@ +import CreditKit +import Foundation + +extension SoftwareCredit { + static func fixture( + name: String = "Example", + kind: Kind = .library, + version: String = "1.2.3", + licenseName: String = "MIT License", + licenseText: String = "Copyright (c) 2026 Example Author", + ) -> SoftwareCredit { + SoftwareCredit( + name: name, + kind: kind, + version: version, + homepageURL: URL(string: "https://example.com/\(name)"), + license: LicenseNotice(name: licenseName, text: licenseText), + ) + } +} + +enum SampleReport { + /// The JSON shape `generate-attribution.rb` writes. Kept as a literal rather + /// than round-tripped from an encoder so a change to the Swift types that + /// silently breaks the wire format fails a test. + static let json = """ + { + "credits": [ + { + "name": "Linked", + "kind": "library", + "version": "0.9.20", + "homepageURL": "https://github.com/example/linked", + "license": { "name": "MIT License", "text": "Linked notice." } + }, + { + "name": "Tool", + "kind": "developmentTool", + "version": "e710f8d577cc", + "homepageURL": "https://github.com/example/tool", + "license": { "name": "Apache License 2.0", "text": "Tool notice." } + } + ] + } + """ +} diff --git a/Shared/CreditKit/Tests/SoftwareCreditTests.swift b/Shared/CreditKit/Tests/SoftwareCreditTests.swift new file mode 100644 index 00000000..5f5fabfc --- /dev/null +++ b/Shared/CreditKit/Tests/SoftwareCreditTests.swift @@ -0,0 +1,17 @@ +import CreditKit +import Foundation +import Testing + +struct SoftwareCreditTests { + @Test func identifiesByName() { + #expect(SoftwareCredit.fixture(name: "ZIPFoundation").id == "ZIPFoundation") + } + + @Test func kindEncodesAsItsWireName() throws { + // The generator writes these strings, so renaming a case would silently + // stop matching every report already committed. + let encoded = try JSONEncoder().encode(SoftwareCredit.Kind.allCases) + let names = try JSONDecoder().decode([String].self, from: encoded) + #expect(names == ["library", "developmentTool"]) + } +} diff --git a/Shared/CreditKit/Tools/generate-attribution.rb b/Shared/CreditKit/Tools/generate-attribution.rb new file mode 100755 index 00000000..47302c0d --- /dev/null +++ b/Shared/CreditKit/Tools/generate-attribution.rb @@ -0,0 +1,322 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Runs an attribution report for one app and writes it as a self-contained +# manifest that `CreditKit.AttributionManifest` decodes at runtime. +# +# The report is generated rather than hand-kept, and that is the whole point: a +# hand-maintained list silently goes stale the moment some *other* module adds +# a dependency, whereas a re-run picks it up wherever it landed. +# +# The app declares what to report on; this script knows *source types*, not any +# one repository's paths. Config (paths relative to the repo root): +# +# { +# "output": "Where/Where/Resources/attribution.json", +# "sources": [ +# { "type": "swiftPackageManager", "manifest": "Package.swift", +# "resolved": "Package.resolved", "shippedFrom": ["WhereUI"] }, +# { "type": "agentSkills", "kind": "developmentTool", +# "manifest": ".agents/external-skills.json" } +# ] +# } +# +# Source types: +# +# - `swiftPackageManager` — packages a target actually links via +# `.product(name:package:)` in the manifest, pinned by the resolved file. A +# package declared for tooling alone (an architecture linter, say) is never +# linked and so is deliberately not credited. +# +# `kind` is **derived, not declared**: `shippedFrom` names the package +# targets the shipping app and its extensions link, and a package reachable +# from that closure is a `library` while any other linked package is a +# `developmentTool`. Linking alone doesn't mean shipping — a snapshot-testing +# engine is linked by a test-support target and never reaches a device — and +# crediting one as a `library` would tell a reader their binary contains it. +# - `agentSkills` — a `./sync-agents` external-skills manifest of +# `name -> { repo, ref }`. These are not in the binary, but the repository +# makes copies of them, which is what their licenses ask us to attribute. +# +# Each credit carries its notice **inline**, read at the pinned revision, so one +# decode yields everything needed to discharge the attribution and there is no +# second lookup that can come back empty. +# +# Needs network and an authenticated `gh`. Idempotent — re-run after changing a +# dependency or running `./sync-agents --update`. Prefer the repo-root wrapper: +# ./attribution +# or point it at one config directly: +# ruby Shared/CreditKit/Tools/generate-attribution.rb +# +# `--check` verifies a committed report still matches what the repo declares, +# without writing anything. It makes **no network calls** — every field it +# compares comes from files in the repo — so it is cheap enough to gate CI, which +# is the only thing that actually stops a stale report from shipping. + +require "json" +require "fileutils" +require "open3" + +ROOT = File.expand_path("../../..", __dir__) + +def fail_with(message) + abort "generate-attribution: #{message}" +end + +# The `kind` values `SoftwareCredit.Kind` decodes. A declared one is checked up +# front because a typo would otherwise produce a report that generates fine and +# commits fine, then fails to decode inside the app — surfacing as a fault and a +# debug trap on the About screen, a long way from the config line that caused it. +KIND_LIBRARY = "library" +KIND_DEVELOPMENT_TOOL = "developmentTool" +KINDS = [KIND_LIBRARY, KIND_DEVELOPMENT_TOOL].freeze + +# GitHub's license endpoint resolves the notice's filename for us (LICENSE, +# LICENSE.md, COPYING, …) and reports the license's title alongside it, so one +# call answers both "what license" and "what text". +# +# Always read it at the pinned revision rather than the default branch: a +# notice we ship should be the one that governs the revision we actually use, +# and upstream edits it (a bumped copyright year, a relicense) between releases. +def github_license(slug, ref) + out, err, status = Open3.capture3("gh", "api", "repos/#{slug}/license?ref=#{ref}") + fail_with("could not read the license for #{slug}: #{err.strip}") unless status.success? + payload = JSON.parse(out) + text = payload["content"].to_s.unpack1("m") + fail_with("#{slug} reports a license with no text") if text.strip.empty? + { "name" => payload.dig("license", "name") || "See notice", "text" => text } +end + +def github_slug(location) + location[%r{github\.com[:/](.+?)(?:\.git)?/?\z}, 1] +end + +# The fields of a credit that come from files already in the repo. The notice is +# deliberately not one of them: it is the only field needing the network, which +# is what lets `--check` derive the whole expected report offline. +NOTICE_FREE_KEYS = %w[name kind version homepageURL].freeze + +def credit(name:, kind:, version:, slug:, ref:) + { + "name" => name, + "kind" => kind, + "version" => version, + "homepageURL" => "https://github.com/#{slug}", + # Carried for the notice fetch, then dropped before the report is written. + "slug" => slug, + "ref" => ref, + } +end + +def notice_free(entry) + NOTICE_FREE_KEYS.to_h { |key| [key, entry[key]] } +end + +def describe(entry) + "#{entry["kind"]}: #{entry["name"]} #{entry["version"]}" +end + +def read_json(relative_path, source_type) + path = File.join(ROOT, relative_path) + fail_with("#{source_type}: no file at #{relative_path}") unless File.exist?(path) + JSON.parse(File.read(path)) +end + +# A target declaration, as distinct from a `.target(name:)` *dependency* entry: +# only the declaration puts `name:` on its own line. Keying off that rather than +# indentation keeps the parse independent of how deeply the array is nested. +TARGET_DECLARATION = /\.(?:target|testTarget|executableTarget)\(\s*\n\s*name:\s*"([^"]+)"/ +TARGET_DEPENDENCY = /\.target\(name:\s*"([^"]+)"/ +PRODUCT_DEPENDENCY = /\.product\(\s*name:\s*"[^"]+",\s*package:\s*"([^"]+)"/ + +# The manifest's target graph: each target with the sibling targets and the +# external packages (by SPM identity — the lowercased `package:` name) it links. +def package_targets(manifest_path) + path = File.join(ROOT, manifest_path) + fail_with("swiftPackageManager: no manifest at #{manifest_path}") unless File.exist?(path) + text = File.read(path) + declarations = text.to_enum(:scan, TARGET_DECLARATION).map { Regexp.last_match } + fail_with("swiftPackageManager: no targets found in #{manifest_path}") if declarations.empty? + + declarations.each_with_index.to_h do |declaration, index| + # Everything up to the next declaration is this target's body. + body = text[declaration.end(0)...(declarations[index + 1]&.begin(0) || text.length)] + [ + declaration[1], + { + "targets" => body.scan(TARGET_DEPENDENCY).flatten, + "packages" => body.scan(PRODUCT_DEPENDENCY).flatten.map(&:downcase), + }, + ] + end +end + +# Package identities reachable from `roots` — the ones that end up in the app +# binary, as opposed to those linked only by test-support or tooling targets. +def shipped_package_identities(targets, roots) + unknown = roots - targets.keys + fail_with("swiftPackageManager: shippedFrom names no such target: #{unknown.join(", ")}") unless unknown.empty? + + visited = [] + queue = roots.dup + shipped = [] + until queue.empty? + name = queue.shift + next if visited.include?(name) + visited << name + target = targets[name] + next unless target + shipped.concat(target["packages"]) + queue.concat(target["targets"]) + end + shipped.uniq +end + +def swift_package_manager_credits(source) + targets = package_targets(source.fetch("manifest")) + linked = targets.values.flat_map { |target| target["packages"] }.uniq + fail_with("swiftPackageManager: no linked packages found") if linked.empty? + shipped = shipped_package_identities(targets, source.fetch("shippedFrom")) + + resolved = read_json(source.fetch("resolved"), "swiftPackageManager") + pins = resolved.fetch("pins").select { |pin| linked.include?(pin["identity"]) } + missing = linked - pins.map { |pin| pin["identity"] } + fail_with("#{missing.join(", ")} linked but unresolved") unless missing.empty? + + pins.map do |pin| + slug = github_slug(pin.fetch("location")) + fail_with("#{pin["identity"]} is not hosted on GitHub") unless slug + state = pin.fetch("state") + revision = state.fetch("revision") + credit( + name: slug.split("/").last, + kind: shipped.include?(pin["identity"]) ? KIND_LIBRARY : KIND_DEVELOPMENT_TOOL, + # A branch-pinned package has no version, so fall back to the revision. + version: state["version"] || revision[0, 12], + slug: slug, + ref: revision, + ) + end +end + +def agent_skills_credits(source) + kind = source.fetch("kind") + read_json(source.fetch("manifest"), "agentSkills").map do |name, entry| + ref = entry.fetch("ref") + credit( + name: name, + kind: kind, + version: ref[0, 12], + slug: entry.fetch("repo"), + ref: ref, + ) + end +end + +SOURCE_TYPES = { + "swiftPackageManager" => { + required: %w[manifest resolved shippedFrom], + generate: method(:swift_package_manager_credits), + }, + "agentSkills" => { + required: %w[manifest kind], + generate: method(:agent_skills_credits), + }, +}.freeze + +# Checked for every source before any of them runs, so a config mistake costs a +# second rather than surfacing after the first source's network round trips. +def validate_source(source, config_path) + type = source.fetch("type") + spec = SOURCE_TYPES[type] + fail_with("unknown source type #{type.inspect} in #{config_path}") unless spec + + missing = spec[:required] - source.keys + fail_with("#{type} in #{config_path} is missing #{missing.join(", ")}") unless missing.empty? + + kind = source["kind"] + return if kind.nil? || KINDS.include?(kind) + fail_with("unknown kind #{kind.inspect} in #{config_path} (expected #{KINDS.join(" or ")})") +end + +def report(config_path, check:) + config = JSON.parse(File.read(config_path)) + sources = config.fetch("sources") + fail_with("#{config_path} declares no sources") if sources.empty? + + sources.each { |source| validate_source(source, config_path) } + + credits = sources.flat_map do |source| + # Sort within a source so the report has a stable order and re-running it + # produces no diff when nothing changed. A source can now emit more than one + # kind, so sort by kind first — otherwise adding a test-only package would + # reshuffle the shipping libraries around it. + SOURCE_TYPES.fetch(source.fetch("type"))[:generate] + .call(source) + .sort_by { |entry| [KINDS.index(entry["kind"]), entry["name"].downcase] } + end + fail_with("#{config_path} produced no credits") if credits.empty? + + # `SoftwareCredit` is `Identifiable` by name, so a collision hands SwiftUI two + # rows sharing one id. It takes only two orgs publishing the same repo name, + # since a library's name is its repo basename. Compared case-insensitively: + # a pair differing only in case is legal but unreadable in a list. + collisions = credits.group_by { |entry| entry["name"].downcase } + .select { |_, entries| entries.size > 1 } + .keys + fail_with("duplicate credit name(s): #{collisions.join(", ")}") unless collisions.empty? + + return check_report(credits, config.fetch("output")) if check + + write_report(credits, config.fetch("output")) +end + +def write_report(credits, output_path) + output = File.join(ROOT, output_path) + entries = credits.map do |entry| + notice_free(entry).merge("license" => github_license(entry["slug"], entry["ref"])) + end + FileUtils.mkdir_p(File.dirname(output)) + File.write(output, "#{JSON.pretty_generate({ "credits" => entries })}\n") + + puts "#{output_path}: #{entries.count} credit(s)" + entries.each { |entry| puts " #{describe(entry)}" } +end + +# Fails when the committed report disagrees with what the repo now declares. +# +# Runs entirely offline, which is the whole reason it can gate CI: every field +# it compares is derived from `Package.swift`, `Package.resolved`, and the skills +# manifest. It can't re-read a notice, but it doesn't need to — a notice is +# fetched at the pinned revision, so a matching revision means matching text by +# construction, and the notice being *present* is checked here directly. +def check_report(credits, output_path) + path = File.join(ROOT, output_path) + fail_with("#{output_path} does not exist — run ./attribution") unless File.exist?(path) + committed = JSON.parse(File.read(path))["credits"] + fail_with("#{output_path} carries no credits — run ./attribution") if committed.nil? || committed.empty? + + expected = credits.map { |entry| notice_free(entry) } + actual = committed.map { |entry| notice_free(entry) } + unless expected == actual + lines = (expected - actual).map { |entry| " missing: #{describe(entry)}" } + + (actual - expected).map { |entry| " unexpected: #{describe(entry)}" } + # Same list, different order, is still a mismatch worth reporting plainly. + lines << " (same credits, different order)" if lines.empty? + fail_with("#{output_path} is stale — run ./attribution\n#{lines.join("\n")}") + end + + bare = committed.select { |entry| entry.dig("license", "text").to_s.strip.empty? } + .map { |entry| entry["name"] } + fail_with("#{output_path}: no notice for #{bare.join(", ")}") unless bare.empty? + + puts "#{output_path}: up to date (#{committed.count} credit(s))" +end + +check = !ARGV.delete("--check").nil? +configs = ARGV +fail_with("usage: generate-attribution.rb [--check] ...") if configs.empty? +configs.each do |config| + fail_with("no config at #{config}") unless File.exist?(config) + report(config, check: check) +end diff --git a/TODOs.md b/TODOs.md index ff1f5199..9ee5fe10 100644 --- a/TODOs.md +++ b/TODOs.md @@ -98,9 +98,13 @@ inbox rather than here. ## P1s (Should do) ## P2s (Nice to have) +- feat(PeriscopeCore) [needs-design]: A `LogSession` can't name the build it came from. `LogSession.current()` reads only `CFBundleShortVersionString` / `CFBundleVersion` off the main bundle (`Shared/Periscope/PeriscopeCore/Sources/Store/LogSession.swift:32`), and the viewer renders that pair as the session's identity (`PeriscopeTools/Sources/Viewer/PeriscopeViewer.swift:269`) — but the Where app pins both in the manifest (`Project.swift:101`), so every developer build reads `v1.0 (1)` and weeks-old logs can't be tied to the code that produced them. The commit is now available: the app stamps `WhereGitSHA` / `WhereGitStatus` into its Info.plist and `WhereCore.BuildInfo` reads them back. PeriscopeCore can't depend on WhereCore (Periscope sits below the Where modules), so this needs a seam rather than a direct adoption — an optional commit field, or general resource attributes on `LogSession` that the host app fills at bootstrap. Spans Periscope and Where, hence here. (agent 2026-07-26) - perf(StuffTestHost) [needs-design]: Two loose ends in the shared test host, both reaching the root Tuist manifest, which is why they sit here rather than in a StuffTestHost file. The WhereCore-always-embedded build trade-off is documented and verified load-bearing at `Project.swift:256` — decide whether to keep documenting it or split the host so unrelated bundles don't pay for it. Separately, the scene configuration name is spelled twice, in `Shared/StuffTestHost/Sources/AppDelegate.swift:11` and `Project.swift:244`, so the two can drift silently. (audit 2026-07-26) # Completed issues +## P1s (Should do) +- test(CreditKit) [needs-design]: The attribution drift guard didn't detect a stale report, so the app could ship notices that don't govern the code in it. **Closed by `./attribution --check`**, a network-free mode that re-derives the expected report from `Package.swift`, `Package.resolved`, and the skills manifest and diffs it against the committed one, gated in CI beside the other lints (`.github/workflows/ci.yml`). The literal name lists in `AppAttributionTests` are gone — a test bundle can't read the manifests, so all it could compare against was a literal — and that suite now asserts only what the shipping bundle can answer. **As originally filed this item was wrong on a detail worth recording:** it claimed the old guard caught an added dependency but not a bumped one. It caught neither. Comparing the report to a hardcoded list means a dependency added without regenerating leaves report and list still agreeing, which is exactly what happened — the guard passed on a report missing the two snapshot packages that merging `main` had added. (pr#140 review, closed 2026-07-26) + ## P2s (Nice to have) - test(WhereUI) [needs-design]: broken-snapshots — two snapshot suites pinned views WhereUI doesn't own (`PeriscopeViewer` and `SwiftDataInspector`), flagged `[Fix later]` on PR #101. (Resolved: both suites, and their reference images, now live under the module they cover. The prerequisite this item assumed — a snapshot *bundle* per module, each with its own scheme and CI job — turned out to be the wrong shape and was **not** built. Per-module bundles are actively unsafe: each `.xctest` statically embeds what it links, so a second one would carry a second copy of `SnapshotKitTesting`, whose "process-global" capture state is module-global and therefore per copy — two copies co-loaded into one `StuffTestHost` would flip the safe-area swizzle's parity against each other and neither capture lock would see the other's captures (verified with `nm`: each built bundle defines its own private `_swizzleDepth`). And per-module bundles were never needed for ownership: swift-snapshot-testing derives the `__Snapshots__` directory from the calling file's `#filePath`, so a suite records beside itself wherever it lives. So the single bundle was renamed `WhereUISnapshotTests` → `StuffSnapshotTests` and gained a `sources` directory per module, keeping one scheme and one CI job. The one-bundle rule is now recorded in the root `AGENTS.md` "Targets" section and in `SnapshotKitTesting/AGENTS.md`. Moving `PeriscopeViewer` changed no pixels — it seeds its own `periscopeBroadwayRoot()`, so the `whereBroadwayRoot()` wrapper the old suite credited for "app styling" was contributing nothing; `SwiftDataInspector` did re-record, since it moved off Where's store onto a local fixture schema.) diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 3aacdd6c..60376dd2 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -110,6 +110,38 @@ A new screen belongs *inside* that shape — a destination pushed from a tab, a sheet, or a Settings row. Adding a fourth tab is a product decision, not a refactor: raise it before building. +Settings itself is a typed-route list: `SettingsSearch.swift` owns +`SettingsDestination` / `SettingsListSection` / `SettingsRoute`, and every +switch over them is exhaustive, so a new drill-in is a set of compile errors to +fill in rather than a screen you can forget to register. **About is deliberately +the last block**, below everything actionable. + +**Nothing on the About screen is a list hard-coded in the view.** It renders +three sources: the generated attribution report (via `WhereCore.AppAttribution`), +`RegionDataSource` (RegionKit) for bundled geometry, and `BuildInfo` (WhereCore) +for the running build. Adding a dependency means re-running `./attribution`; +adding a dataset means regenerating RegionKit's — see +[`CreditKit/AGENTS.md`](../Shared/CreditKit/AGENTS.md) and +[`RegionKit/AGENTS.md`](RegionKit/AGENTS.md). + +The report lives in `Where/Where/Resources/attribution.json` — the **app +target's** resources, so only the app bundle carries one. Every other bundle +(RegionViewer, `StuffTestHost`, the extensions) reads `nil` and the screen says +so, exactly as it does for an unstamped `BuildInfo`. A section with nothing of +its kind says so too, in different words: "no report at all" and "nothing of +this kind" describe different builds, and a header and footer over no rows would +describe neither. `AppAttributionTests`, in the app's own test bundle because it +is the one hosted by `Where.app`, asserts the report is usable — present, +decodable, both kinds populated. Whether it still *matches* the dependency graph +is `./attribution --check`'s job in CI, not a literal here. + +A **shipped library** and a **development tool** render as separate sections. +Development tools are the agent skills `./sync-agents` vendors *and* packages +linked only outside the app's target closure (the snapshot-testing engine, the +accessibility parser) — credited because the repository depends on them, not +because they reach a device. Keep the sections separate: one merged list would +tell a reader something untrue about the app they are running. + ## Localization All user-facing copy resolves through each module's `Localizable.xcstrings` via diff --git a/Where/RegionKit/AGENTS.md b/Where/RegionKit/AGENTS.md index d616c633..a4a08426 100644 --- a/Where/RegionKit/AGENTS.md +++ b/Where/RegionKit/AGENTS.md @@ -39,6 +39,14 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature (dev viewer/tests); `.shared` the default four. It's UI-free: `BoundingBox` / `LongitudeSpan` expose the min/max math, but MapKit conversion lives in the UI layer. `RegionAttributing` lets `WhereCore` supply a live, swappable attributor. +- **Bundled geometry is credited in code, not only in prose.** `RegionDataSource` + states each boundary set's origin, license, and fidelity, and derives its + coverage from the catalog — the US sources by the `us-` id prefix the generator + mints, everything else by an explicit id list, deliberately *not* an + "everything else" fallback that would silently mis-credit a new region. + `RegionDataSourceTests` fails when a region is covered zero times or twice, so + regenerating the catalog can't ship uncredited data. Keep it in step with the + [README](README.md#source-data-not-bundled) provenance notes. - **Region names are manifest data (a documented trade-off).** `localizedName` resolves a manifest entry's optional `localizationKey` from the string catalog, else the manifest's English `name` — so dynamic ids cost static string-catalog diff --git a/Where/RegionKit/README.md b/Where/RegionKit/README.md index c1358cc6..c45cf0b0 100644 --- a/Where/RegionKit/README.md +++ b/Where/RegionKit/README.md @@ -35,6 +35,12 @@ into it for lookup. RegionKit depends only on - **`RegionGeometryCatalog`** — read-only drawable `RegionOutline`s for the developer region-map viewer (`.attribution` for a given attributor vs `.source` for the whole catalog). +- **`RegionDataSource`** — where the bundled geometry came from: the boundary + set's name, its links, its `License`, its `Fidelity` (`.authoritative` vs the + `.approximate` hand-drawn outlines), and the regions it covers. + `RegionDataSource.all` derives coverage from the catalog, and the Where app's + Settings > About screen renders it. Untranslated by design — these are proper + nouns and legal terms, and the UI supplies the localized framing. - **`RegionLog`** — RegionKit's Periscope logging facade: one `"RegionKit"` root scope with a typed `LogEvent` per collaborator (`RegionAttributor`, `RegionCatalog`, `RegionGeometryCatalog`), emitted into the process-wide @@ -109,6 +115,9 @@ ruby Where/RegionKit/Tools/generate-regions.rb ### Source data (not bundled) +Each entry below is also expressed in code as a `RegionDataSource`, which is +what the app credits on its About screen; keep the two in step. + - **`us-states.geojson`** — US state boundaries (50 states + DC + PR), `MultiPolygon` per feature keyed by `properties.NAME`; the generator splits it into one `regions/us-.geojson` per feature. Originally @@ -132,7 +141,10 @@ Adding a region is now **pure data** — no new `Region` case, no code: a new US feature; blocs/countries get an entry in the script's `NON_US` list). 3. Optionally add a `region.` entry to `Localizable.xcstrings` and point the manifest entry's `localizationKey` at it (otherwise the English `name` shows). -4. Add a `RegionAttributorTests` spot-check. +4. Attribute the geometry in `RegionDataSource` — a US state is already covered + by the `us-` rule, anything else needs its source named. + `RegionDataSourceTests` fails until it is. +5. Add a `RegionAttributorTests` spot-check. Everything downstream (`RegionStyle`, region pickers, the App Intents `RegionEntity`) derives from the catalog automatically. diff --git a/Where/RegionKit/Sources/RegionDataSource.swift b/Where/RegionKit/Sources/RegionDataSource.swift new file mode 100644 index 00000000..7e1c077e --- /dev/null +++ b/Where/RegionKit/Sources/RegionDataSource.swift @@ -0,0 +1,92 @@ +import Foundation + +/// Where the bundled region geometry came from, so the app can credit its data +/// the way it credits its code. +/// +/// RegionKit owns this because RegionKit owns the GeoJSON: provenance is a fact +/// about the data, not presentation. The values here are proper nouns, URLs, and +/// license terms, so they are deliberately **not** localized — the UI supplies +/// the translated framing around them, and reads ``fidelity`` rather than +/// hard-coding which source happens to be approximate. +/// +/// Coverage is derived from ``RegionCatalog`` rather than listed by hand, so a +/// regenerated catalog can't leave a region silently uncredited — +/// `RegionDataSourceTests` fails if one is covered zero times or twice. +public struct RegionDataSource: Sendable, Hashable { + /// The terms the geometry is available under. + public enum License: Sendable, Hashable { + /// A public-domain publication; the associated value names *why* it's in + /// the public domain. Attribution is still requested for these. + case publicDomain(String) + /// Drawn in this repository, so it carries the project's own license + /// rather than a third party's. + case originalWork + } + + /// How closely the geometry follows the real boundary — the difference + /// between "simplified from a published boundary set" and "sketched well + /// enough to spot-check with". + public enum Fidelity: Sendable, Hashable { + /// Simplified from an authoritative published boundary set. + case authoritative + /// A coarse outline with no authoritative basis. Fine for tests and a + /// rough answer, not for a residency audit. + case approximate + } + + /// The boundary set's own name, e.g. "US Census Bureau Cartographic + /// Boundary Files". Untranslated (see the type note). + public let name: String + /// Where the publisher documents the boundary set, when there is one. + public let sourceURL: URL? + /// The intermediate the bundled files were actually taken from, when the + /// data didn't come straight from the publisher — a conversion we credit + /// separately rather than implying we pulled from the primary source. + public let obtainedFromURL: URL? + public let license: License + public let fidelity: Fidelity + /// The regions this source provides geometry for, in catalog order. + public let regions: [Region] + + /// Every data source behind the bundled geometry, each covering at least one + /// catalog region. + public static let all: [RegionDataSource] = sources(coveringRegionsIn: .shared) + + /// The sources for `catalog`'s regions. Split out from ``all`` so tests can + /// drive it with a catalog of their own. + static func sources(coveringRegionsIn catalog: RegionCatalog) -> [RegionDataSource] { + // The `us-` prefix is the provenance marker, not a naming coincidence: + // `generate-regions.rb` mints exactly these ids while splitting the + // single Census `us-states.geojson` into one file per feature. + let censusStates = catalog.all.filter { $0.rawValue.hasPrefix(usStateIDPrefix) } + // Listed explicitly rather than "everything else", so a new non-US + // region from a real source can't be quietly credited as hand-drawn — + // it stays uncovered until someone files where it came from. + let handDrawn = catalog.all.filter { handDrawnIDs.contains($0.rawValue) } + + return [ + RegionDataSource( + name: "US Census Bureau Cartographic Boundary Files", + sourceURL: URL( + string: "https://www.census.gov/geographies/mapping-files/time-series/geo/cartographic-boundary.html", + ), + obtainedFromURL: URL(string: "https://eric.clst.org/tech/usgeojson/"), + license: .publicDomain("US Government works — 17 U.S.C. § 105"), + fidelity: .authoritative, + regions: censusStates, + ), + RegionDataSource( + name: "Hand-drawn outlines", + sourceURL: nil, + obtainedFromURL: nil, + license: .originalWork, + fidelity: .approximate, + regions: handDrawn, + ), + ] + .filter { !$0.regions.isEmpty } + } + + private static let usStateIDPrefix = "us-" + private static let handDrawnIDs: Set = ["canada", "european-union"] +} diff --git a/Where/RegionKit/Tests/RegionDataSourceTests.swift b/Where/RegionKit/Tests/RegionDataSourceTests.swift new file mode 100644 index 00000000..57cb86f7 --- /dev/null +++ b/Where/RegionKit/Tests/RegionDataSourceTests.swift @@ -0,0 +1,72 @@ +import Foundation +@testable import RegionKit +import Testing + +/// The drift guard behind the About screen's data credits: every bundled +/// region's geometry is attributed to exactly one source, so regenerating the +/// catalog can't quietly ship uncredited data. +struct RegionDataSourceTests { + private let catalog = RegionCatalog.shared + + @Test func everyCatalogRegionIsCoveredExactlyOnce() { + let covered = RegionDataSource.all.flatMap(\.regions) + #expect(Set(covered) == Set(catalog.all)) + // Set equality alone would accept a region claimed by two sources. + #expect(covered.count == catalog.all.count) + } + + @Test func noSourceClaimsARegionOutsideTheCatalog() { + let known = Set(catalog.all) + for source in RegionDataSource.all { + #expect(source.regions.allSatisfy { known.contains($0) }) + } + } + + @Test func aRegionFromAnUnknownSourceStaysUncovered() { + // The failure this guard exists for: a new non-US region lands in the + // catalog and no source claims it. It must not be swept into the + // hand-drawn bucket as an "everything else" fallback. + let unattributed = Region(unchecked: "atlantis") + let catalog = RegionCatalog(entries: [ + RegionCatalog.Entry( + region: unattributed, + name: "Atlantis", + localizationKey: nil, + geometryFile: "atlantis.geojson", + ), + ]) + let covered = RegionDataSource.sources(coveringRegionsIn: catalog).flatMap(\.regions) + #expect(!covered.contains(unattributed)) + } + + @Test func creditsTheCensusBoundaryFilesForUSStates() throws { + let source = try #require(RegionDataSource.all.first { $0.regions.contains(.california) }) + #expect(source.name == "US Census Bureau Cartographic Boundary Files") + #expect(source.fidelity == .authoritative) + #expect(source.license == .publicDomain("US Government works — 17 U.S.C. § 105")) + #expect(source.sourceURL != nil) + // Credited separately from the publisher: the bundled files came via a + // conversion, and saying otherwise would overstate the provenance. + #expect(source.obtainedFromURL != nil) + } + + @Test func marksTheHandDrawnOutlinesAsApproximate() throws { + let source = try #require(RegionDataSource.all.first { $0.regions.contains(.canada) }) + #expect(source.regions.contains(.europeanUnion)) + #expect(source.fidelity == .approximate) + #expect(source.license == .originalWork) + #expect(source.sourceURL == nil) + } + + @Test func listsRegionsInCatalogOrder() { + for source in RegionDataSource.all { + let expected = catalog.all.filter { source.regions.contains($0) } + #expect(source.regions == expected) + } + } + + @Test func dropsSourcesThatCoverNothing() { + // An empty catalog credits nobody rather than listing bare source names. + #expect(RegionDataSource.sources(coveringRegionsIn: RegionCatalog(entries: [])).isEmpty) + } +} diff --git a/Where/Where/AGENTS.md b/Where/Where/AGENTS.md index ab4e44df..8bd21ea2 100644 --- a/Where/Where/AGENTS.md +++ b/Where/Where/AGENTS.md @@ -15,8 +15,23 @@ layering, and the domain rules this target merely starts up. target is a Tuist `.app` ([`Project.swift`](../../Project.swift), bundle ID `com.stuff.where`), and its Info.plist keys, entitlements, and build settings live in that manifest — there is no checked-in plist to edit. +- `Scripts/` holds this target's build-phase scripts, not dev commands (those + are the repo-root executables). Today that is + [`stamp-build-info.sh`](Scripts/stamp-build-info.sh), which stamps the commit + into the built Info.plist — see [Version and build + metadata](../../AGENTS.md#version-and-build-metadata) for the constraints on + it. - `Resources/AppIcon.xcassets` is managed by `./icons` (see the root [`AGENTS.md`](../../AGENTS.md#managing-app-icons)) — never hand-edit it. +- `Resources/attribution.json` is the app's generated attribution report, and + `attribution-sources.json` beside it declares where the report reads from. + Both are `./attribution`'s (see + [Attribution](../../AGENTS.md#attribution)) — never hand-edit the report. This + is the **only** bundle that carries one, which is why `AppAttributionTests` + lives in this target's test bundle: it is the one hosted by `Where.app`, so + `Bundle.main` is the shipping bundle. Those tests cover the report being + usable; `./attribution --check` in CI covers it still matching the dependency + graph, which no test bundle can see. ## Invariants diff --git a/Where/Where/Resources/attribution.json b/Where/Where/Resources/attribution.json new file mode 100644 index 00000000..99064336 --- /dev/null +++ b/Where/Where/Resources/attribution.json @@ -0,0 +1,74 @@ +{ + "credits": [ + { + "name": "ZIPFoundation", + "kind": "library", + "version": "0.9.20", + "homepageURL": "https://github.com/weichsel/ZIPFoundation", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2017-2025 Thomas Zoechling (https://www.peakstep.com)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" + } + }, + { + "name": "AccessibilitySnapshot", + "kind": "developmentTool", + "version": "0.11.0", + "homepageURL": "https://github.com/cashapp/AccessibilitySnapshot", + "license": { + "name": "Apache License 2.0", + "text": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" + } + }, + { + "name": "swift-snapshot-testing", + "kind": "developmentTool", + "version": "1.19.3", + "homepageURL": "https://github.com/pointfreeco/swift-snapshot-testing", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2019 Point-Free, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" + } + }, + { + "name": "swift-concurrency-pro", + "kind": "developmentTool", + "version": "e710f8d577cc", + "homepageURL": "https://github.com/twostraws/Swift-Concurrency-Agent-Skill", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 Paul Hudson.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE." + } + }, + { + "name": "swift-testing-pro", + "kind": "developmentTool", + "version": "2d6bba14a3c8", + "homepageURL": "https://github.com/twostraws/Swift-Testing-Agent-Skill", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 Paul Hudson.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE." + } + }, + { + "name": "swiftdata-pro", + "kind": "developmentTool", + "version": "922d989473a9", + "homepageURL": "https://github.com/twostraws/SwiftData-Agent-Skill", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 Paul Hudson.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE." + } + }, + { + "name": "swiftui-pro", + "kind": "developmentTool", + "version": "61b74001b64b", + "homepageURL": "https://github.com/twostraws/swiftui-agent-skill", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 Paul Hudson.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE." + } + } + ] +} diff --git a/Where/Where/Scripts/stamp-build-info.sh b/Where/Where/Scripts/stamp-build-info.sh new file mode 100755 index 00000000..80f6e644 --- /dev/null +++ b/Where/Where/Scripts/stamp-build-info.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# +# Stamps the commit the app was built from into the built product's Info.plist, +# so the Settings > About screen can show it (read back by `WhereCore.BuildInfo`). +# +# Runs as a post-build script phase on the `Where` target — after "Process +# Info.plist" writes the file and before code signing seals the bundle — with +# dependency analysis off so it re-runs on every build rather than caching a +# stale SHA. Only the app is stamped; the extensions never read these keys. +# +# A checkout without git metadata (an exported tarball, a sandboxed script +# phase) is not a failure: the keys are still written, marked `unknown`, and the +# About screen says so rather than showing an invented commit. + +set -euo pipefail + +plist="${TARGET_BUILD_DIR}/${INFOPLIST_PATH}" + +sha=$(git -C "$SRCROOT" rev-parse --short=12 HEAD 2>/dev/null || echo "") +if [ -z "$sha" ]; then + sha="unknown" + status="unknown" +elif [ -n "$(git -C "$SRCROOT" status --porcelain 2>/dev/null)" ]; then + status="dirty" +else + status="clean" +fi + +set_key() { + /usr/libexec/PlistBuddy -c "Set :$1 $2" "$plist" 2>/dev/null || + /usr/libexec/PlistBuddy -c "Add :$1 string $2" "$plist" +} + +set_key WhereGitSHA "$sha" +set_key WhereGitStatus "$status" diff --git a/Where/Where/Tests/AppAttributionTests.swift b/Where/Where/Tests/AppAttributionTests.swift new file mode 100644 index 00000000..d0f8b9bc --- /dev/null +++ b/Where/Where/Tests/AppAttributionTests.swift @@ -0,0 +1,84 @@ +import CreditKit +import Foundation +import Testing +import WhereCore + +/// Covers the Where app's *own* attribution report, which is why it lives here +/// rather than in `CreditKitTests`: this bundle is hosted by `Where.app`, so +/// `Bundle.main` is the shipping bundle, and a generic attribution library has +/// no business asserting which packages its consumer links. +/// +/// These assert the report is *usable* — present, decodable, and complete +/// enough to render. They deliberately do **not** assert which packages it +/// names: this bundle can't read `Package.swift`, so any such assertion is a +/// literal that a stale report agrees with just as happily as a fresh one. That +/// is not hypothetical — a hardcoded list here passed while the committed report +/// was missing two packages a merge had added. `./attribution --check` owns +/// agreement with the dependency graph and gates CI; this owns everything only +/// the shipping bundle can answer. +@MainActor +struct AppAttributionTests { + private func report() throws -> AttributionManifest { + try #require( + AppAttribution.main, + "the app bundle carries no attribution report — run ./attribution", + ) + } + + @Test func theAppBundleCarriesAReport() throws { + #expect(try !report().credits.isEmpty) + } + + @Test func creditsBothKindsOfWork() throws { + // Not a list of names — that's `--check`'s job. This pins the shape the + // About screen depends on: two populated sections, so neither renders + // its "nothing to credit" state in the shipping app. + for kind in SoftwareCredit.Kind.allCases { + #expect( + try !report().credits(ofKind: kind).isEmpty, + "the report credits nothing of kind \(kind)", + ) + } + } + + @Test func everyCreditCarriesItsNoticeInline() throws { + for credit in try report().credits { + #expect(!credit.license.text.isEmpty, "\(credit.name) ships no license notice") + #expect(!credit.license.name.isEmpty, "\(credit.name) names no license") + #expect(!credit.version.isEmpty, "\(credit.name) records no version") + #expect(credit.homepageURL != nil, "\(credit.name) records no homepage") + } + } + + @Test func namesEveryCreditUniquely() throws { + // `SoftwareCredit` is `Identifiable` by name, so a collision would hand + // the About screen's `ForEach` two rows sharing one id. `./attribution` + // refuses to write a report with duplicates; this catches one edited in + // by hand, and names the offenders rather than just counting them. + let names = try report().credits.map(\.name) + let collisions = Dictionary(grouping: names) { $0.lowercased() } + .filter { $0.value.count > 1 } + .keys + .sorted() + #expect( + collisions.isEmpty, + "duplicate credit name(s): \(collisions.joined(separator: ", "))", + ) + } + + @Test func cachesTheReportItReadsFromTheMainBundle() { + // `AppAttribution.main` is what the About screen's default argument + // reads on every view init, so the cached value has to answer exactly + // what a fresh read of the same bundle would. + #expect(AppAttribution.main == AppAttribution.current(bundle: .main)) + } + + @Test func aBundleWithoutAReportReadsAsNone() { + // Every other bundle in the process — the test bundle itself included — + // legitimately carries no report, and that must stay a quiet `nil` + // rather than the fault path. + #expect(AppAttribution.current(bundle: Bundle(for: BundleMarker.self)) == nil) + } +} + +private final class BundleMarker {} diff --git a/Where/Where/attribution-sources.json b/Where/Where/attribution-sources.json new file mode 100644 index 00000000..db51814f --- /dev/null +++ b/Where/Where/attribution-sources.json @@ -0,0 +1,16 @@ +{ + "output": "Where/Where/Resources/attribution.json", + "sources": [ + { + "type": "swiftPackageManager", + "manifest": "Package.swift", + "resolved": "Package.resolved", + "shippedFrom": ["WhereUI", "WhereIntents"] + }, + { + "type": "agentSkills", + "kind": "developmentTool", + "manifest": ".agents/external-skills.json" + } + ] +} diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index be240d57..e5a32611 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -134,6 +134,26 @@ internal shape. that derives attribution from a seeded store instead.) Detection is naturally scoped to the set — the attributor only loads tracked-region geometry, so `distanceToBoundary` is `nil` elsewhere. +- **Adding an external package means re-running `./attribution`.** WhereCore + links the app's only third-party package (ZIPFoundation), but it does **not** + own attribution: the report format and tooling are + [`CreditKit`](../../Shared/CreditKit/AGENTS.md)'s, and the report itself is the + app target's resource. Regenerate and commit it, or the app ships a library + whose license it doesn't reproduce. +- **`AppAttribution` and `BuildInfo` both read *this bundle*, and both treat + absence as legitimate.** Only the app target gets a stamped Info.plist and a + generated report, so a bundle without either (RegionViewer, `StuffTestHost`, + an extension) reports `nil` rather than a placeholder. `AppAttribution` splits + that from a report that is present and won't decode, which is a corrupt + resource and faults — don't collapse the two back together. See [Version and + build metadata](../../AGENTS.md#version-and-build-metadata) and + [Attribution](../../AGENTS.md#attribution). +- **Read the app's report through `AppAttribution.main`, not + `current(bundle: .main)`.** The report inlines every notice, so decoding it is + not free, and its caller is a SwiftUI default argument — which Swift + re-evaluates on every `init`, and SwiftUI re-inits views constantly. `main` + caches the decode for the process; `current(bundle:)` stays for tests and for + any bundle that isn't `.main`. - **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 a `WhereLog` typed `LogEvent` (PII-free, `.public`) and leave diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index e1e5a36b..7f6da6d4 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -96,6 +96,15 @@ 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`. +- **`BuildInfo`** + **`AppAttribution`** — what Settings > About says about the + bundle it is running in. `BuildInfo.current(bundle:)` reads the marketing + version, build number, and the commit the app was built from; + `AppAttribution.main` reads the generated attribution report, decoding it once + per process (`current(bundle:)` for any other bundle). Both return + `nil`-shaped honesty for a bundle outside the app target, which carries + neither. (The report's *format* and tooling are + [`CreditKit`](../../Shared/CreditKit/README.md)'s; data-source provenance is + [`RegionKit`](../RegionKit/README.md)'s.) - **`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`. diff --git a/Where/WhereCore/Sources/About/AppAttribution.swift b/Where/WhereCore/Sources/About/AppAttribution.swift new file mode 100644 index 00000000..486d2b36 --- /dev/null +++ b/Where/WhereCore/Sources/About/AppAttribution.swift @@ -0,0 +1,57 @@ +import CreditKit +import Foundation + +/// The Where app's attribution report, read back out of the bundle it ships in. +/// +/// The report is generated by `./attribution` and committed into the app +/// target's resources, so — exactly like the commit `BuildInfo` reads — **only +/// the app target carries one**. A bundle that never received it (the +/// RegionViewer developer tool, `StuffTestHost`, an app extension) reports `nil` +/// rather than a placeholder, and the About screen says attribution is +/// unavailable instead of rendering empty sections that read as "nothing to +/// credit". +/// +/// That distinction is the whole reason this sits above `CreditKit`: absent is a +/// legitimate state only the app knows how to interpret, while a report that is +/// present and won't decode is a corrupt resource. CreditKit throws for both; +/// here they diverge. +public enum AppAttribution { + /// Name of the generated resource inside the app bundle. + static let resource = "attribution" + + /// The running app's report, read and decoded **once per process**. + /// + /// Prefer this to `current(bundle: .main)` at a call site that runs more + /// than once. The report is an immutable resource in an immutable bundle, + /// but it is not small — every notice is inlined — and the natural caller + /// is a SwiftUI default argument, which Swift re-evaluates on every single + /// `init`. `AboutSettingsView` is rebuilt on each body pass of the Settings + /// stack, so reading eagerly there re-read the file, re-ran the decoder, + /// and re-emitted a log line per keystroke in the search field. + /// + /// Same shape as `RegionDataSource.all`, the sibling default on that init. + public static let main: AttributionManifest? = current(bundle: .main) + + /// Reads the attribution report out of `bundle`, or `nil` when the bundle + /// carries none. The bundle is explicit (no default) so a caller can't + /// accidentally read the wrong one; from the app, prefer ``main``, which + /// caches this for `.main`. + public static func current(bundle: Bundle) -> AttributionManifest? { + do { + let manifest = try AttributionManifest.load(from: bundle, resource: resource) + logger { .loaded(creditCount: manifest.credits.count) } + return manifest + } catch AttributionError.reportMissing { + logger { .noReport } + return nil + } catch { + logger(attachments: [.error(error, name: "decode-error")]) { + .decodeFailed(description: error.localizedDescription) + } + assertionFailure("Failed to decode the bundled attribution report: \(error)") + return nil + } + } + + private static let logger = WhereLog.root(AppAttributionLog.self) +} diff --git a/Where/WhereCore/Sources/About/BuildInfo.swift b/Where/WhereCore/Sources/About/BuildInfo.swift new file mode 100644 index 00000000..34dd7aba --- /dev/null +++ b/Where/WhereCore/Sources/About/BuildInfo.swift @@ -0,0 +1,98 @@ +import Foundation + +/// Which build of the app is running: the marketing version, the build number, +/// and the commit it was built from — read back from the bundle's Info.plist. +/// +/// The version keys are Xcode's; the commit keys are stamped into the built +/// product by the `Where` target's "Stamp Build Info" script phase (see the root +/// `AGENTS.md`). Only the app target is stamped, so a bundle that never ran that +/// phase — the RegionViewer developer tool, `StuffTestHost`, an app extension — +/// reports `commit == nil` rather than a placeholder, and the About screen says +/// the build is unidentified instead of showing an invented one. +public struct BuildInfo: Sendable, Equatable { + /// The commit the binary was built from, when the bundle carries one. + public struct Commit: Sendable, Equatable { + /// Abbreviated (12-character) commit hash. + public let sha: String + /// Whether the working tree had uncommitted changes at build time, so a + /// SHA from a developer build can't be mistaken for a reproducible one. + public let isDirty: Bool + + public init(sha: String, isDirty: Bool) { + self.sha = sha + self.isDirty = isDirty + } + } + + /// `CFBundleShortVersionString`, or `nil` in a bundle without one. + public let version: String? + /// `CFBundleVersion`, or `nil` in a bundle without one. + public let build: String? + /// The stamped commit, or `nil` in an unstamped bundle. + public let commit: Commit? + + /// Reads the build metadata out of `bundle`. Pass `.main` from the app; the + /// bundle is explicit (no default) so a caller can't accidentally read the + /// wrong one from an extension or a test host. + public static func current(bundle: Bundle) -> BuildInfo { + let info = bundle.infoDictionary ?? [:] + return BuildInfo(infoDictionary: info.compactMapValues { $0 as? String }) + } + + /// Builds from a raw Info.plist string dictionary. `@_spi(Testing)` because + /// production always goes through ``current(bundle:)`` — synthesizing a + /// bundle just to test the stamped / unstamped / dirty readings isn't worth + /// it. + @_spi(Testing) + public init(infoDictionary: [String: String]) { + version = Self.value(for: .version, in: infoDictionary) + build = Self.value(for: .build, in: infoDictionary) + commit = Self.commit(from: infoDictionary) + } + + private static func commit(from info: [String: String]) -> Commit? { + guard + let sha = value(for: .gitSHA, in: info), + let raw = value(for: .gitStatus, in: info), + let status = Status(rawValue: raw) + else { + return nil + } + switch status { + case .clean: return Commit(sha: sha, isDirty: false) + case .dirty: return Commit(sha: sha, isDirty: true) + // The stamp ran but git couldn't answer, so the SHA beside it is the + // literal "unknown" placeholder rather than a hash. + case .unknown: return nil + } + } + + /// The value for `key`, treating a missing entry and a blank one alike — a + /// plist string key can exist and hold `""`. + private static func value(for key: Key, in info: [String: String]) -> String? { + guard + let value = info[key.rawValue]?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { + return nil + } + return value + } + + /// The Info.plist keys read here — the two Xcode writes and the two the + /// stamp script adds. The script writes the same literals; they have to + /// match. + private enum Key: String { + case version = "CFBundleShortVersionString" + case build = "CFBundleVersion" + case gitSHA = "WhereGitSHA" + case gitStatus = "WhereGitStatus" + } + + /// The values the stamp script writes for `WhereGitStatus`. + private enum Status: String { + case clean + case dirty + case unknown + } +} diff --git a/Where/WhereCore/Sources/Logging/AppAttributionLog.swift b/Where/WhereCore/Sources/Logging/AppAttributionLog.swift new file mode 100644 index 00000000..b2f01c23 --- /dev/null +++ b/Where/WhereCore/Sources/Logging/AppAttributionLog.swift @@ -0,0 +1,39 @@ +import PeriscopeCore + +/// Structured events for loading the app's bundled attribution report. +/// +/// The two failure cases are deliberately not the same severity. A bundle +/// without a report is a *legitimate* state — only the app target ships one, so +/// the RegionViewer, `StuffTestHost`, and the extensions have none — and says so +/// at `.info`. A report that is present but won't decode is a corrupt bundled +/// resource, so it logs at `.fault` to match the paired `assertionFailure`, and +/// it matters beyond a blank screen: the report is how the app discharges its +/// attribution obligations. +enum AppAttributionLog: LogEvent { + /// The bundle carries no report. Expected outside the app target. + case noReport + /// The report decoded successfully into `creditCount` entries. + case loaded(creditCount: Int) + /// The report was present but could not be decoded. + case decodeFailed(description: String) + + static let eventName = "AppAttribution" + + var level: LogLevel { + switch self { + case .decodeFailed: .fault + case .noReport, .loaded: .info + } + } + + var message: String { + switch self { + case .noReport: + "Bundle carries no attribution report" + case let .loaded(creditCount): + "Loaded attribution report with \(creditCount) credit(s)" + case let .decodeFailed(description): + "Failed to decode bundled attribution report: \(description)" + } + } +} diff --git a/Where/WhereCore/Tests/BuildInfoTests.swift b/Where/WhereCore/Tests/BuildInfoTests.swift new file mode 100644 index 00000000..6d2700ea --- /dev/null +++ b/Where/WhereCore/Tests/BuildInfoTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +@_spi(Testing) @testable import WhereCore + +/// Covers reading build metadata out of an Info.plist dictionary: the stamped +/// app bundle, the unstamped ones (RegionViewer, the test host, extensions), and +/// the degraded stamps the script writes when git can't answer. +struct BuildInfoTests { + private func info( + version: String? = "1.4", + build: String? = "27", + sha: String? = "a18a9309c5d6", + status: String? = "clean", + ) -> [String: String] { + var info: [String: String] = [:] + if let version { info["CFBundleShortVersionString"] = version } + if let build { info["CFBundleVersion"] = build } + if let sha { info["WhereGitSHA"] = sha } + if let status { info["WhereGitStatus"] = status } + return info + } + + @Test func readsAStampedBundle() { + let buildInfo = BuildInfo(infoDictionary: info()) + #expect(buildInfo.version == "1.4") + #expect(buildInfo.build == "27") + #expect(buildInfo.commit == BuildInfo.Commit(sha: "a18a9309c5d6", isDirty: false)) + } + + @Test func marksACommitBuiltFromADirtyTree() { + let buildInfo = BuildInfo(infoDictionary: info(status: "dirty")) + #expect(buildInfo.commit?.isDirty == true) + #expect(buildInfo.commit?.sha == "a18a9309c5d6") + } + + @Test func reportsNoCommitForAnUnstampedBundle() { + // The RegionViewer / StuffTestHost case: versions present, no stamp. + let buildInfo = BuildInfo(infoDictionary: info(sha: nil, status: nil)) + #expect(buildInfo.version == "1.4") + #expect(buildInfo.commit == nil) + } + + @Test func reportsNoCommitWhenTheStampCouldNotReadGit() { + // A checkout without git metadata: the script still writes both keys. + let buildInfo = BuildInfo(infoDictionary: info(sha: "unknown", status: "unknown")) + #expect(buildInfo.commit == nil) + } + + @Test func reportsNoCommitForAHalfWrittenStamp() { + #expect(BuildInfo(infoDictionary: info(status: nil)).commit == nil) + #expect(BuildInfo(infoDictionary: info(sha: nil)).commit == nil) + } + + @Test func reportsNoCommitForAnUnrecognizedStatus() { + // A status the reader doesn't know is not silently treated as clean. + #expect(BuildInfo(infoDictionary: info(status: "probably-fine")).commit == nil) + } + + @Test func treatsBlankValuesAsAbsent() { + let buildInfo = BuildInfo(infoDictionary: info(version: "", build: " ", sha: "")) + #expect(buildInfo.version == nil) + #expect(buildInfo.build == nil) + #expect(buildInfo.commit == nil) + } + + @Test func readsTheHostBundleWithoutTrapping() { + // StuffTestHost is unstamped, so this only pins that reading a real + // bundle is safe and honest — not any particular value. + let buildInfo = BuildInfo.current(bundle: .main) + #expect(buildInfo.commit == nil) + } +} diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index c7de7d21..99823789 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -24,8 +24,13 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's `MainTabs`, the Liquid Glass tab bar over three tabs — Locations, Your Year, Settings. Elsewhere is an entry card on Locations, Resolve a Locations toolbar button, and the data screens (attachments, logged days, regions) sit in the - Settings "Data" group. `MainTabs` is built from the `WhereSession` the - launch's `.ready` carries. The app injects the launch-built model + runner + Settings "Data" group. `AboutSettingsView` is the last Settings block — build + identity, the app's generated attribution report (linked libraries and + development tools as separate sections), and bundled-data provenance, each + vended by whoever owns it rather than listed in the view; it renders an + explicit "no report" state, since only the app bundle carries one. `MainTabs` + is built from the `WhereSession` the launch's `.ready` carries. The app + injects the launch-built model + runner (`init(model:launcher:)`); a no-arg `init()` builds its own for previews and the hosted UI test. - **`WhereModel`** — app-level state: the onboarding flag, the owned diff --git a/Where/WhereUI/SnapshotTests/AboutSettingsViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/AboutSettingsViewSnapshotTests.swift new file mode 100644 index 00000000..83d02fb9 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/AboutSettingsViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct AboutSettingsViewSnapshotTests { + @Test func about() async { + await assertSnapshots(of: AboutSettingsView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/LicenseViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/LicenseViewSnapshotTests.swift new file mode 100644 index 00000000..a5b1f1ff --- /dev/null +++ b/Where/WhereUI/SnapshotTests/LicenseViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct LicenseViewSnapshotTests { + @Test func license() async { + await assertSnapshots(of: LicenseView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad.png new file mode 100644 index 00000000..ae7bcb34 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d16877a26de7cc1262f0c5e081b3a7bffe748922fbe876c03be50a14ce9e3519 +size 406231 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_accessibility.png new file mode 100644 index 00000000..e01a0af4 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e6c79b9d0b9429ad023e5774952453db4a32fe6c31cd6ee0d7b2b9252c5d08a +size 796723 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_ax5.png new file mode 100644 index 00000000..edde49c5 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d5f0bd8128cf203d7509350955212b9f2b80ee35b85b24e8330dc29c706863bd +size 427898 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_contrast.png new file mode 100644 index 00000000..529034cd --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f15676ecafb05821a1cacc4dba3017586856b903b5727cd3cdb8a680de26c6e7 +size 411907 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_dark.png new file mode 100644 index 00000000..f821ec39 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22e2a092e740e002c382e1c6ca9eb20e967f336a9755a061474b4b028fc3f215 +size 414527 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone.png new file mode 100644 index 00000000..aaf1a582 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f36b11999560df92ae16d3040cccc46e6a33411b9ce05f05aa8187a431b81ed6 +size 251612 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_accessibility.png new file mode 100644 index 00000000..bb6c5c15 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9446a5b49591cee473e80b85bed80ef191b1043acb88ff47e5cc06ce58eef5f4 +size 583176 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_ax5.png new file mode 100644 index 00000000..b4d60a53 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a630c2183190275da6e6104af26c35cbc722809bcabea9a29489f80733cacfd8 +size 180823 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_contrast.png new file mode 100644 index 00000000..4aa51153 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68cbdf83ad7bc523135f27b820cafda7ca1296660c905217f6c5c7f6078487a0 +size 254956 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_dark.png new file mode 100644 index 00000000..d73721f1 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Default_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c8e5b983839c6e005574edcd28880443a5b440cbbbd5da123aa0fb9fe130aa1 +size 258418 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone.png new file mode 100644 index 00000000..95341c36 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67723aed0d350c0d3291ee576f868d2df963d7f788c716c8420ac41ccf930191 +size 254847 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone_dark.png new file mode 100644 index 00000000..9472c6ac --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.DirtyTree_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6fff7e9d2db0e717f123d507b633762340ba108cbb1821a4ca69de902519b865 +size 261816 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone.png new file mode 100644 index 00000000..73748ddc --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe2e51ca3cfeb4581bc42d1d4307aaecf71af43af39586afdd9a6d542ee03cb6 +size 247914 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone_dark.png new file mode 100644 index 00000000..1d756567 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.LibrariesOnly_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:015abffbcf61d81ca485768b6c624bde3afc86c92e557a7b5c0afe748ad5ab7e +size 254744 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone.png new file mode 100644 index 00000000..f95fcf82 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34210c696d0ef302e9e3c0d309645084825ba73b0a484579b2c5f66018bb1b93 +size 250420 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone_dark.png new file mode 100644 index 00000000..26776462 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AboutSettingsViewSnapshotTests/about.Unattributed_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:64c3cd1528ac0b2ffd7c4ea72894f9e6adf7d7ca9321238592841da11d52c5f1 +size 257061 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad.png new file mode 100644 index 00000000..6a34cb67 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b09ccdf25615b58b6e57a54d820a10a260bae3cf92d4e7779ecb576a534cb5dc +size 234042 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_accessibility.png new file mode 100644 index 00000000..61c2d1d2 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2fd7ef865df350d7065c02a29f07ae60cc278b5d05290f4f514c511ac2198b27 +size 415249 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_ax5.png new file mode 100644 index 00000000..9582a6f9 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db20743e5f477b45c5b33cf0bdc66a2bd61d60589f53aff229d08da2550171ee +size 428256 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_contrast.png new file mode 100644 index 00000000..12ebcf62 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb3dc7e160a512d51d4e0fc16bbe739e2240a31b76e006a81d8b986e13f3f8f2 +size 234452 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_dark.png new file mode 100644 index 00000000..5c6de7f7 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39e711d9bd781d6270ac26c69522457c3a309efdf1da983f46e50647b30c9ba4 +size 254695 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone.png new file mode 100644 index 00000000..a85dc576 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc35d18dfaca0eb6279e2734b89e6239f9a8f33eaba4eda4a08d8fac2f7b68da +size 130913 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_accessibility.png new file mode 100644 index 00000000..9669de64 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7952dbb35334c63917df8fa50f92305b5c2d00deba3216029f0af1b1316d9869 +size 283914 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_ax5.png new file mode 100644 index 00000000..7610e973 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a951cc3c62004f2dd5758bafda41e04ab222054d3f4df9a1c3706a53791547d +size 208168 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_contrast.png new file mode 100644 index 00000000..980489ba --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0771da176143fc03ef058f8b958124760f7b3b213dd8653d9b8e03504e1d0a0c +size 131198 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_dark.png new file mode 100644 index 00000000..ef375e47 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.Default_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:025984aa3a209a42a52c84f83ad46f4c298b85db56688798d0eef81130e4ce81 +size 140123 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.NoNotice_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.NoNotice_iPhone.png new file mode 100644 index 00000000..e5a6866b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.NoNotice_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e8f3722b01575f6e6308396f749f482a71393c177e95213e6b9bdf00a7abf1ae +size 95407 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.NoNotice_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.NoNotice_iPhone_dark.png new file mode 100644 index 00000000..2551303e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LicenseViewSnapshotTests/license.NoNotice_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc794788e1d38be656ab221d501166cd1068ceda0f9b1d3deb7470c2bf0cf818 +size 104225 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad.png index 94e77bfa..8c786431 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:55a60081d9d0414c3168a8540d4606130bf5b1223150b690cf84c0b14f941066 -size 328001 +oid sha256:68947ab4464a1d1b52b0549aa43ac758c7746619307e26155598695802cfb52d +size 336297 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png index ff92ac1e..8c92b8f2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:175d677ef711e76f568ac5cc1d393c2c1542e5f90a425c3c1ab663bacd205125 -size 551655 +oid sha256:8b87a0b2df455320fdc8471464dc1f5d1dc31fe1af715d3b0fc9e3a42463dd10 +size 568290 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_contrast.png index 0b4d0247..cb46ddfe 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fc732879d445908a0a94520ca1b771e057d3d6f1a6f6bf95a8895d78fc18280e -size 328416 +oid sha256:e23309bb8cd235d23e02efe2f1900971ca47d879b0e326ccc018ac1fd64dc2d4 +size 337282 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_dark.png index c20e3d2e..2c2a21a2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb4b01c56284002c10ee1b4c465fdd5202d1c969e46aa28281150a14b483eddb -size 327280 +oid sha256:0c3c62472a1abd219be5dbf805ea381b4db153b8b5f03115c742421dc81aed7a +size 336599 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png index 7368c876..cf13fd43 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:52a1832904759c5ce11172afb02712060609a44633cd631972bd4b5cb00aff1a -size 237695 +oid sha256:a37ac3c9f6a3e54a047107c081d10d3afdfbd6f4f6510dd81abda7d7ea1d330a +size 241947 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png index 2a5eca3f..a64d4300 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:554a939eb4d8a7ace1025cabe365fe0e91531c13c764057b6104b95772511051 -size 422232 +oid sha256:d9547af2b7b2775b3a4ef482d98232ccea2fac5d7329eeb35a2b0ee0766a5c2e +size 431753 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_contrast.png index 59f92b62..1e94d0b5 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:516751addcec7ea857111a63289e647a36ac7573a7beb5c8b7ec8d93c1ef248b -size 222330 +oid sha256:8a5df37b9e9d8ea2b6d0e2995d132f9ac1bad4c2ae6051b262b87d1ca31518bf +size 228636 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png index 3574d14f..49c804da 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8e0ecc24e561310f316f62ea87f2d09ecf63db30753c684d362a8c135d84a799 -size 215681 +oid sha256:410d961b35614d81f9619379d4f2b486d3714caa3e926f6f7729ea0f65fe2df4 +size 234659 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_rtl.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_rtl.png index 5db6567d..2a0297ff 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_rtl.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_rtl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e4f59fc1727ef85603e870148796490486ddf3e8f73a55d7965a057374ec0c2f -size 237161 +oid sha256:f4ddf50c790047b612ff1e3e44a2d1944e847d0849871bd936ccf1345adeffd5 +size 241545 diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index d1961e03..fe4e267d 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -1,4 +1,5 @@ #if DEBUG + import CreditKit import Foundation import PeriscopeCore import RegionKit @@ -102,6 +103,66 @@ BackupModel(services: previewServices()) } + // MARK: - Build metadata (About sub-screen) + + /// Build metadata as the shipping app carries it — the About screen's + /// normal state. A preview or test bundle is never stamped, so this can't + /// come from the real bundle. + public static func stampedBuildInfo(isDirty: Bool = false) -> BuildInfo { + BuildInfo(infoDictionary: [ + "CFBundleShortVersionString": "1.0", + "CFBundleVersion": "42", + "WhereGitSHA": "a18a9309c5d6", + "WhereGitStatus": isDirty ? "dirty" : "clean", + ]) + } + + /// Build metadata from a bundle the stamp script never ran on — the + /// RegionViewer, an extension, a test host. + public static func unstampedBuildInfo() -> BuildInfo { + BuildInfo(infoDictionary: [:]) + } + + /// An attribution report shaped like the generated one: a linked library + /// and a development tool, so both About sections render. Only the app + /// target ships a real report, so a preview or test bundle can't read one. + public static func sampleAttribution() -> AttributionManifest { + AttributionManifest(credits: [ + sampleCredit(noticeText: sampleNotice), + SoftwareCredit( + name: "swiftui-pro", + kind: .developmentTool, + version: "61b74001b64b", + homepageURL: URL(string: "https://github.com/twostraws/swiftui-agent-skill"), + license: LicenseNotice(name: "MIT License", text: sampleNotice), + ), + ]) + } + + /// The library credit from ``sampleAttribution()``, with its notice text + /// substitutable: pass `""` for the no-notice state a hand-edited report + /// could reach, which the generator itself refuses to write. + public static func sampleCredit(noticeText: String) -> SoftwareCredit { + SoftwareCredit( + name: "ZIPFoundation", + kind: .library, + version: "0.9.20", + homepageURL: URL(string: "https://github.com/weichsel/ZIPFoundation"), + license: LicenseNotice(name: "MIT License", text: noticeText), + ) + } + + /// Stand-in notice text. Deliberately not a real license: a fixture that + /// reproduced one verbatim would read as an attribution the app makes. + public static let sampleNotice = """ + Sample License + + Copyright (c) 2026 Example Author + + Placeholder notice text for previews and tests. The shipping app renders + the real notice carried by its generated attribution report. + """ + // MARK: - Region picker / customization /// A primary-region selection model seeded with a few US picks + looks, diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 55786dd3..51144530 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -3008,6 +3008,282 @@ } } }, + "settings.about.attribution.none" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing to credit here." + } + } + } + }, + "settings.about.attribution.unavailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This build carries no attribution report." + } + } + } + }, + "settings.about.build.row" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Build" + } + } + } + }, + "settings.about.commit.modified" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ (modified)" + } + } + } + }, + "settings.about.commit.row" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Commit" + } + } + } + }, + "settings.about.dataSource.approximate" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Approximate outline drawn for this app — coarse, and not authoritative." + } + } + } + }, + "settings.about.dataSource.authoritative" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Simplified from the published boundary set." + } + } + } + }, + "settings.about.dataSource.obtainedFromLink" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obtained from %@" + } + } + } + }, + "settings.about.dataSource.originalWork" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No external source; covered by the app's own license." + } + } + } + }, + "settings.about.dataSource.publicDomain" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Public domain — %@" + } + } + } + }, + "settings.about.dataSource.publisherLink" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Published at %@" + } + } + } + }, + "settings.about.dataSource.regionCount" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld region" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld regions" + } + } + } + } + } + } + }, + "settings.about.dataSources.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The region boundaries bundled with the app, and where they came from." + } + } + } + }, + "settings.about.dataSources.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data Sources" + } + } + } + }, + "settings.about.dependencies.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Where is built with these open-source libraries. Tap one to read its license." + } + } + } + }, + "settings.about.dependencies.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Source" + } + } + } + }, + "settings.about.developmentTools.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tools used to build Where. They aren't part of the app on your device, but the project ships copies of them, so their licenses are included here." + } + } + } + }, + "settings.about.developmentTools.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Development Tools" + } + } + } + }, + "settings.about.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "About" + } + } + } + }, + "settings.about.license.unavailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This credit's report entry carries no license text." + } + } + } + }, + "settings.about.value.unknown" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown" + } + } + } + }, + "settings.about.version.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Include the version and commit when you report a problem." + } + } + } + }, + "settings.about.version.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } + } + } + }, + "settings.about.version.row" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } + } + } + }, "settings.alerts.group" : { "extractionState" : "manual", "localizations" : { @@ -3429,6 +3705,50 @@ } } }, + "settings.keywords.aboutDataSources" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "data, data sources, attribution, geojson, boundaries, borders, census, maps" + } + } + } + }, + "settings.keywords.aboutDependencies" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "license, licenses, open source, credits, acknowledgements, libraries, dependencies, third party" + } + } + } + }, + "settings.keywords.aboutDevelopmentTools" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tools, development, build, agent, skills, license, licenses, credits, acknowledgements, third party" + } + } + } + }, + "settings.keywords.aboutVersion" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "version, build, build number, commit, sha, git, about, release" + } + } + } + }, "settings.keywords.appIcon" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Settings/AboutSettingsView.swift b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift new file mode 100644 index 00000000..75aaf8f9 --- /dev/null +++ b/Where/WhereUI/Sources/Settings/AboutSettingsView.swift @@ -0,0 +1,281 @@ +import CreditKit +import RegionKit +import SnapshotKit +import SwiftUI +import WhereCore + +/// Settings drill-in for what the app *is* rather than what it does: which build +/// is running, the third-party work it is built with, and where its bundled +/// region boundaries came from. +/// +/// Every fact here is vended by whoever owns it — `BuildInfo` and the generated +/// attribution report from `WhereCore`, `RegionDataSource` from `RegionKit` — so +/// this screen only renders and localizes. Adding a dependency means re-running +/// `./attribution`, and adding a dataset means regenerating RegionKit's; neither +/// is a change to this view. +/// +/// Credits are split by `SoftwareCredit.Kind` into two sections rather than one +/// combined list, because a development tool is *not* in the binary the reader +/// is running; merging them would credit honestly but describe the app falsely. +struct AboutSettingsView: View { + var focus: SettingsFocus? + + @Environment(\.stylesheet) private var stylesheet + + private let buildInfo: BuildInfo + private let attribution: AttributionManifest? + private let dataSources: [RegionDataSource] + + /// Defaults read the live values; the parameters exist so previews and tests + /// can render a stamped build and a populated report, neither of which a + /// bundle outside the app target carries. + init( + focus: SettingsFocus?, + buildInfo: BuildInfo = .current(bundle: .main), + attribution: AttributionManifest? = AppAttribution.main, + dataSources: [RegionDataSource] = RegionDataSource.all, + ) { + self.focus = focus + self.buildInfo = buildInfo + self.attribution = attribution + self.dataSources = dataSources + } + + var body: some View { + SettingsFocusScope(focus: focus) { + Form { + versionSection + dependenciesSection + developmentToolsSection + dataSourcesSection + } + } + .navigationTitle(String(localized: .settingsAboutHeader)) + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: Version + + private var versionSection: some View { + Section { + LabeledContent( + String(localized: .settingsAboutVersionRow), + value: WhereFormat.aboutValue(buildInfo.version), + ) + LabeledContent( + String(localized: .settingsAboutBuildRow), + value: WhereFormat.aboutValue(buildInfo.build), + ) + LabeledContent( + String(localized: .settingsAboutCommitRow), + value: WhereFormat.aboutCommit(buildInfo.commit), + ) + } header: { + Text(String(localized: .settingsAboutVersionHeader)) + } footer: { + Text(String(localized: .settingsAboutVersionFooter)) + } + // Selectable so a bug report can carry the exact commit rather than a + // squinted-at transcription of it. + .textSelection(.enabled) + .settingsRow(Item.version) + } + + // MARK: Open source + + private var dependenciesSection: some View { + creditSection( + kind: .library, + header: String(localized: .settingsAboutDependenciesHeader), + footer: String(localized: .settingsAboutDependenciesFooter), + ) + .settingsRow(Item.dependencies) + } + + // MARK: Development tools + + private var developmentToolsSection: some View { + creditSection( + kind: .developmentTool, + header: String(localized: .settingsAboutDevelopmentToolsHeader), + footer: String(localized: .settingsAboutDevelopmentToolsFooter), + ) + .settingsRow(Item.developmentTools) + } + + private func creditSection( + kind: SoftwareCredit.Kind, + header: String, + footer: String, + ) -> some View { + let credits = attribution?.credits(ofKind: kind) ?? [] + return Section { + if credits.isEmpty { + // Keyed on *this section* being empty, not on the report being + // absent: a report that carries only libraries would otherwise + // render the tools header and footer over no rows, and a footer + // still promising a list to tap through reads as a broken screen + // rather than as an empty one. + Text(String(localized: emptyMessage)) + .foregroundStyle(.secondary) + } else { + ForEach(credits) { credit in + // A closure-form link, not a `SettingsRoute`: routes are the + // top-level group vocabulary, and a leaf pushed from inside + // one group would need its own `navigationDestination`. + NavigationLink { + LicenseView(credit: credit) + } label: { + LabeledContent(credit.name) { + Text(credit.version) + } + } + } + } + } header: { + Text(header) + } footer: { + Text(footer) + } + } + + /// Why a section is empty, which the reader needs distinguished: no report + /// at all means the whole build is unattributed, while an empty section of + /// a real report means only that this kind has nothing in it. + var emptyMessage: LocalizedStringResource { + attribution == nil ? .settingsAboutAttributionUnavailable : .settingsAboutAttributionNone + } + + // MARK: Data sources + + private var dataSourcesSection: some View { + Section { + ForEach(dataSources, id: \.name) { source in + dataSourceRow(source) + } + } header: { + Text(String(localized: .settingsAboutDataSourcesHeader)) + } footer: { + Text(String(localized: .settingsAboutDataSourcesFooter)) + } + .settingsRow(Item.dataSources) + } + + private func dataSourceRow(_ source: RegionDataSource) -> some View { + // Everything below the name is stacked rather than trailing-aligned: a + // boundary set's name is long enough to wrap, and a trailing count that + // sometimes drops to its own line reads as a different layout per row. + VStack(alignment: .leading, spacing: stylesheet.spacing.xSmall) { + Text(source.name) + VStack(alignment: .leading, spacing: stylesheet.spacing.xxSmall) { + // Only the prose is dimmed — a link tinted secondary alongside + // it stops reading as tappable. + Group { + Text(WhereFormat.regionDataSourceRegionCount(source.regions.count)) + Text(WhereFormat.regionDataSourceFidelity(source.fidelity)) + Text(WhereFormat.regionDataSourceLicense(source.license)) + } + .foregroundStyle(.secondary) + if let url = source.sourceURL { + Link(WhereFormat.regionDataSourcePublisher(url), destination: url) + } + if let url = source.obtainedFromURL { + Link(WhereFormat.regionDataSourceObtainedFrom(url), destination: url) + } + } + .font(.caption) + } + } +} + +extension AboutSettingsView: SettingsSection { + static var destination: SettingsDestination { + .about + } + + enum Item: SettingsItem { + case version + case dependencies + case developmentTools + case dataSources + + var title: String { + switch self { + case .version: String(localized: .settingsAboutVersionHeader) + case .dependencies: String(localized: .settingsAboutDependenciesHeader) + case .developmentTools: + String(localized: .settingsAboutDevelopmentToolsHeader) + case .dataSources: String(localized: .settingsAboutDataSourcesHeader) + } + } + + var keywords: [String] { + switch self { + case .version: splitKeywords(String(localized: .settingsKeywordsAboutVersion)) + case .dependencies: + splitKeywords(String(localized: .settingsKeywordsAboutDependencies)) + case .developmentTools: + splitKeywords(String(localized: .settingsKeywordsAboutDevelopmentTools)) + case .dataSources: + splitKeywords(String(localized: .settingsKeywordsAboutDataSources)) + } + } + } +} + +#if DEBUG + extension AboutSettingsView: SnapshotProviding { + /// Every state the screen has, which for this screen means every degree + /// of *absence*: the shipping build is the only one that is both stamped + /// and attributed, so the interesting cases are what each missing piece + /// renders as. + static var snapshots: [SnapshotCase] { + whereSnapshot(name: "Default", configurations: .screenDefaults) { + NavigationStack { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.stampedBuildInfo(), + attribution: PreviewSupport.sampleAttribution(), + ) + } + } + whereSnapshot(name: "DirtyTree", configurations: .phoneLightDark) { + NavigationStack { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.stampedBuildInfo(isDirty: true), + attribution: PreviewSupport.sampleAttribution(), + ) + } + } + whereSnapshot(name: "Unattributed", configurations: .phoneLightDark) { + // What a bundle outside the app target shows: honest unknowns and + // an explicit "no report" rather than blank rows and empty sections. + NavigationStack { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.unstampedBuildInfo(), + attribution: nil, + ) + } + } + whereSnapshot(name: "LibrariesOnly", configurations: .phoneLightDark) { + // A real report that credits nothing of one kind. Pinned as an + // image because the failure mode is purely visual: a header and + // footer over no rows, promising a list that isn't there. + let libraries = PreviewSupport.sampleAttribution().credits(ofKind: .library) + NavigationStack { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.stampedBuildInfo(), + attribution: AttributionManifest(credits: libraries), + ) + } + } + } + } + + #Preview { + AboutSettingsView.snapshotPreviews + } +#endif diff --git a/Where/WhereUI/Sources/Settings/LicenseView.swift b/Where/WhereUI/Sources/Settings/LicenseView.swift new file mode 100644 index 00000000..72e1b88b --- /dev/null +++ b/Where/WhereUI/Sources/Settings/LicenseView.swift @@ -0,0 +1,83 @@ +import CreditKit +import SnapshotKit +import SwiftUI + +/// The full license notice for one credited work, pushed from Settings > About. +/// Permissive licenses require the notice verbatim, so it is rendered as plain +/// monospaced text, unreflowed and untruncated. +struct LicenseView: View { + let credit: SoftwareCredit + + @Environment(\.stylesheet) private var stylesheet + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: stylesheet.spacing.xxLarge) { + header + notice + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .navigationTitle(credit.name) + .navigationBarTitleDisplayMode(.inline) + } + + private var header: some View { + VStack(alignment: .leading, spacing: stylesheet.spacing.xxSmall) { + Text(credit.license.name) + .font(.headline) + Text(credit.version) + .font(.subheadline) + .foregroundStyle(.secondary) + if let url = credit.homepageURL { + Link(url.absoluteString, destination: url) + .font(.subheadline) + } + } + } + + @ViewBuilder + private var notice: some View { + // The report carries the notice inline, so there is no file to fail to + // load — an empty one would mean the generator wrote a credit without + // text, which it refuses to do. + if credit.license.text.isEmpty { + Text(String(localized: .settingsAboutLicenseUnavailable)) + .font(.footnote) + .foregroundStyle(.secondary) + } else { + Text(credit.license.text) + .font(.footnote.monospaced()) + .textSelection(.enabled) + } + } +} + +#if DEBUG + extension LicenseView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + whereSnapshot(name: "Default", configurations: .screenDefaults) { + NavigationStack { + LicenseView( + credit: PreviewSupport.sampleCredit( + noticeText: PreviewSupport.sampleNotice, + ), + ) + } + } + whereSnapshot(name: "NoNotice", configurations: .phoneLightDark) { + // The generator refuses to emit a credit without notice text, so + // this is the state a hand-edited report would reach — worth an + // image because it is the one case that renders no notice at all. + NavigationStack { + LicenseView(credit: PreviewSupport.sampleCredit(noticeText: "")) + } + } + } + } + + #Preview { + LicenseView.snapshotPreviews + } +#endif diff --git a/Where/WhereUI/Sources/Settings/SettingsSearch.swift b/Where/WhereUI/Sources/Settings/SettingsSearch.swift index d5c7ba7a..180e4af3 100644 --- a/Where/WhereUI/Sources/Settings/SettingsSearch.swift +++ b/Where/WhereUI/Sources/Settings/SettingsSearch.swift @@ -14,6 +14,7 @@ enum SettingsDestination: Hashable, CaseIterable { case year case backup case data + case about /// The localized title shown on the top-level drill-in row and as the parent /// group label under a search result. @@ -28,6 +29,7 @@ enum SettingsDestination: Hashable, CaseIterable { case .year: String(localized: .settingsYearHeader) case .backup: String(localized: .settingsBackupHeader) case .data: String(localized: .settingsDataHeader) + case .about: String(localized: .settingsAboutHeader) } } @@ -43,6 +45,7 @@ enum SettingsDestination: Hashable, CaseIterable { case .year: "calendar" case .backup: "externaldrive.fill" case .data: "trash.fill" + case .about: "info" } } @@ -60,6 +63,7 @@ enum SettingsDestination: Hashable, CaseIterable { case .year: .orange case .backup: .teal case .data: .gray + case .about: .brown } } @@ -69,7 +73,8 @@ enum SettingsDestination: Hashable, CaseIterable { var isSheet: Bool { switch self { case .regions: true - case .attachments, .loggedDays, .location, .alerts, .appearance, .year, .backup, .data: + case .attachments, .loggedDays, .location, .alerts, .appearance, .year, .backup, .data, + .about: false } } @@ -85,6 +90,9 @@ enum SettingsListSection: CaseIterable { case notifications case display case storage + /// Last on purpose: About is reference material, so it sits below everything + /// actionable, where iOS Settings puts its own. + case about var destinations: [SettingsDestination] { switch self { @@ -93,6 +101,7 @@ enum SettingsListSection: CaseIterable { case .notifications: [.alerts] case .display: [.appearance, .year] case .storage: [.backup, .data] + case .about: [.about] } } } @@ -199,6 +208,7 @@ enum SettingsCatalog { + VisibleYearSettingsView.searchResults + BackupSettingsView.searchResults + DataSettingsView.searchResults + + AboutSettingsView.searchResults /// The results matching a (trimmed, non-empty) query. static func results(matching query: String) -> [SettingsSearchResult] { diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index c4448f2c..1734652b 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -5,9 +5,9 @@ import WhereCore /// Settings tab: an iOS-Settings-style top-level list of icon rows that drill /// into grouped sub-screens — a Data group at the top (attachments, logged days, -/// regions), then location, alerts, appearance, report year, backup, and erase -/// — plus a search field that filters individual settings and deep-links to the -/// screen — and the row — containing each. +/// regions), then location, alerts, appearance, report year, backup, erase, and +/// About — plus a search field that filters individual settings and deep-links +/// to the screen — and the row — containing each. /// /// The top level owns nothing but navigation; behavior lives in the sub-screens /// (`LocationSettingsView`, `AlertsSettingsView`, …). The scene's report model and @@ -107,7 +107,8 @@ struct SettingsView: View { switch destination { case .regions: showRegions = true - case .attachments, .loggedDays, .location, .alerts, .appearance, .year, .backup, .data: + case .attachments, .loggedDays, .location, .alerts, .appearance, .year, .backup, .data, + .about: assertionFailure("\(destination) is a push destination, not a sheet") } } @@ -140,7 +141,7 @@ struct SettingsView: View { ) case .year: report.selectedYear.formatted(.number.grouping(.never)) - case .attachments, .loggedDays, .regions, .alerts, .appearance, .backup, .data: + case .attachments, .loggedDays, .regions, .alerts, .appearance, .backup, .data, .about: nil } } @@ -186,6 +187,8 @@ struct SettingsView: View { BackupSettingsView(backup: backup, focus: route.focus) case .data: DataSettingsView(report: report, focus: route.focus) + case .about: + AboutSettingsView(focus: route.focus) } } diff --git a/Where/WhereUI/Sources/Shared/WhereFormat.swift b/Where/WhereUI/Sources/Shared/WhereFormat.swift index 83778628..64f33d23 100644 --- a/Where/WhereUI/Sources/Shared/WhereFormat.swift +++ b/Where/WhereUI/Sources/Shared/WhereFormat.swift @@ -341,4 +341,58 @@ enum WhereFormat { String(localized: .recentActivityUnavailableUnknown) } } + + // MARK: About + + /// A build-metadata value, or a localized "Unknown" when the bundle doesn't + /// carry one — so a blank row can never read as a real, empty value. + static func aboutValue(_ value: String?) -> String { + value ?? String(localized: .settingsAboutValueUnknown) + } + + /// The commit the app was built from, flagged when the working tree had + /// uncommitted changes so a developer build isn't mistaken for a + /// reproducible one. + static func aboutCommit(_ commit: BuildInfo.Commit?) -> String { + guard let commit else { return String(localized: .settingsAboutValueUnknown) } + return commit.isDirty + ? String(localized: .settingsAboutCommitModified(commit.sha)) + : commit.sha + } + + static func regionDataSourceRegionCount(_ count: Int) -> String { + String(localized: .settingsAboutDataSourceRegionCount(count)) + } + + static func regionDataSourceFidelity(_ fidelity: RegionDataSource.Fidelity) -> String { + switch fidelity { + case .authoritative: String(localized: .settingsAboutDataSourceAuthoritative) + case .approximate: String(localized: .settingsAboutDataSourceApproximate) + } + } + + static func regionDataSourceLicense(_ license: RegionDataSource.License) -> String { + switch license { + case let .publicDomain(rationale): + String(localized: .settingsAboutDataSourcePublicDomain(rationale)) + case .originalWork: + String(localized: .settingsAboutDataSourceOriginalWork) + } + } + + static func regionDataSourcePublisher(_ url: URL) -> String { + String(localized: .settingsAboutDataSourcePublisherLink(linkHost(url))) + } + + static func regionDataSourceObtainedFrom(_ url: URL) -> String { + String(localized: .settingsAboutDataSourceObtainedFromLink(linkHost(url))) + } + + /// A link's bare host ("census.gov"), so a data-source link names where it + /// goes instead of reading as an unlabeled "Publisher". Falls back to the + /// whole URL for anything without a host. + private static func linkHost(_ url: URL) -> String { + guard let host = url.host() else { return url.absoluteString } + return host.hasPrefix("www.") ? String(host.dropFirst(4)) : host + } } diff --git a/Where/WhereUI/Tests/AboutSettingsViewTests.swift b/Where/WhereUI/Tests/AboutSettingsViewTests.swift new file mode 100644 index 00000000..80add30c --- /dev/null +++ b/Where/WhereUI/Tests/AboutSettingsViewTests.swift @@ -0,0 +1,179 @@ +import CreditKit +import RegionKit +import SwiftUI +import TestHostSupport +import Testing +@_spi(Testing) import WhereCore +@testable import WhereUI + +/// Covers the About screen's registration in Settings search and that it renders +/// each build-metadata state — including the unstamped one, which is what every +/// bundle other than the shipping app produces. +@MainActor +struct AboutSettingsViewTests { + // MARK: Placement + + @Test func aboutIsTheLastBlockInTheSettingsList() throws { + let last = try #require(SettingsListSection.allCases.last) + #expect(last.destinations == [.about]) + } + + @Test func aboutPushesRatherThanPresentingASheet() { + #expect(!SettingsDestination.about.isSheet) + } + + // MARK: Search + + @Test func everyItemIsRegisteredForSearch() { + let registered = AboutSettingsView.searchResults + #expect(registered.count == AboutSettingsView.Item.allCases.count) + #expect(registered.allSatisfy { $0.destination == .about }) + } + + @Test func searchFindsTheAboutSettingsByKeyword() { + // None of these words appear in the section titles, so a match proves the + // keyword lists are wired rather than the titles happening to overlap. + for query in ["licenses", "sha", "geojson", "skills"] { + let destinations = Set(SettingsCatalog.results(matching: query).map(\.destination)) + #expect(destinations.contains(.about), "no About result for \"\(query)\"") + } + } + + // MARK: Rendering + + @Test func hostsAStampedBuild() throws { + let rootView = NavigationStack { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.stampedBuildInfo(), + attribution: PreviewSupport.sampleAttribution(), + ) + } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + + @Test func hostsAnUnstampedBuild() throws { + // The RegionViewer / test-host case: no version keys and no commit. + let rootView = NavigationStack { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.unstampedBuildInfo(), + attribution: PreviewSupport.sampleAttribution(), + ) + } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + + @Test func hostsWithASearchFocus() throws { + let focus = try #require( + SettingsCatalog.results.first { $0.destination == .about }, + ).focus + let rootView = NavigationStack { + AboutSettingsView( + focus: focus, + buildInfo: PreviewSupport.stampedBuildInfo(), + attribution: PreviewSupport.sampleAttribution(), + ) + } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + + @Test func hostsABundleCarryingNoReport() throws { + // Every bundle but the app target's is in this state, so it has to render + // rather than trip on a force-unwrapped manifest. + let rootView = NavigationStack { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.unstampedBuildInfo(), + attribution: nil, + dataSources: [], + ) + } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + + @Test func hostsAnEmptyReport() throws { + let rootView = NavigationStack { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.unstampedBuildInfo(), + attribution: AttributionManifest(credits: []), + dataSources: [], + ) + } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + + @Test func hostsAReportThatCreditsOnlyOneKind() throws { + // Reachable for real: a config whose agent-skills manifest is empty still + // generates a valid report, and the tools section then has nothing to + // list while the libraries section is full. + let libraries = PreviewSupport.sampleAttribution().credits(ofKind: .library) + let rootView = NavigationStack { + AboutSettingsView( + focus: nil, + buildInfo: PreviewSupport.unstampedBuildInfo(), + attribution: AttributionManifest(credits: libraries), + dataSources: [], + ) + } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + + @Test func distinguishesAnAbsentReportFromAnEmptySection() { + // The two say different things about the build: no report means nothing + // is attributed at all, while an empty section of a real report means + // only that this one kind is unpopulated. + let absent = AboutSettingsView(focus: nil, attribution: nil, dataSources: []) + #expect( + String(localized: absent.emptyMessage) + == String(localized: .settingsAboutAttributionUnavailable), + ) + + let empty = AboutSettingsView( + focus: nil, + attribution: AttributionManifest(credits: []), + dataSources: [], + ) + #expect( + String(localized: empty.emptyMessage) + == String(localized: .settingsAboutAttributionNone), + ) + } + + @Test func showsTheLiveDataSourceList() { + // The screen shows what RegionKit vends, not a copy of its own. The + // software credits are the app's generated report, asserted against the + // real bundle in the Where app's `AppAttributionTests`. + #expect(!RegionDataSource.all.isEmpty) + } + + @Test func separatesLinkedLibrariesFromDevelopmentTools() { + // The two kinds must reach the screen as distinct sections: a development + // tool isn't in the binary, so listing it under "Open Source" alongside + // the libraries would describe the running app inaccurately. + let titles = Set(AboutSettingsView.Item.allCases.map(\.title)) + #expect(titles.contains(String(localized: .settingsAboutDevelopmentToolsHeader))) + #expect(titles.contains(String(localized: .settingsAboutDependenciesHeader))) + + let sample = PreviewSupport.sampleAttribution() + for kind in SoftwareCredit.Kind.allCases { + #expect( + !sample.credits(ofKind: kind).isEmpty, + "the fixture credits nothing for \(kind), so that section goes untested", + ) + } + } +} diff --git a/Where/WhereUI/Tests/LicenseViewTests.swift b/Where/WhereUI/Tests/LicenseViewTests.swift new file mode 100644 index 00000000..78d5afa2 --- /dev/null +++ b/Where/WhereUI/Tests/LicenseViewTests.swift @@ -0,0 +1,30 @@ +import CreditKit +import SwiftUI +import TestHostSupport +import Testing +@testable import WhereUI + +/// Covers the license notice pushed from Settings > About: it renders the text +/// the report carries, which is the compliance-relevant path. +@MainActor +struct LicenseViewTests { + @Test func hostsEveryCreditsNotice() throws { + for credit in PreviewSupport.sampleAttribution().credits { + let rootView = NavigationStack { LicenseView(credit: credit) } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + } + + @Test func hostsACreditWhoseNoticeIsEmpty() throws { + // The generator refuses to write one, so this is the defensive path — it + // must say the notice is unavailable rather than render a blank page + // that reads like a license with no terms. + let credit = PreviewSupport.sampleCredit(noticeText: "") + let rootView = NavigationStack { LicenseView(credit: credit) } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } +} diff --git a/Where/WhereUI/Tests/SettingsSearchTests.swift b/Where/WhereUI/Tests/SettingsSearchTests.swift index 40b7a891..3cb70079 100644 --- a/Where/WhereUI/Tests/SettingsSearchTests.swift +++ b/Where/WhereUI/Tests/SettingsSearchTests.swift @@ -41,6 +41,13 @@ struct SettingsSearchTests { #expect(destinations.contains(.alerts)) } + @Test func matchesTheAboutScreenOnALicenseKeyword() { + // "license" is nowhere in a section title, so this only passes if the + // About screen's keywords are registered. + let results = SettingsCatalog.results(matching: "license") + #expect(results.contains { $0.destination == .about }) + } + @Test func focusedRouteCarriesTheResultsDestinationAndFocus() throws { let result = try #require(SettingsCatalog.results.first) let route = SettingsRoute(result) diff --git a/Where/WhereUI/Tests/WhereFormatTests.swift b/Where/WhereUI/Tests/WhereFormatTests.swift index 7800efff..c37de898 100644 --- a/Where/WhereUI/Tests/WhereFormatTests.swift +++ b/Where/WhereUI/Tests/WhereFormatTests.swift @@ -145,4 +145,50 @@ struct WhereFormatTests { "Every feature decoded straight from the bundled GeoJSON, at full authored fidelity.", ) } + + // MARK: About + + @Test func aboutValueNamesAMissingBuildValue() { + #expect(WhereFormat.aboutValue("1.0") == "1.0") + // An absent value must read as unknown, never as a blank that looks + // like a real (empty) version. + #expect(WhereFormat.aboutValue(nil) == "Unknown") + } + + @Test func aboutCommitFlagsADirtyTree() { + let sha = "a18a9309c5d6" + #expect(WhereFormat.aboutCommit(BuildInfo.Commit(sha: sha, isDirty: false)) == sha) + #expect( + WhereFormat.aboutCommit(BuildInfo.Commit(sha: sha, isDirty: true)) + == "\(sha) (modified)", + ) + #expect(WhereFormat.aboutCommit(nil) == "Unknown") + } + + @Test func regionDataSourceCountPluralizes() { + #expect(WhereFormat.regionDataSourceRegionCount(1) == "1 region") + #expect(WhereFormat.regionDataSourceRegionCount(52) == "52 regions") + } + + @Test func regionDataSourceFidelitySwitchesResolve() { + #expect( + WhereFormat.regionDataSourceFidelity(.authoritative) + == "Simplified from the published boundary set.", + ) + #expect( + WhereFormat.regionDataSourceFidelity(.approximate) + == "Approximate outline drawn for this app — coarse, and not authoritative.", + ) + } + + @Test func regionDataSourceLicenseSwitchesResolve() { + #expect( + WhereFormat.regionDataSourceLicense(.publicDomain("17 U.S.C. § 105")) + == "Public domain — 17 U.S.C. § 105", + ) + #expect( + WhereFormat.regionDataSourceLicense(.originalWork) + == "No external source; covered by the app's own license.", + ) + } } diff --git a/attribution b/attribution new file mode 100755 index 00000000..5179c310 --- /dev/null +++ b/attribution @@ -0,0 +1,80 @@ +#!/bin/bash +set -euo pipefail + +# attribution — regenerate each app's attribution report. +# +# A report lists every third-party work an app is built with, each carrying its +# license notice inline, and lands in that app's own resources for the About +# screen to decode. It is generated rather than hand-kept, which is the point: a +# hand-maintained list silently goes stale the moment some *other* module adds a +# dependency, whereas a re-run picks it up wherever it landed. +# +# Each app declares its own sources in an attribution-sources.json; the generic +# reporting lives in CreditKit. Re-run after adding or bumping a package, or +# after `./sync-agents --update` moves a pinned skill, and commit the result. +# +# Needs network and an authenticated `gh` (notices are read at the pinned +# revision, so the text shipped is the one governing the code actually in use). + +cd "$(dirname "$0")" + +GENERATOR="Shared/CreditKit/Tools/generate-attribution.rb" + +# One entry per app that ships a report. +CONFIGS=( + "Where/Where/attribution-sources.json" +) + +usage() { + cat <<'USAGE' +Usage: + ./attribution [config ...] + ./attribution --check [config ...] + ./attribution --list + ./attribution --help + +Regenerates the attribution report for each configured app. With no arguments, +regenerates every app's report; pass a config path to do just one. + + --check Verify each committed report still matches what the repo declares, + without writing anything. Runs offline and needs no gh, so CI can + gate on it — which is what stops a stale report from shipping. +USAGE +} + +case "${1-}" in + -h | --help) + usage + exit 0 + ;; + --list) + printf '%s\n' "${CONFIGS[@]}" + exit 0 + ;; +esac + +check="" +if [ "${1-}" = "--check" ]; then + check="--check" + shift +fi + +# Only a regenerating run reads notices off GitHub; --check is entirely local. +if [ -z "$check" ] && ! command -v gh >/dev/null 2>&1; then + echo "attribution: needs the GitHub CLI (gh) to read license notices" >&2 + exit 1 +fi + +if [ "$#" -gt 0 ]; then + targets=("$@") +else + targets=("${CONFIGS[@]}") +fi + +# The pinned Ruby via mise when available, since the repo pins one; the system +# Ruby otherwise, so a bare checkout can still run this. +if command -v mise >/dev/null 2>&1; then + mise exec -- ruby "$GENERATOR" ${check:+"$check"} "${targets[@]}" +else + ruby "$GENERATOR" ${check:+"$check"} "${targets[@]}" +fi