Commit 0740527636

074052763638a36e274e18d00f5f5ab21846be28

parent: b824dae8ea

Unsigned

cmc <hello@cleberg.net> · 2026-07-25 16:35 UTC

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.

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +4
@@ -21,6 +21,7 @@
21 8BDA00010000000000000002 /* DomainDigWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 21 8BDA00010000000000000002 /* DomainDigWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
22 8BDB00010000000000000002 /* DomainDigShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDB00010000000000000001 /* DomainDigShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 22 8BDB00010000000000000002 /* DomainDigShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDB00010000000000000001 /* DomainDigShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
23 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; }; 23 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; };
24 99024E91EED2275FD80E83A9 /* AppInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 499B99A1866999A38AC475F5 /* AppInfoTests.swift */; };
24 A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA789D3607B55E3612E6FFA /* SnapshotFixture.swift */; }; 25 A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA789D3607B55E3612E6FFA /* SnapshotFixture.swift */; };
25 C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */; }; 26 C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */; };
26 E959C4D24DAAB3CB80D854B2 /* DiffServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15A25DF2B8BB52589D49986B /* DiffServiceTests.swift */; }; 27 E959C4D24DAAB3CB80D854B2 /* DiffServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15A25DF2B8BB52589D49986B /* DiffServiceTests.swift */; };
@@ -77,6 +78,7 @@
77 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAPIContractTests.swift; sourceTree = "<group>"; }; 78 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAPIContractTests.swift; sourceTree = "<group>"; };
78 15A25DF2B8BB52589D49986B /* DiffServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DiffServiceTests.swift; sourceTree = "<group>"; }; 79 15A25DF2B8BB52589D49986B /* DiffServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DiffServiceTests.swift; sourceTree = "<group>"; };
79 435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DataMigrationServiceTests.swift; sourceTree = "<group>"; }; 80 435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DataMigrationServiceTests.swift; sourceTree = "<group>"; };
81 499B99A1866999A38AC475F5 /* AppInfoTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppInfoTests.swift; sourceTree = "<group>"; };
80 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainReportExporterTests.swift; sourceTree = "<group>"; }; 82 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainReportExporterTests.swift; sourceTree = "<group>"; };
81 5CA789D3607B55E3612E6FFA /* SnapshotFixture.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnapshotFixture.swift; sourceTree = "<group>"; }; 83 5CA789D3607B55E3612E6FFA /* SnapshotFixture.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnapshotFixture.swift; sourceTree = "<group>"; };
82 7572DA0A838E0A044F045260 /* LocalAPIContract.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAPIContract.swift; sourceTree = "<group>"; }; 84 7572DA0A838E0A044F045260 /* LocalAPIContract.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAPIContract.swift; sourceTree = "<group>"; };
@@ -232,6 +234,7 @@
232 CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */, 234 CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */,
233 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */, 235 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */,
234 435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */, 236 435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */,
237 499B99A1866999A38AC475F5 /* AppInfoTests.swift */,
235 ); 238 );
236 name = DomainDigTests; 239 name = DomainDigTests;
237 path = DomainDigTests; 240 path = DomainDigTests;
@@ -491,6 +494,7 @@
491 A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */, 494 A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */,
492 EA12012CDB4C21ABB4217D86 /* LocalAPIContractTests.swift in Sources */, 495 EA12012CDB4C21ABB4217D86 /* LocalAPIContractTests.swift in Sources */,
493 4F96CEB875EC3501E784CE39 /* DataMigrationServiceTests.swift in Sources */, 496 4F96CEB875EC3501E784CE39 /* DataMigrationServiceTests.swift in Sources */,
497 99024E91EED2275FD80E83A9 /* AppInfoTests.swift in Sources */,
494 ); 498 );
495 runOnlyForDeploymentPostprocessing = 0; 499 runOnlyForDeploymentPostprocessing = 0;
496 }; 500 };
DomainDig/AppInfo.swift added +52
@@ -0,0 +1,52 @@
1import Foundation
2import UIKit
3
4/// Runtime app metadata read from the bundle, plus the locally-assembled
5/// diagnostics used to prefill an issue report. Nothing here touches the network
6/// or collects any identifier beyond the app version, iOS version, and hardware
7/// model string.
8enum AppInfo {
9 /// CFBundleShortVersionString (marketing version), falling back to the
10 /// compiled-in `AppVersion.current` if the Info.plist key is missing.
11 static var marketingVersion: String {
12 Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? AppVersion.current
13 }
14
15 /// CFBundleVersion (build number).
16 static var buildNumber: String {
17 Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "—"
18 }
19
20 /// "5.0.0 (build 45)" — version alongside build, as the App Info row shows it.
21 static var versionDisplay: String {
22 "\(marketingVersion) (build \(buildNumber))"
23 }
24
25 static let minimumOS = "17.6"
26
27 static var systemVersion: String {
28 UIDevice.current.systemVersion
29 }
30
31 /// Hardware model identifier (e.g. "iPhone15,2") — a model string, not a
32 /// per-device identifier.
33 static var deviceModel: String {
34 var systemInfo = utsname()
35 uname(&systemInfo)
36 let identifier = withUnsafeBytes(of: &systemInfo.machine) { raw -> String in
37 let bytes = raw.prefix { $0 != 0 }
38 return String(decoding: bytes, as: UTF8.self)
39 }
40 return identifier.isEmpty ? "Unknown" : identifier
41 }
42
43 /// Diagnostics prefilled into an issue report. Shown to the user before any
44 /// send — never collected silently.
45 static var diagnosticsReport: String {
46 """
47 DomainDig \(versionDisplay)
48 iOS \(systemVersion)
49 Device \(deviceModel)
50 """
51 }
52}
DomainDig/AppInfoView.swift added +254
@@ -0,0 +1,254 @@
1import StoreKit
2import SwiftUI
3
4/// Settings → App Info. Shows app/build metadata and links out to docs, source,
5/// privacy, support, and the App Store. The actionable rows are driven by a
6/// single declarative `AppInfoRow` model so titles, icons, and destinations live
7/// in one place; only the Rate and Share rows are special-cased (a StoreKit
8/// action and a `ShareLink` view, respectively).
9struct AppInfoView: View {
10 @Environment(\.openURL) private var openURL
11 @Environment(\.requestReview) private var requestReview
12 @State private var cloudSyncService = CloudSyncService.shared
13 @State private var activeSheet: AppInfoSheet?
14
15 var body: some View {
16 Form {
17 Section("About") {
18 LabeledContent("Version", value: AppInfo.versionDisplay)
19 LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only")
20 LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)")
21 LabeledContent("Minimum iOS", value: AppInfo.minimumOS)
22 }
23
24 Section("Resources") {
25 ForEach(resourceRows) { row($0) }
26 }
27
28 Section("Support") {
29 ForEach(supportRows) { row($0) }
30 }
31
32 Section("Support the App") {
33 Button {
34 requestReview()
35 } label: {
36 Label("Rate DomainDig", systemImage: "star")
37 }
38
39 ShareLink(item: AppLinks.appStoreListing) {
40 Label("Share DomainDig", systemImage: "square.and.arrow.up")
41 }
42 .accessibilityHint("Opens the share sheet")
43 }
44
45 Section {
46 } footer: {
47 Text(AppLinks.copyright)
48 .frame(maxWidth: .infinity, alignment: .center)
49 }
50 }
51 .navigationTitle("App Info")
52 .task {
53 await cloudSyncService.refreshAvailability()
54 }
55 .sheet(item: $activeSheet) { sheet in
56 NavigationStack {
57 switch sheet {
58 case .whatsNew:
59 WhatsNewView()
60 case .acknowledgements:
61 AcknowledgementsView()
62 case .reportIssue:
63 ReportIssueView()
64 }
65 }
66 }
67 }
68
69 private var resourceRows: [AppInfoRow] {
70 [
71 AppInfoRow(title: "What's New", systemImage: "sparkles", action: .sheet(.whatsNew)),
72 AppInfoRow(title: "Documentation & FAQ", systemImage: "book", action: .openURL(AppLinks.documentation)),
73 AppInfoRow(title: "Source Code", systemImage: "chevron.left.forwardslash.chevron.right", action: .openURL(AppLinks.sourceCode)),
74 AppInfoRow(title: "Privacy Policy", systemImage: "hand.raised", action: .openURL(AppLinks.privacyPolicy)),
75 AppInfoRow(title: "Acknowledgements", systemImage: "checkmark.seal", action: .sheet(.acknowledgements))
76 ]
77 }
78
79 private var supportRows: [AppInfoRow] {
80 [
81 AppInfoRow(title: "Report an Issue", systemImage: "ladybug", action: .sheet(.reportIssue)),
82 AppInfoRow(
83 title: "Contact",
84 systemImage: "envelope",
85 action: .mail(address: AppLinks.supportEmail, subject: "DomainDig", body: "")
86 )
87 ]
88 }
89
90 @ViewBuilder
91 private func row(_ item: AppInfoRow) -> some View {
92 switch item.action {
93 case .openURL(let url):
94 Link(destination: url) {
95 Label(item.title, systemImage: item.systemImage)
96 }
97 .accessibilityHint("Opens outside the app")
98 case .mail(let address, let subject, let body):
99 Button {
100 if let url = Self.mailURL(to: address, subject: subject, body: body) {
101 openURL(url)
102 }
103 } label: {
104 Label(item.title, systemImage: item.systemImage)
105 }
106 .accessibilityHint("Opens your mail app")
107 case .sheet(let sheet):
108 Button {
109 activeSheet = sheet
110 } label: {
111 Label(item.title, systemImage: item.systemImage)
112 }
113 }
114 }
115
116 static func mailURL(to address: String, subject: String, body: String) -> URL? {
117 var components = URLComponents()
118 components.scheme = "mailto"
119 components.path = address
120 var query: [URLQueryItem] = []
121 if !subject.isEmpty { query.append(URLQueryItem(name: "subject", value: subject)) }
122 if !body.isEmpty { query.append(URLQueryItem(name: "body", value: body)) }
123 components.queryItems = query.isEmpty ? nil : query
124 return components.url
125 }
126}
127
128private struct AppInfoRow: Identifiable {
129 enum Action {
130 case openURL(URL)
131 case mail(address: String, subject: String, body: String)
132 case sheet(AppInfoSheet)
133 }
134
135 let id = UUID()
136 let title: String
137 let systemImage: String
138 let action: Action
139}
140
141private enum AppInfoSheet: String, Identifiable {
142 case whatsNew
143 case acknowledgements
144 case reportIssue
145
146 var id: String { rawValue }
147}
148
149// MARK: - Sheets
150
151private struct WhatsNewView: View {
152 @Environment(\.dismiss) private var dismiss
153 private let notes = ReleaseNotes.bundled()
154
155 var body: some View {
156 Form {
157 if let notes {
158 Section {
159 ForEach(Array(notes.highlights.enumerated()), id: \.offset) { _, highlight in
160 Label(highlight, systemImage: "sparkle")
161 .labelStyle(.titleAndIcon)
162 }
163 } header: {
164 Text("Version \(notes.version)")
165 }
166 } else {
167 Section {
168 Text("Release notes are unavailable.")
169 .foregroundStyle(Color(.appTextSecondary))
170 }
171 }
172 }
173 .navigationTitle("What's New")
174 .navigationBarTitleDisplayMode(.inline)
175 .toolbar {
176 ToolbarItem(placement: .confirmationAction) {
177 Button("Done") { dismiss() }
178 }
179 }
180 }
181}
182
183private struct AcknowledgementsView: View {
184 @Environment(\.dismiss) private var dismiss
185
186 var body: some View {
187 Form {
188 Section("Dependencies") {
189 Text("DomainDig has no third-party dependencies. It is built entirely on Apple's frameworks.")
190 }
191 Section("License") {
192 Text("DomainDig is released under the MIT License.")
193 Text(AppLinks.copyright)
194 .foregroundStyle(Color(.appTextSecondary))
195 }
196 }
197 .navigationTitle("Acknowledgements")
198 .navigationBarTitleDisplayMode(.inline)
199 .toolbar {
200 ToolbarItem(placement: .confirmationAction) {
201 Button("Done") { dismiss() }
202 }
203 }
204 }
205}
206
207private struct ReportIssueView: View {
208 @Environment(\.dismiss) private var dismiss
209 @Environment(\.openURL) private var openURL
210
211 private let diagnostics = AppInfo.diagnosticsReport
212
213 var body: some View {
214 Form {
215 Section {
216 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.")
217 .foregroundStyle(Color(.appTextSecondary))
218 }
219
220 Section("Included Diagnostics") {
221 Text(diagnostics)
222 .font(.system(.footnote, design: .monospaced))
223 .textSelection(.enabled)
224 }
225
226 Section {
227 Button {
228 if let url = AppInfoView.mailURL(
229 to: AppLinks.supportEmail,
230 subject: "DomainDig Issue Report",
231 body: "\n\n---\n\(diagnostics)"
232 ) {
233 openURL(url)
234 }
235 } label: {
236 Label("Email Report", systemImage: "envelope")
237 }
238 .accessibilityHint("Opens your mail app with the diagnostics prefilled")
239
240 Link(destination: AppLinks.sourceCode.appendingPathComponent("issues/new")) {
241 Label("Open on GitHub", systemImage: "chevron.left.forwardslash.chevron.right")
242 }
243 .accessibilityHint("Opens outside the app")
244 }
245 }
246 .navigationTitle("Report an Issue")
247 .navigationBarTitleDisplayMode(.inline)
248 .toolbar {
249 ToolbarItem(placement: .cancellationAction) {
250 Button("Done") { dismiss() }
251 }
252 }
253 }
254}
DomainDig/AppLinks.swift added +34
@@ -0,0 +1,34 @@
1import Foundation
2
3/// Centralized external destinations, kept in one place so URLs and the App Store
4/// identifier can be swapped without touching any view.
5///
6/// TODO(release): `appStoreID` is a placeholder until the App Store listing is
7/// live — the listing/review/share URLs derive from it. `documentation` points
8/// at the repository README for now; swap in a dedicated docs site if one lands.
9enum AppLinks {
10 /// App Store numeric identifier. Placeholder until the listing is published.
11 static let appStoreID = "0000000000"
12
13 static let sourceCode = url("https://github.com/zerolabsco/domain-dig")
14 static let documentation = url("https://github.com/zerolabsco/domain-dig#readme")
15 static let privacyPolicy = url("https://zerolabs.sh/domaindig/privacy-policy/")
16 static let supportEmail = "hello@zerolabs.sh"
17
18 static var appStoreListing: URL { url("https://apps.apple.com/app/id\(appStoreID)") }
19 static var writeReview: URL { url("https://apps.apple.com/app/id\(appStoreID)?action=write-review") }
20
21 static var copyright: String {
22 let year = Calendar.current.component(.year, from: Date())
23 return "© \(year) Christian Cleberg"
24 }
25
26 /// Builds URLs from developer-controlled literals; a malformed literal is a
27 /// programming error, surfaced loudly in development rather than force-unwrapped.
28 private static func url(_ string: String) -> URL {
29 guard let url = URL(string: string) else {
30 preconditionFailure("Invalid AppLinks URL literal: \(string)")
31 }
32 return url
33 }
34}
DomainDig/ReleaseNotes.json added +9
@@ -0,0 +1,9 @@
1{
2 "version": "5.0.0",
3 "highlights": [
4 "The Local API now has a documented, versioned \"v1\" response contract, so scripts and automation you build against it stay stable across updates.",
5 "A versioned migration system keeps your tracked domains, history, audits, and settings safe and correctly upgraded across app versions.",
6 "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.",
7 "App Info now links to documentation, source, privacy, and support, and can prefill an issue report with your app and device versions."
8 ]
9}
DomainDig/ReleaseNotes.swift added +17
@@ -0,0 +1,17 @@
1import Foundation
2
3/// Release notes shown in the "What's New" sheet, loaded from the bundled
4/// `ReleaseNotes.json`. No network access — the content ships with the app.
5struct ReleaseNotes: Decodable {
6 let version: String
7 let highlights: [String]
8
9 static func bundled() -> ReleaseNotes? {
10 guard let url = Bundle.main.url(forResource: "ReleaseNotes", withExtension: "json"),
11 let data = try? Data(contentsOf: url),
12 let notes = try? JSONDecoder().decode(ReleaseNotes.self, from: data) else {
13 return nil
14 }
15 return notes
16 }
17}
DomainDig/SettingsViews.swift +1 −23
@@ -105,7 +105,7 @@ struct SettingsView: View {
105 105
106 Section("About") { 106 Section("About") {
107 NavigationLink("App Info") { 107 NavigationLink("App Info") {
108 AboutSettingsView() 108 AppInfoView()
109 } 109 }
110 } 110 }
111 } 111 }
@@ -1070,28 +1070,6 @@ private struct DataManagementSettingsView: View {
1070 } 1070 }
1071} 1071}
1072 1072
1073private struct AboutSettingsView: View {
1074 @State private var cloudSyncService = CloudSyncService.shared
1075
1076 private var appVersion: String {
1077 AppVersion.current
1078 }
1079
1080 var body: some View {
1081 Form {
1082 Section("About") {
1083 LabeledContent("Version", value: appVersion)
1084 LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only")
1085 LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)")
1086 }
1087 }
1088 .navigationTitle("App Info")
1089 .task {
1090 await cloudSyncService.refreshAvailability()
1091 }
1092 }
1093}
1094
1095private struct DataImportPreviewSheet: View { 1073private struct DataImportPreviewSheet: View {
1096 @Environment(\.dismiss) private var dismiss 1074 @Environment(\.dismiss) private var dismiss
1097 1075
DomainDigTests/AppInfoTests.swift added +45
@@ -0,0 +1,45 @@
1import XCTest
2@testable import DomainDig
3
4/// Tests for the App Info surface's deterministic logic: the mailto builder, the
5/// version display format, and that the bundled release notes ship and parse.
6final class AppInfoTests: XCTestCase {
7
8 func testMailURLEncodesSubjectAndBody() throws {
9 let url = try XCTUnwrap(AppInfoView.mailURL(
10 to: "hello@zerolabs.sh",
11 subject: "DomainDig Issue Report",
12 body: "line one\nline two"
13 ))
14
15 XCTAssertEqual(url.scheme, "mailto")
16 let string = url.absoluteString
17 XCTAssertTrue(string.hasPrefix("mailto:hello@zerolabs.sh?"))
18 XCTAssertTrue(string.contains("subject=DomainDig%20Issue%20Report"))
19 XCTAssertTrue(string.contains("body=line%20one%0Aline%20two"))
20 }
21
22 func testMailURLOmitsEmptyQueryItems() throws {
23 let url = try XCTUnwrap(AppInfoView.mailURL(to: "hello@zerolabs.sh", subject: "", body: ""))
24 XCTAssertEqual(url.absoluteString, "mailto:hello@zerolabs.sh")
25 }
26
27 func testVersionDisplayPairsMarketingVersionAndBuild() {
28 XCTAssertEqual(AppInfo.versionDisplay, "\(AppInfo.marketingVersion) (build \(AppInfo.buildNumber))")
29 }
30
31 func testDiagnosticsReportContainsVersionOSAndModelOnly() {
32 let report = AppInfo.diagnosticsReport
33 XCTAssertTrue(report.contains(AppInfo.marketingVersion))
34 XCTAssertTrue(report.contains(AppInfo.systemVersion))
35 XCTAssertTrue(report.contains(AppInfo.deviceModel))
36 // Three lines, nothing more — no identifiers beyond version/OS/model.
37 XCTAssertEqual(report.split(separator: "\n").count, 3)
38 }
39
40 func testBundledReleaseNotesShipAndParse() throws {
41 let notes = try XCTUnwrap(ReleaseNotes.bundled(), "ReleaseNotes.json must be bundled and decodable")
42 XCTAssertFalse(notes.version.isEmpty)
43 XCTAssertFalse(notes.highlights.isEmpty)
44 }
45}