Commit 0740527636
Unsigned
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 @@ | |||
| 1 | import Foundation | ||
| 2 | import 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. | ||
| 8 | enum 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 @@ | |||
| 1 | import StoreKit | ||
| 2 | import 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). | ||
| 9 | struct 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 | |||
| 128 | private 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 | |||
| 141 | private 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 | |||
| 151 | private 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 | |||
| 183 | private 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 | |||
| 207 | private 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 @@ | |||
| 1 | import 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. | ||
| 9 | enum 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 @@ | |||
| 1 | import 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. | ||
| 5 | struct 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 | ||
| 1073 | private 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 | |||
| 1095 | private struct DataImportPreviewSheet: View { | 1073 | private 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 @@ | |||
| 1 | import 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. | ||
| 6 | final 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 | } | ||