summaryrefslogtreecommitdiff
path: root/DomainDig
diff options
context:
space:
mode:
Diffstat (limited to 'DomainDig')
-rw-r--r--DomainDig/AppInfo.swift52
-rw-r--r--DomainDig/AppInfoView.swift254
-rw-r--r--DomainDig/AppLinks.swift34
-rw-r--r--DomainDig/ReleaseNotes.json9
-rw-r--r--DomainDig/ReleaseNotes.swift17
-rw-r--r--DomainDig/SettingsViews.swift24
6 files changed, 367 insertions, 23 deletions
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 = "[email protected]"
+
+ 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