krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

074052763638a36e274e18d00f5f5ab21846be28

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-07-25T16:35:46Z

feat: enrich Settings → App Info with metadata & resource links (#56)

Replaces the three-row App Info screen with a full About/Resources/Support/
Legal layout, driven by a declarative AppInfoRow model with all URLs centralized
in one AppLinks namespace.

- About: Version now shows "5.0.0 (build N)" (CFBundleShortVersionString +
  CFBundleVersion), plus Storage, Backup Schema, and Minimum iOS (17.6).
- Resources: What's New (bundled ReleaseNotes.json sheet, no network),
  Documentation & FAQ, Source Code, Privacy Policy, and Acknowledgements
  ("no third-party dependencies" + MIT license).
- Support: Report an Issue (a sheet that shows the locally-assembled
  version/OS/device diagnostics before offering Email or GitHub — nothing is
  collected silently) and Contact (mailto).
- Support the App: Rate (SwiftUI's @Environment(\.requestReview), which handles
  the scene internally and respects Apple's throttling — the modern, safer
  equivalent of the issue's SKStoreReviewController + connectedScenes path) and
  Share (ShareLink to the App Store listing).
- Legal: copyright footer with the current year.

External rows use Link/openURL with an "opens outside the app" accessibility
hint; the screen is standard adaptive Form controls with semantic colors, so it
tracks Dynamic Type and light/dark automatically.

New files (AppLinks, AppInfo, ReleaseNotes[.swift/.json], AppInfoView) auto-
compile/bundle via the synchronized DomainDig/ group. AppInfoTests covers the
mailto builder, version format, diagnostics contents, and that the bundled notes
ship and parse. Unit suite green.

Placeholder pending the App Store listing: AppLinks.appStoreID (the review/share
URLs derive from it), flagged with a TODO.
 DomainDig.xcodeproj/project.pbxproj |   4 +
 DomainDig/AppInfo.swift             |  52 ++++++++
 DomainDig/AppInfoView.swift         | 254 ++++++++++++++++++++++++++++++++++++
 DomainDig/AppLinks.swift            |  34 +++++
 DomainDig/ReleaseNotes.json         |   9 ++
 DomainDig/ReleaseNotes.swift        |  17 +++
 DomainDig/SettingsViews.swift       |  24 +---
 DomainDigTests/AppInfoTests.swift   |  45 +++++++
 8 files changed, 416 insertions(+), 23 deletions(-)

diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 386de7e..da11c1e 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -21,6 +21,7 @@
 		8BDA00010000000000000002 /* DomainDigWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
 		8BDB00010000000000000002 /* DomainDigShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDB00010000000000000001 /* DomainDigShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
 		8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; };
+		99024E91EED2275FD80E83A9 /* AppInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 499B99A1866999A38AC475F5 /* AppInfoTests.swift */; };
 		A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA789D3607B55E3612E6FFA /* SnapshotFixture.swift */; };
 		C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */; };
 		E959C4D24DAAB3CB80D854B2 /* DiffServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15A25DF2B8BB52589D49986B /* DiffServiceTests.swift */; };
@@ -77,6 +78,7 @@
 		0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAPIContractTests.swift; sourceTree = "<group>"; };
 		15A25DF2B8BB52589D49986B /* DiffServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DiffServiceTests.swift; sourceTree = "<group>"; };
 		435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DataMigrationServiceTests.swift; sourceTree = "<group>"; };
+		499B99A1866999A38AC475F5 /* AppInfoTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppInfoTests.swift; sourceTree = "<group>"; };
 		54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainReportExporterTests.swift; sourceTree = "<group>"; };
 		5CA789D3607B55E3612E6FFA /* SnapshotFixture.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnapshotFixture.swift; sourceTree = "<group>"; };
 		7572DA0A838E0A044F045260 /* LocalAPIContract.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAPIContract.swift; sourceTree = "<group>"; };
@@ -232,6 +234,7 @@
 				CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */,
 				0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */,
 				435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */,
+				499B99A1866999A38AC475F5 /* AppInfoTests.swift */,
 			);
 			name = DomainDigTests;
 			path = DomainDigTests;
@@ -491,6 +494,7 @@
 				A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */,
 				EA12012CDB4C21ABB4217D86 /* LocalAPIContractTests.swift in Sources */,
 				4F96CEB875EC3501E784CE39 /* DataMigrationServiceTests.swift in Sources */,
+				99024E91EED2275FD80E83A9 /* AppInfoTests.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
diff --git a/DomainDig/AppInfo.swift b/DomainDig/AppInfo.swift
new file mode 100644
index 0000000..28b0826
--- /dev/null
+++ b/DomainDig/AppInfo.swift
@@ -0,0 +1,52 @@
+import Foundation
+import UIKit
+
+/// Runtime app metadata read from the bundle, plus the locally-assembled
+/// diagnostics used to prefill an issue report. Nothing here touches the network
+/// or collects any identifier beyond the app version, iOS version, and hardware
+/// model string.
+enum AppInfo {
+    /// CFBundleShortVersionString (marketing version), falling back to the
+    /// compiled-in `AppVersion.current` if the Info.plist key is missing.
+    static var marketingVersion: String {
+        Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? AppVersion.current
+    }
+
+    /// CFBundleVersion (build number).
+    static var buildNumber: String {
+        Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "—"
+    }
+
+    /// "5.0.0 (build 45)" — version alongside build, as the App Info row shows it.
+    static var versionDisplay: String {
+        "\(marketingVersion) (build \(buildNumber))"
+    }
+
+    static let minimumOS = "17.6"
+
+    static var systemVersion: String {
+        UIDevice.current.systemVersion
+    }
+
+    /// Hardware model identifier (e.g. "iPhone15,2") — a model string, not a
+    /// per-device identifier.
+    static var deviceModel: String {
+        var systemInfo = utsname()
+        uname(&systemInfo)
+        let identifier = withUnsafeBytes(of: &systemInfo.machine) { raw -> String in
+            let bytes = raw.prefix { $0 != 0 }
+            return String(decoding: bytes, as: UTF8.self)
+        }
+        return identifier.isEmpty ? "Unknown" : identifier
+    }
+
+    /// Diagnostics prefilled into an issue report. Shown to the user before any
+    /// send — never collected silently.
+    static var diagnosticsReport: String {
+        """
+        DomainDig \(versionDisplay)
+        iOS \(systemVersion)
+        Device \(deviceModel)
+        """
+    }
+}
diff --git a/DomainDig/AppInfoView.swift b/DomainDig/AppInfoView.swift
new file mode 100644
index 0000000..749c066
--- /dev/null
+++ b/DomainDig/AppInfoView.swift
@@ -0,0 +1,254 @@
+import StoreKit
+import SwiftUI
+
+/// Settings → App Info. Shows app/build metadata and links out to docs, source,
+/// privacy, support, and the App Store. The actionable rows are driven by a
+/// single declarative `AppInfoRow` model so titles, icons, and destinations live
+/// in one place; only the Rate and Share rows are special-cased (a StoreKit
+/// action and a `ShareLink` view, respectively).
+struct AppInfoView: View {
+    @Environment(\.openURL) private var openURL
+    @Environment(\.requestReview) private var requestReview
+    @State private var cloudSyncService = CloudSyncService.shared
+    @State private var activeSheet: AppInfoSheet?
+
+    var body: some View {
+        Form {
+            Section("About") {
+                LabeledContent("Version", value: AppInfo.versionDisplay)
+                LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only")
+                LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)")
+                LabeledContent("Minimum iOS", value: AppInfo.minimumOS)
+            }
+
+            Section("Resources") {
+                ForEach(resourceRows) { row($0) }
+            }
+
+            Section("Support") {
+                ForEach(supportRows) { row($0) }
+            }
+
+            Section("Support the App") {
+                Button {
+                    requestReview()
+                } label: {
+                    Label("Rate DomainDig", systemImage: "star")
+                }
+
+                ShareLink(item: AppLinks.appStoreListing) {
+                    Label("Share DomainDig", systemImage: "square.and.arrow.up")
+                }
+                .accessibilityHint("Opens the share sheet")
+            }
+
+            Section {
+            } footer: {
+                Text(AppLinks.copyright)
+                    .frame(maxWidth: .infinity, alignment: .center)
+            }
+        }
+        .navigationTitle("App Info")
+        .task {
+            await cloudSyncService.refreshAvailability()
+        }
+        .sheet(item: $activeSheet) { sheet in
+            NavigationStack {
+                switch sheet {
+                case .whatsNew:
+                    WhatsNewView()
+                case .acknowledgements:
+                    AcknowledgementsView()
+                case .reportIssue:
+                    ReportIssueView()
+                }
+            }
+        }
+    }
+
+    private var resourceRows: [AppInfoRow] {
+        [
+            AppInfoRow(title: "What's New", systemImage: "sparkles", action: .sheet(.whatsNew)),
+            AppInfoRow(title: "Documentation & FAQ", systemImage: "book", action: .openURL(AppLinks.documentation)),
+            AppInfoRow(title: "Source Code", systemImage: "chevron.left.forwardslash.chevron.right", action: .openURL(AppLinks.sourceCode)),
+            AppInfoRow(title: "Privacy Policy", systemImage: "hand.raised", action: .openURL(AppLinks.privacyPolicy)),
+            AppInfoRow(title: "Acknowledgements", systemImage: "checkmark.seal", action: .sheet(.acknowledgements))
+        ]
+    }
+
+    private var supportRows: [AppInfoRow] {
+        [
+            AppInfoRow(title: "Report an Issue", systemImage: "ladybug", action: .sheet(.reportIssue)),
+            AppInfoRow(
+                title: "Contact",
+                systemImage: "envelope",
+                action: .mail(address: AppLinks.supportEmail, subject: "DomainDig", body: "")
+            )
+        ]
+    }
+
+    @ViewBuilder
+    private func row(_ item: AppInfoRow) -> some View {
+        switch item.action {
+        case .openURL(let url):
+            Link(destination: url) {
+                Label(item.title, systemImage: item.systemImage)
+            }
+            .accessibilityHint("Opens outside the app")
+        case .mail(let address, let subject, let body):
+            Button {
+                if let url = Self.mailURL(to: address, subject: subject, body: body) {
+                    openURL(url)
+                }
+            } label: {
+                Label(item.title, systemImage: item.systemImage)
+            }
+            .accessibilityHint("Opens your mail app")
+        case .sheet(let sheet):
+            Button {
+                activeSheet = sheet
+            } label: {
+                Label(item.title, systemImage: item.systemImage)
+            }
+        }
+    }
+
+    static func mailURL(to address: String, subject: String, body: String) -> URL? {
+        var components = URLComponents()
+        components.scheme = "mailto"
+        components.path = address
+        var query: [URLQueryItem] = []
+        if !subject.isEmpty { query.append(URLQueryItem(name: "subject", value: subject)) }
+        if !body.isEmpty { query.append(URLQueryItem(name: "body", value: body)) }
+        components.queryItems = query.isEmpty ? nil : query
+        return components.url
+    }
+}
+
+private struct AppInfoRow: Identifiable {
+    enum Action {
+        case openURL(URL)
+        case mail(address: String, subject: String, body: String)
+        case sheet(AppInfoSheet)
+    }
+
+    let id = UUID()
+    let title: String
+    let systemImage: String
+    let action: Action
+}
+
+private enum AppInfoSheet: String, Identifiable {
+    case whatsNew
+    case acknowledgements
+    case reportIssue
+
+    var id: String { rawValue }
+}
+
+// MARK: - Sheets
+
+private struct WhatsNewView: View {
+    @Environment(\.dismiss) private var dismiss
+    private let notes = ReleaseNotes.bundled()
+
+    var body: some View {
+        Form {
+            if let notes {
+                Section {
+                    ForEach(Array(notes.highlights.enumerated()), id: \.offset) { _, highlight in
+                        Label(highlight, systemImage: "sparkle")
+                            .labelStyle(.titleAndIcon)
+                    }
+                } header: {
+                    Text("Version \(notes.version)")
+                }
+            } else {
+                Section {
+                    Text("Release notes are unavailable.")
+                        .foregroundStyle(Color(.appTextSecondary))
+                }
+            }
+        }
+        .navigationTitle("What's New")
+        .navigationBarTitleDisplayMode(.inline)
+        .toolbar {
+            ToolbarItem(placement: .confirmationAction) {
+                Button("Done") { dismiss() }
+            }
+        }
+    }
+}
+
+private struct AcknowledgementsView: View {
+    @Environment(\.dismiss) private var dismiss
+
+    var body: some View {
+        Form {
+            Section("Dependencies") {
+                Text("DomainDig has no third-party dependencies. It is built entirely on Apple's frameworks.")
+            }
+            Section("License") {
+                Text("DomainDig is released under the MIT License.")
+                Text(AppLinks.copyright)
+                    .foregroundStyle(Color(.appTextSecondary))
+            }
+        }
+        .navigationTitle("Acknowledgements")
+        .navigationBarTitleDisplayMode(.inline)
+        .toolbar {
+            ToolbarItem(placement: .confirmationAction) {
+                Button("Done") { dismiss() }
+            }
+        }
+    }
+}
+
+private struct ReportIssueView: View {
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.openURL) private var openURL
+
+    private let diagnostics = AppInfo.diagnosticsReport
+
+    var body: some View {
+        Form {
+            Section {
+                Text("These details are included so issues can be reproduced. They are only added when you send a report — nothing is collected in the background.")
+                    .foregroundStyle(Color(.appTextSecondary))
+            }
+
+            Section("Included Diagnostics") {
+                Text(diagnostics)
+                    .font(.system(.footnote, design: .monospaced))
+                    .textSelection(.enabled)
+            }
+
+            Section {
+                Button {
+                    if let url = AppInfoView.mailURL(
+                        to: AppLinks.supportEmail,
+                        subject: "DomainDig Issue Report",
+                        body: "\n\n---\n\(diagnostics)"
+                    ) {
+                        openURL(url)
+                    }
+                } label: {
+                    Label("Email Report", systemImage: "envelope")
+                }
+                .accessibilityHint("Opens your mail app with the diagnostics prefilled")
+
+                Link(destination: AppLinks.sourceCode.appendingPathComponent("issues/new")) {
+                    Label("Open on GitHub", systemImage: "chevron.left.forwardslash.chevron.right")
+                }
+                .accessibilityHint("Opens outside the app")
+            }
+        }
+        .navigationTitle("Report an Issue")
+        .navigationBarTitleDisplayMode(.inline)
+        .toolbar {
+            ToolbarItem(placement: .cancellationAction) {
+                Button("Done") { dismiss() }
+            }
+        }
+    }
+}
diff --git a/DomainDig/AppLinks.swift b/DomainDig/AppLinks.swift
new file mode 100644
index 0000000..39c9036
--- /dev/null
+++ b/DomainDig/AppLinks.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+/// Centralized external destinations, kept in one place so URLs and the App Store
+/// identifier can be swapped without touching any view.
+///
+/// TODO(release): `appStoreID` is a placeholder until the App Store listing is
+/// live — the listing/review/share URLs derive from it. `documentation` points
+/// at the repository README for now; swap in a dedicated docs site if one lands.
+enum AppLinks {
+    /// App Store numeric identifier. Placeholder until the listing is published.
+    static let appStoreID = "0000000000"
+
+    static let sourceCode = url("https://github.com/zerolabsco/domain-dig")
+    static let documentation = url("https://github.com/zerolabsco/domain-dig#readme")
+    static let privacyPolicy = url("https://zerolabs.sh/domaindig/privacy-policy/")
+    static let supportEmail = "hello@zerolabs.sh"
+
+    static var appStoreListing: URL { url("https://apps.apple.com/app/id\(appStoreID)") }
+    static var writeReview: URL { url("https://apps.apple.com/app/id\(appStoreID)?action=write-review") }
+
+    static var copyright: String {
+        let year = Calendar.current.component(.year, from: Date())
+        return "© \(year) Christian Cleberg"
+    }
+
+    /// Builds URLs from developer-controlled literals; a malformed literal is a
+    /// programming error, surfaced loudly in development rather than force-unwrapped.
+    private static func url(_ string: String) -> URL {
+        guard let url = URL(string: string) else {
+            preconditionFailure("Invalid AppLinks URL literal: \(string)")
+        }
+        return url
+    }
+}
diff --git a/DomainDig/ReleaseNotes.json b/DomainDig/ReleaseNotes.json
new file mode 100644
index 0000000..99384f6
--- /dev/null
+++ b/DomainDig/ReleaseNotes.json
@@ -0,0 +1,9 @@
+{
+  "version": "5.0.0",
+  "highlights": [
+    "The Local API now has a documented, versioned \"v1\" response contract, so scripts and automation you build against it stay stable across updates.",
+    "A versioned migration system keeps your tracked domains, history, audits, and settings safe and correctly upgraded across app versions.",
+    "A large internal cleanup: the two biggest source files were decomposed into focused units behind a new unit-test safety net, for faster and safer future releases.",
+    "App Info now links to documentation, source, privacy, and support, and can prefill an issue report with your app and device versions."
+  ]
+}
diff --git a/DomainDig/ReleaseNotes.swift b/DomainDig/ReleaseNotes.swift
new file mode 100644
index 0000000..2cac883
--- /dev/null
+++ b/DomainDig/ReleaseNotes.swift
@@ -0,0 +1,17 @@
+import Foundation
+
+/// Release notes shown in the "What's New" sheet, loaded from the bundled
+/// `ReleaseNotes.json`. No network access — the content ships with the app.
+struct ReleaseNotes: Decodable {
+    let version: String
+    let highlights: [String]
+
+    static func bundled() -> ReleaseNotes? {
+        guard let url = Bundle.main.url(forResource: "ReleaseNotes", withExtension: "json"),
+              let data = try? Data(contentsOf: url),
+              let notes = try? JSONDecoder().decode(ReleaseNotes.self, from: data) else {
+            return nil
+        }
+        return notes
+    }
+}
diff --git a/DomainDig/SettingsViews.swift b/DomainDig/SettingsViews.swift
index a39639d..d93f6f9 100644
--- a/DomainDig/SettingsViews.swift
+++ b/DomainDig/SettingsViews.swift
@@ -105,7 +105,7 @@ struct SettingsView: View {
 
             Section("About") {
                 NavigationLink("App Info") {
-                    AboutSettingsView()
+                    AppInfoView()
                 }
             }
         }
@@ -1070,28 +1070,6 @@ private struct DataManagementSettingsView: View {
     }
 }
 
-private struct AboutSettingsView: View {
-    @State private var cloudSyncService = CloudSyncService.shared
-
-    private var appVersion: String {
-        AppVersion.current
-    }
-
-    var body: some View {
-        Form {
-            Section("About") {
-                LabeledContent("Version", value: appVersion)
-                LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only")
-                LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)")
-            }
-        }
-        .navigationTitle("App Info")
-        .task {
-            await cloudSyncService.refreshAvailability()
-        }
-    }
-}
-
 private struct DataImportPreviewSheet: View {
     @Environment(\.dismiss) private var dismiss
 
diff --git a/DomainDigTests/AppInfoTests.swift b/DomainDigTests/AppInfoTests.swift
new file mode 100644
index 0000000..5b574f7
--- /dev/null
+++ b/DomainDigTests/AppInfoTests.swift
@@ -0,0 +1,45 @@
+import XCTest
+@testable import DomainDig
+
+/// Tests for the App Info surface's deterministic logic: the mailto builder, the
+/// version display format, and that the bundled release notes ship and parse.
+final class AppInfoTests: XCTestCase {
+
+    func testMailURLEncodesSubjectAndBody() throws {
+        let url = try XCTUnwrap(AppInfoView.mailURL(
+            to: "hello@zerolabs.sh",
+            subject: "DomainDig Issue Report",
+            body: "line one\nline two"
+        ))
+
+        XCTAssertEqual(url.scheme, "mailto")
+        let string = url.absoluteString
+        XCTAssertTrue(string.hasPrefix("mailto:hello@zerolabs.sh?"))
+        XCTAssertTrue(string.contains("subject=DomainDig%20Issue%20Report"))
+        XCTAssertTrue(string.contains("body=line%20one%0Aline%20two"))
+    }
+
+    func testMailURLOmitsEmptyQueryItems() throws {
+        let url = try XCTUnwrap(AppInfoView.mailURL(to: "hello@zerolabs.sh", subject: "", body: ""))
+        XCTAssertEqual(url.absoluteString, "mailto:hello@zerolabs.sh")
+    }
+
+    func testVersionDisplayPairsMarketingVersionAndBuild() {
+        XCTAssertEqual(AppInfo.versionDisplay, "\(AppInfo.marketingVersion) (build \(AppInfo.buildNumber))")
+    }
+
+    func testDiagnosticsReportContainsVersionOSAndModelOnly() {
+        let report = AppInfo.diagnosticsReport
+        XCTAssertTrue(report.contains(AppInfo.marketingVersion))
+        XCTAssertTrue(report.contains(AppInfo.systemVersion))
+        XCTAssertTrue(report.contains(AppInfo.deviceModel))
+        // Three lines, nothing more — no identifiers beyond version/OS/model.
+        XCTAssertEqual(report.split(separator: "\n").count, 3)
+    }
+
+    func testBundledReleaseNotesShipAndParse() throws {
+        let notes = try XCTUnwrap(ReleaseNotes.bundled(), "ReleaseNotes.json must be bundled and decodable")
+        XCTAssertFalse(notes.version.isEmpty)
+        XCTAssertFalse(notes.highlights.isEmpty)
+    }
+}