From fe83ff502ffcaa59c7a22471c634d565972e7644 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sun, 2 Aug 2026 15:29:31 -0500 Subject: convert readme to nfo; convert docs to txt; relicense to 0bsd --- Docs/ACCESSIBILITY.md | 313 ------------------------------------------- Docs/ACCESSIBILITY.txt | 313 +++++++++++++++++++++++++++++++++++++++++++ Docs/ARCHITECTURE.md | 102 -------------- Docs/ARCHITECTURE.txt | 102 ++++++++++++++ Docs/data-migration.md | 94 ------------- Docs/data-migration.txt | 94 +++++++++++++ Docs/local-api.md | 113 ---------------- Docs/local-api.txt | 113 ++++++++++++++++ LICENSE | 33 ++--- README.md | 108 --------------- README.nfo | 74 +++++++++++ RELEASE_ROADMAP.md | 346 ------------------------------------------------ RELEASE_ROADMAP.txt | 346 ++++++++++++++++++++++++++++++++++++++++++++++++ SECURITY.md | 33 ----- SECURITY.txt | 33 +++++ 15 files changed, 1087 insertions(+), 1130 deletions(-) delete mode 100644 Docs/ACCESSIBILITY.md create mode 100644 Docs/ACCESSIBILITY.txt delete mode 100644 Docs/ARCHITECTURE.md create mode 100644 Docs/ARCHITECTURE.txt delete mode 100644 Docs/data-migration.md create mode 100644 Docs/data-migration.txt delete mode 100644 Docs/local-api.md create mode 100644 Docs/local-api.txt delete mode 100644 README.md create mode 100644 README.nfo delete mode 100644 RELEASE_ROADMAP.md create mode 100644 RELEASE_ROADMAP.txt delete mode 100644 SECURITY.md create mode 100644 SECURITY.txt diff --git a/Docs/ACCESSIBILITY.md b/Docs/ACCESSIBILITY.md deleted file mode 100644 index 6e82055..0000000 --- a/Docs/ACCESSIBILITY.md +++ /dev/null @@ -1,313 +0,0 @@ -# Accessibility Audit - -`DomainDigUITests` runs Apple's `performAccessibilityAudit()` across every -primary screen. The audit checks contrast, hit-region size, clipped text at -large Dynamic Type, element descriptions, trait correctness, and Dynamic Type -support — the same ground the accessibility pass tracked in -[issue #21](https://github.com/krazywarez/domain-dig/issues/21) covers. - -## The colour palette - -Semantic colours live in `Shared/Colors.xcassets`, which is inside the `Shared` -file-system-synchronized group and therefore reaches the app, the widget, and -the share extension automatically. `AccentColor` stays in -`DomainDig/Assets.xcassets` because it is the system-wide tint resolved via -`ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME`. - -Use the generated asset symbols — `Color(.statusCritical)`, `Color(.appSurface)` -— never a literal. `ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS` -is on, so these are compile-time checked; a typo will not build. - -Every value clears WCAG AA (4.5:1) as text on its page, on its card, **and on -its own 16% badge tint** — the way `AppStatusBadgeView` actually draws it. The -worst of those three is shown: - -| Role | Light | Dark | Worst light | Worst dark | -| --- | --- | --- | --- | --- | -| `StatusInfo` / `AccentColor` | `#0000FF` | `#4DA3FF` | 6.76 | 6.47 | -| `StatusPositive` | `#008035` | `#30D158` | 4.54 | 7.62 | -| `StatusWarning` | `#AD5100` | `#FF9F0A` | 4.59 | 7.76 | -| `StatusCritical` | `#CC0700` | `#FF6961` | 4.68 | 6.12 | -| `StatusNeutral` | `#5A5A5F` | `#A1A1A6` | 5.84 | 6.76 | - -Each status foreground has a matching `…Surface` colour for the fill behind it, -paired through `AppStatusTone`. - -### Contrast alone is not a palette - -The first version of this palette maximised contrast and produced mud. Requiring -every foreground to clear 4.5:1 against *its own 16% tint* — the harshest -surface it ever sits on — pushed each colour ~20% darker than the common case -needed. `#7A5600` is not amber, it is olive; `#146C2E` is not green so much as -bottle-dark. Contrast passed and the UI was still hard to read, because hue -identity is what tells "warning" from "critical" at a glance. - -Two fixes: - -1. **Decouple the fill from the foreground.** `AppStatusTone` carries a - `foreground` and a `surface` that are authored independently, so the - foreground no longer has to survive a wash of itself. Every status foreground - is now fully saturated (`S = 1.0`). -2. **Warning is orange, not yellow.** Yellow cannot stay yellow at a lightness - low enough to clear 4.5:1 on white — it *becomes* olive. That is - colorimetric, not a tuning problem. Orange holds its identity when darkened, - so warning is `#AD5100` in light and `#FF9F0A` in dark. - -When adding a colour, search for the most saturated value that passes, not the -darkest. The darkest is always easy and always wrong. -| `AppTextSecondary` | `#5A5A5F` | `#A1A1A6` | 6.15 | 7.50 | - -`AppTextSecondary` replaces `.secondary` for body text. iOS's own `secondaryLabel` -is only **3.29:1** on a light card — below AA — which never showed while the app -was locked to dark, where the same colour reads 6.32:1. Unlocking light mode -exposed it across 191 sites. - -High Contrast variants push further in the same direction. Surfaces -(`AppBackground`, `AppSurface`, `AppSurfaceElevated`, `AppSeparator`) carry no -meaning, so they get Any/Dark and, where useful, High Contrast — but no status -semantics. - -Why custom values instead of the system palette: **every** system colour fails -in light mode. Measured on white — systemYellow 1.51:1, systemOrange 2.20:1, -systemGreen 2.22:1, systemCyan 2.54:1, systemRed 3.55:1. All of them pass in -dark mode, which is why the dark-locked app looked fine and why unlocking light -mode is impossible without this work. - -### The accent has two roles, and they conflict - -An accent used as **text on a dark background** must be light. The same accent -used as a **fill behind a white label** must be dark. One value cannot do both: -`#4DA3FF` reads at 8.00:1 as text on black, but only 2.63:1 behind white text. - -So there are two colours: - -- `StatusInfo` / `AccentColor` — the accent as *foreground*: text, icons, - bordered-button labels, tab bar. -- `AccentFill` — the accent as a *filled background* behind a label, used by - `.borderedProminent`. Stays dark in both schemes so a white label clears AA - (8.59:1 light, 7.56:1 dark). - -`AppOnAccent` is the label colour for a solid accent fill and flips by scheme — -white on the light accent, black on the dark one. - -## Appearance - -`AppAppearance` (System / Light / Dark) is stored in `@AppStorage` and applied in -**exactly one place** — the `WindowGroup` in `DomainDigApp`. Keep it that way. The -app previously carried 16 separate `.preferredColorScheme(.dark)` calls scattered -through view bodies, which is how light mode became unreachable without anyone -noticing; re-applying per view is what let the lock spread. - -Users override it under Settings → Display. - -### Known light-mode findings - -| Finding | Cause | Action | -| --- | --- | --- | -| 2× `contrast failed` on Settings | The last rows of a section sit under the translucent tab bar, so the audit measures text against a blended background. Present in dark mode too, since phase 0. | None — standard iOS scroll-under behaviour | -| 3× `contrast nearly passed` on Settings | iOS-rendered `Section` headers (`TIER`, `PREFERENCES`, `SERVICES`) use the system's grey. | Not fixed. Overriding system header styling across every section to gain ~0.3:1 on decorative labels trades platform convention for very little | - -Dark mode reports 18 findings and light mode 21; the three extra are the section -headers above. Everything the app actually controls passes in both schemes. - -## Enforcement — the ratchet is engaged - -With phases 1–5 landed, `AccessibilityAuditHarness.enforcedAuditTypes` enforces -**`.textClipped`, `.dynamicType`, `.hitRegion`, `.elementDetection`, -`.sufficientElementDescription`, `.trait`** on the empty-state test suite. A -named finding in any of these fails CI — regressions in five phases of work are -now gated, not merely reported. - -Three deliberate carve-outs, each with its evidence: - -1. **`.contrast` stays report-only.** The two long-standing Settings findings - are rows scrolled under the translucent tab bar; their attribution flips - between a row name and nil run-to-run, so no suppression is narrow enough to - keep CI stable. The centralised palette in `Shared/Colors.xcassets` is the - actual guard against contrast regressions. -2. **The seeded tests run `reportOnly`.** Bisecting the row/badge accessibility - modifiers showed the audit degrades on `children: .ignore` content — the - *correct* VoiceOver treatment for dense rows — emitting unattributed - contrast/dynamicType failures on rows that measure 6–7:1 and render - correctly. Their burndown still prints; it just doesn't gate. -3. **Characterised noise is suppressed narrowly and always logged** with a - `[noise: reason]` marker — disabled controls (WCAG 1.4.3 exempt), "nearly - passed" near-misses, system field placeholders (clipped at any length — - proven by shortening them to no effect), and unattributed - clipped/dynamic-type artifacts. Nothing disappears silently; see - `noiseReason(for:)` for each rule's provenance. - -Enforcement is a committed constant rather than a CI setting, for two reasons. -Environment variables do not work: neither a plain `xcodebuild` env var nor a -`TEST_RUNNER_`-prefixed build setting reaches the UI test process, so the toggle -silently did nothing. And a committed value makes "when did clipping become -enforced?" answerable with `git blame` instead of CI tribal knowledge. - -## Why coverage is split between local and CI - -**Audit coverage is not nested across OS versions.** Each runtime reports -findings the others miss, in *both* directions. Measured on this project: - -| Screen | iOS 18.6 | iOS 27.0 | -| --- | --- | --- | -| Tracked Domains | 2 (text clipped) | **6** (+ contrast ×3, element detection) | -| Settings | 2 contrast | **`dynamicType`** finding 18.6 missed | -| Dashboard @ `AccessibilityXXXL` | **hit region** + 2 clipped | 1 clipped only | - -Neither runtime is a superset, so the oldest supported OS needs its own run. -This also rules out committing per-screen baseline counts as a regression guard: -no single number is correct on both. - -The catch is that **GitHub's `macos-26` image ships only iOS 26.x simulator -runtimes.** It cannot test the 17.6 floor at all. A two-job CI matrix was tried -and produced two near-identical 26.x runs at double the macOS minutes. - -So the work is split by what each side can uniquely do: - -| | Runtime | Uniquely provides | -| --- | --- | --- | -| **CI** (`.github/workflows/build.yml`) | newest available | A clean checkout of the merge result — catches a file that was never committed, which a local run cannot. Matters here because `DomainDig.xcodeproj` is hand-edited and uses file-system-synchronized groups, where a whole missing folder still builds locally. | -| **Local** (`Scripts/audit-a11y.sh`) | oldest supported + newest | Real floor coverage, on a machine that actually has an 18.x runtime installed. | - -Together they cover both ends; neither duplicates the other. - -## Running it - -```sh -./Scripts/audit-a11y.sh # floor + current -./Scripts/audit-a11y.sh floor # oldest supported only (~85s) -./Scripts/audit-a11y.sh current # newest installed only -``` - -The script reads the deployment target from the project rather than hard-coding -it, and selects the oldest installed runtime **at or above** it — a runtime -below the deployment target is useless, because the app cannot install there. -If the nearest installed runtime is a major version above the target, it says -so rather than implying floor coverage it does not have. - -### Pre-push hook - -```sh -git config core.hooksPath .githooks -``` - -Runs the floor audit before a push, and only when Swift, asset, or project files -changed. Bypass with `git push --no-verify`. - -Pre-push rather than pre-commit deliberately: the suite takes ~85s, and at -pre-commit that blocks every commit. A hook routinely bypassed with -`--no-verify` is worse than no hook, because it trains you to ignore it. - -## Layout gotchas found the hard way - -- **`Label` clips its own title.** Every empty-state heading reported as clipped - text. `.fixedSize` applied to the `Label` does not reach the `Text` inside it, - so the fix is to split it into an `HStack { Image; Text }` and put the modifier - on the `Text`. Changing the font design did **not** help — that hypothesis was - tested and discarded. -- **Splitting a `Label` exposes its icon to VoiceOver.** `Label` folds the image - into the title's accessibility element; an `HStack` does not, so the icon - starts announcing its raw SF Symbol name ("checklist.unchecked"). Decorative - icons split out of a `Label` need `.accessibilityHidden(true)`. -- **Placeholder text is always reported as clipped.** Search prompts and - `TextField` placeholders are flagged regardless of length — shortening - "Search portfolio" to "Search" changed nothing. Treat `textClipped` findings on - a `searchField` or `textField` element as noise rather than shortening useful - prompts to chase them. -- **`AppLayout.minimumTapTarget` is the floor for every control.** `@ScaledMetric` - scales *down* below the default text size as well as up, so a scaled dimension - needs `max(scaled, AppLayout.minimumTapTarget)` or it drops under 44pt for - users who prefer smaller text. - -## VoiceOver conventions - -- **Dense rows use combine-for-summary, custom-content-for-detail.** - `BatchResultRowView` and `WatchlistRowView` each hold 8–9 text elements. - Reading them inline makes a long sweep unnavigable, so each row is one element: - `.accessibilityElement(children: .ignore)` + domain label + status value, with - the rest on `.accessibilityCustomContent(...)`. `.high` importance is spoken - inline; everything else reaches the More Content rotor on a vertical swipe. - Rows with only 3–4 elements (the portfolio activity/attention/expiry rows) are - left to `NavigationLink`'s automatic combine — custom content is for the dense - case, per WWDC21-10121. -- **The custom-content chain must live in a `ViewModifier`.** Inlined onto a row - body, six `.accessibilityCustomContent` calls plus the visual layout blow the - Swift type-checker's budget ("unable to type-check in reasonable time"). - `BatchRowAccessibility` / `WatchlistRowAccessibility` exist for that reason. -- **Splitting a `Label` exposes its icon; combining a header swallows its - trailing controls.** Two opposite traps. A decorative icon pulled out of a - `Label` needs `.accessibilityHidden(true)`. A header built as a `Button` must - *not* get `.accessibilityElement(children: .combine)` if its label contains - other controls (`CollapsibleSectionView`'s `trailing()` holds Track/Pin) — - combine would merge them into the header and make them unreachable. -- **Label-in-name (WCAG 2.5.3).** Every `accessibilityLabel` added to a control - with visible text keeps that text, so Voice Control still works. Free-form - labels are used only where the control is genuinely icon-only. -- **Technical strings** get `speechStyle: .technical` on `InfoRowViewData`, which - applies `.speechAlwaysIncludesPunctuation()` and - `.accessibilityTextContentType(.sourceCode)`. Set today on DNS record values - and cipher suites; extend it wherever the view model emits a fingerprint, - serial, or record string. - -## Color independence, motion, transparency - -- **Status is never colour-only.** In-app badges already pair a symbol with the - colour. The widget status dot is now an SF Symbol - (`checkmark.circle.fill` / `exclamationmark.triangle.fill` / - `exclamationmark.octagon.fill`) — the same vocabulary as the badges, so a - status reads consistently across surfaces and survives greyscale. -- **`accessibilityDifferentiateWithoutColor`** adds redundant shape only when the - user asks for it, avoiding clutter otherwise: the Dashboard summary-card dot - becomes a per-filter symbol, the selected quick-filter chip gains a checkmark - and border (selection was fill-colour only), and `LabeledValueRow` prefixes a - warning/failure symbol. -- **`accessibilityReduceMotion`** guards all five animation sites via - `withAnimation(reduceMotion ? nil : …)` / `.animation(reduceMotion ? nil : …)`: - `AppCopyButton`'s check cross-fade, `CollapsibleSectionView`'s expand/collapse, - `TimelineDiffView`'s scroll, and `WatchlistView`'s list reorder. -- **`accessibilityReduceTransparency`** swaps the single `.thinMaterial` for an - opaque `AppSurfaceElevated` capsule. - -These cannot be verified by `simctl`, which toggles only Increase Contrast — the -other three settings live in the simulator's Settings app. They are correct by -construction and build-clean; their runtime behaviour is part of the Phase 6 -manual pass. `SweepActivityController` was dropped from the motion list: it is -pure ActivityKit lifecycle with no animation to guard. - -### What the automated audit cannot check - -`performAccessibilityAudit()` validates descriptions, traits, contrast, hit -regions, and clipping. It does **not** exercise VoiceOver speech, the More -Content rotor, custom-content ordering, or announcements. Those are verified by -construction and a manual VoiceOver pass (Phase 6), not by the suite. A green -audit is necessary, not sufficient, for the row and speech work. - -Additionally, the dense rows (`BatchResultRowView`, `WatchlistRowView`) and the -widget never render in the audit — the test simulator has no tracked domains or -batch results. Their treatment is unverified by the suite for the same reason the -Phase 3 `ViewThatFits` work was deferred: absence of findings is absence of data. - -## Notes - -- **Disabled controls are a false positive, and are suppressed.** WCAG 1.4.3 - exempts inactive components from contrast requirements, but the audit flags - them anyway — Inspect's Run button is disabled until a domain is typed, and - auditing the empty state reported a contrast failure that was never a real - defect. The harness now drops contrast findings whose element reports - `isEnabled == false`. Suppressing on the rule beats driving the UI to enable - the control: typing raises the keyboard, which then follows the audit onto - later screens and flags the system emoji picker's category buttons. -- **A dirty simulator inflates the burndown.** Keyboard state persists across - runs, so a simulator left with the emoji picker open reports ~9 phantom - hit-region findings per screen. If findings appear that name system UI - ("Flags category", "Frequently Used category"), erase the simulator - (`xcrun simctl erase `) and re-run before believing them. -- Audits retry up to three times. Slower machines can miss the audit's internal - deadline (`Audit failed to complete in time`, code `-56`), which is a tooling - timeout, not an app defect. A screen that still cannot be audited is reported - as an `XCTSkip`, never a pass — skips are visually distinct in CI, so an - unaudited screen stays visible instead of being silently counted as clean. -- The suite launches with `DOMAIN_DIG_FORCE_PRO_PLUS` so Pro-gated screens are - reachable. `PurchaseService` honours that argument in `DEBUG` builds only. -- Everything used is available at the iOS 17.6 deployment floor; - `performAccessibilityAudit` is `ios(17.0)`. diff --git a/Docs/ACCESSIBILITY.txt b/Docs/ACCESSIBILITY.txt new file mode 100644 index 0000000..6e82055 --- /dev/null +++ b/Docs/ACCESSIBILITY.txt @@ -0,0 +1,313 @@ +# Accessibility Audit + +`DomainDigUITests` runs Apple's `performAccessibilityAudit()` across every +primary screen. The audit checks contrast, hit-region size, clipped text at +large Dynamic Type, element descriptions, trait correctness, and Dynamic Type +support — the same ground the accessibility pass tracked in +[issue #21](https://github.com/krazywarez/domain-dig/issues/21) covers. + +## The colour palette + +Semantic colours live in `Shared/Colors.xcassets`, which is inside the `Shared` +file-system-synchronized group and therefore reaches the app, the widget, and +the share extension automatically. `AccentColor` stays in +`DomainDig/Assets.xcassets` because it is the system-wide tint resolved via +`ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME`. + +Use the generated asset symbols — `Color(.statusCritical)`, `Color(.appSurface)` +— never a literal. `ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS` +is on, so these are compile-time checked; a typo will not build. + +Every value clears WCAG AA (4.5:1) as text on its page, on its card, **and on +its own 16% badge tint** — the way `AppStatusBadgeView` actually draws it. The +worst of those three is shown: + +| Role | Light | Dark | Worst light | Worst dark | +| --- | --- | --- | --- | --- | +| `StatusInfo` / `AccentColor` | `#0000FF` | `#4DA3FF` | 6.76 | 6.47 | +| `StatusPositive` | `#008035` | `#30D158` | 4.54 | 7.62 | +| `StatusWarning` | `#AD5100` | `#FF9F0A` | 4.59 | 7.76 | +| `StatusCritical` | `#CC0700` | `#FF6961` | 4.68 | 6.12 | +| `StatusNeutral` | `#5A5A5F` | `#A1A1A6` | 5.84 | 6.76 | + +Each status foreground has a matching `…Surface` colour for the fill behind it, +paired through `AppStatusTone`. + +### Contrast alone is not a palette + +The first version of this palette maximised contrast and produced mud. Requiring +every foreground to clear 4.5:1 against *its own 16% tint* — the harshest +surface it ever sits on — pushed each colour ~20% darker than the common case +needed. `#7A5600` is not amber, it is olive; `#146C2E` is not green so much as +bottle-dark. Contrast passed and the UI was still hard to read, because hue +identity is what tells "warning" from "critical" at a glance. + +Two fixes: + +1. **Decouple the fill from the foreground.** `AppStatusTone` carries a + `foreground` and a `surface` that are authored independently, so the + foreground no longer has to survive a wash of itself. Every status foreground + is now fully saturated (`S = 1.0`). +2. **Warning is orange, not yellow.** Yellow cannot stay yellow at a lightness + low enough to clear 4.5:1 on white — it *becomes* olive. That is + colorimetric, not a tuning problem. Orange holds its identity when darkened, + so warning is `#AD5100` in light and `#FF9F0A` in dark. + +When adding a colour, search for the most saturated value that passes, not the +darkest. The darkest is always easy and always wrong. +| `AppTextSecondary` | `#5A5A5F` | `#A1A1A6` | 6.15 | 7.50 | + +`AppTextSecondary` replaces `.secondary` for body text. iOS's own `secondaryLabel` +is only **3.29:1** on a light card — below AA — which never showed while the app +was locked to dark, where the same colour reads 6.32:1. Unlocking light mode +exposed it across 191 sites. + +High Contrast variants push further in the same direction. Surfaces +(`AppBackground`, `AppSurface`, `AppSurfaceElevated`, `AppSeparator`) carry no +meaning, so they get Any/Dark and, where useful, High Contrast — but no status +semantics. + +Why custom values instead of the system palette: **every** system colour fails +in light mode. Measured on white — systemYellow 1.51:1, systemOrange 2.20:1, +systemGreen 2.22:1, systemCyan 2.54:1, systemRed 3.55:1. All of them pass in +dark mode, which is why the dark-locked app looked fine and why unlocking light +mode is impossible without this work. + +### The accent has two roles, and they conflict + +An accent used as **text on a dark background** must be light. The same accent +used as a **fill behind a white label** must be dark. One value cannot do both: +`#4DA3FF` reads at 8.00:1 as text on black, but only 2.63:1 behind white text. + +So there are two colours: + +- `StatusInfo` / `AccentColor` — the accent as *foreground*: text, icons, + bordered-button labels, tab bar. +- `AccentFill` — the accent as a *filled background* behind a label, used by + `.borderedProminent`. Stays dark in both schemes so a white label clears AA + (8.59:1 light, 7.56:1 dark). + +`AppOnAccent` is the label colour for a solid accent fill and flips by scheme — +white on the light accent, black on the dark one. + +## Appearance + +`AppAppearance` (System / Light / Dark) is stored in `@AppStorage` and applied in +**exactly one place** — the `WindowGroup` in `DomainDigApp`. Keep it that way. The +app previously carried 16 separate `.preferredColorScheme(.dark)` calls scattered +through view bodies, which is how light mode became unreachable without anyone +noticing; re-applying per view is what let the lock spread. + +Users override it under Settings → Display. + +### Known light-mode findings + +| Finding | Cause | Action | +| --- | --- | --- | +| 2× `contrast failed` on Settings | The last rows of a section sit under the translucent tab bar, so the audit measures text against a blended background. Present in dark mode too, since phase 0. | None — standard iOS scroll-under behaviour | +| 3× `contrast nearly passed` on Settings | iOS-rendered `Section` headers (`TIER`, `PREFERENCES`, `SERVICES`) use the system's grey. | Not fixed. Overriding system header styling across every section to gain ~0.3:1 on decorative labels trades platform convention for very little | + +Dark mode reports 18 findings and light mode 21; the three extra are the section +headers above. Everything the app actually controls passes in both schemes. + +## Enforcement — the ratchet is engaged + +With phases 1–5 landed, `AccessibilityAuditHarness.enforcedAuditTypes` enforces +**`.textClipped`, `.dynamicType`, `.hitRegion`, `.elementDetection`, +`.sufficientElementDescription`, `.trait`** on the empty-state test suite. A +named finding in any of these fails CI — regressions in five phases of work are +now gated, not merely reported. + +Three deliberate carve-outs, each with its evidence: + +1. **`.contrast` stays report-only.** The two long-standing Settings findings + are rows scrolled under the translucent tab bar; their attribution flips + between a row name and nil run-to-run, so no suppression is narrow enough to + keep CI stable. The centralised palette in `Shared/Colors.xcassets` is the + actual guard against contrast regressions. +2. **The seeded tests run `reportOnly`.** Bisecting the row/badge accessibility + modifiers showed the audit degrades on `children: .ignore` content — the + *correct* VoiceOver treatment for dense rows — emitting unattributed + contrast/dynamicType failures on rows that measure 6–7:1 and render + correctly. Their burndown still prints; it just doesn't gate. +3. **Characterised noise is suppressed narrowly and always logged** with a + `[noise: reason]` marker — disabled controls (WCAG 1.4.3 exempt), "nearly + passed" near-misses, system field placeholders (clipped at any length — + proven by shortening them to no effect), and unattributed + clipped/dynamic-type artifacts. Nothing disappears silently; see + `noiseReason(for:)` for each rule's provenance. + +Enforcement is a committed constant rather than a CI setting, for two reasons. +Environment variables do not work: neither a plain `xcodebuild` env var nor a +`TEST_RUNNER_`-prefixed build setting reaches the UI test process, so the toggle +silently did nothing. And a committed value makes "when did clipping become +enforced?" answerable with `git blame` instead of CI tribal knowledge. + +## Why coverage is split between local and CI + +**Audit coverage is not nested across OS versions.** Each runtime reports +findings the others miss, in *both* directions. Measured on this project: + +| Screen | iOS 18.6 | iOS 27.0 | +| --- | --- | --- | +| Tracked Domains | 2 (text clipped) | **6** (+ contrast ×3, element detection) | +| Settings | 2 contrast | **`dynamicType`** finding 18.6 missed | +| Dashboard @ `AccessibilityXXXL` | **hit region** + 2 clipped | 1 clipped only | + +Neither runtime is a superset, so the oldest supported OS needs its own run. +This also rules out committing per-screen baseline counts as a regression guard: +no single number is correct on both. + +The catch is that **GitHub's `macos-26` image ships only iOS 26.x simulator +runtimes.** It cannot test the 17.6 floor at all. A two-job CI matrix was tried +and produced two near-identical 26.x runs at double the macOS minutes. + +So the work is split by what each side can uniquely do: + +| | Runtime | Uniquely provides | +| --- | --- | --- | +| **CI** (`.github/workflows/build.yml`) | newest available | A clean checkout of the merge result — catches a file that was never committed, which a local run cannot. Matters here because `DomainDig.xcodeproj` is hand-edited and uses file-system-synchronized groups, where a whole missing folder still builds locally. | +| **Local** (`Scripts/audit-a11y.sh`) | oldest supported + newest | Real floor coverage, on a machine that actually has an 18.x runtime installed. | + +Together they cover both ends; neither duplicates the other. + +## Running it + +```sh +./Scripts/audit-a11y.sh # floor + current +./Scripts/audit-a11y.sh floor # oldest supported only (~85s) +./Scripts/audit-a11y.sh current # newest installed only +``` + +The script reads the deployment target from the project rather than hard-coding +it, and selects the oldest installed runtime **at or above** it — a runtime +below the deployment target is useless, because the app cannot install there. +If the nearest installed runtime is a major version above the target, it says +so rather than implying floor coverage it does not have. + +### Pre-push hook + +```sh +git config core.hooksPath .githooks +``` + +Runs the floor audit before a push, and only when Swift, asset, or project files +changed. Bypass with `git push --no-verify`. + +Pre-push rather than pre-commit deliberately: the suite takes ~85s, and at +pre-commit that blocks every commit. A hook routinely bypassed with +`--no-verify` is worse than no hook, because it trains you to ignore it. + +## Layout gotchas found the hard way + +- **`Label` clips its own title.** Every empty-state heading reported as clipped + text. `.fixedSize` applied to the `Label` does not reach the `Text` inside it, + so the fix is to split it into an `HStack { Image; Text }` and put the modifier + on the `Text`. Changing the font design did **not** help — that hypothesis was + tested and discarded. +- **Splitting a `Label` exposes its icon to VoiceOver.** `Label` folds the image + into the title's accessibility element; an `HStack` does not, so the icon + starts announcing its raw SF Symbol name ("checklist.unchecked"). Decorative + icons split out of a `Label` need `.accessibilityHidden(true)`. +- **Placeholder text is always reported as clipped.** Search prompts and + `TextField` placeholders are flagged regardless of length — shortening + "Search portfolio" to "Search" changed nothing. Treat `textClipped` findings on + a `searchField` or `textField` element as noise rather than shortening useful + prompts to chase them. +- **`AppLayout.minimumTapTarget` is the floor for every control.** `@ScaledMetric` + scales *down* below the default text size as well as up, so a scaled dimension + needs `max(scaled, AppLayout.minimumTapTarget)` or it drops under 44pt for + users who prefer smaller text. + +## VoiceOver conventions + +- **Dense rows use combine-for-summary, custom-content-for-detail.** + `BatchResultRowView` and `WatchlistRowView` each hold 8–9 text elements. + Reading them inline makes a long sweep unnavigable, so each row is one element: + `.accessibilityElement(children: .ignore)` + domain label + status value, with + the rest on `.accessibilityCustomContent(...)`. `.high` importance is spoken + inline; everything else reaches the More Content rotor on a vertical swipe. + Rows with only 3–4 elements (the portfolio activity/attention/expiry rows) are + left to `NavigationLink`'s automatic combine — custom content is for the dense + case, per WWDC21-10121. +- **The custom-content chain must live in a `ViewModifier`.** Inlined onto a row + body, six `.accessibilityCustomContent` calls plus the visual layout blow the + Swift type-checker's budget ("unable to type-check in reasonable time"). + `BatchRowAccessibility` / `WatchlistRowAccessibility` exist for that reason. +- **Splitting a `Label` exposes its icon; combining a header swallows its + trailing controls.** Two opposite traps. A decorative icon pulled out of a + `Label` needs `.accessibilityHidden(true)`. A header built as a `Button` must + *not* get `.accessibilityElement(children: .combine)` if its label contains + other controls (`CollapsibleSectionView`'s `trailing()` holds Track/Pin) — + combine would merge them into the header and make them unreachable. +- **Label-in-name (WCAG 2.5.3).** Every `accessibilityLabel` added to a control + with visible text keeps that text, so Voice Control still works. Free-form + labels are used only where the control is genuinely icon-only. +- **Technical strings** get `speechStyle: .technical` on `InfoRowViewData`, which + applies `.speechAlwaysIncludesPunctuation()` and + `.accessibilityTextContentType(.sourceCode)`. Set today on DNS record values + and cipher suites; extend it wherever the view model emits a fingerprint, + serial, or record string. + +## Color independence, motion, transparency + +- **Status is never colour-only.** In-app badges already pair a symbol with the + colour. The widget status dot is now an SF Symbol + (`checkmark.circle.fill` / `exclamationmark.triangle.fill` / + `exclamationmark.octagon.fill`) — the same vocabulary as the badges, so a + status reads consistently across surfaces and survives greyscale. +- **`accessibilityDifferentiateWithoutColor`** adds redundant shape only when the + user asks for it, avoiding clutter otherwise: the Dashboard summary-card dot + becomes a per-filter symbol, the selected quick-filter chip gains a checkmark + and border (selection was fill-colour only), and `LabeledValueRow` prefixes a + warning/failure symbol. +- **`accessibilityReduceMotion`** guards all five animation sites via + `withAnimation(reduceMotion ? nil : …)` / `.animation(reduceMotion ? nil : …)`: + `AppCopyButton`'s check cross-fade, `CollapsibleSectionView`'s expand/collapse, + `TimelineDiffView`'s scroll, and `WatchlistView`'s list reorder. +- **`accessibilityReduceTransparency`** swaps the single `.thinMaterial` for an + opaque `AppSurfaceElevated` capsule. + +These cannot be verified by `simctl`, which toggles only Increase Contrast — the +other three settings live in the simulator's Settings app. They are correct by +construction and build-clean; their runtime behaviour is part of the Phase 6 +manual pass. `SweepActivityController` was dropped from the motion list: it is +pure ActivityKit lifecycle with no animation to guard. + +### What the automated audit cannot check + +`performAccessibilityAudit()` validates descriptions, traits, contrast, hit +regions, and clipping. It does **not** exercise VoiceOver speech, the More +Content rotor, custom-content ordering, or announcements. Those are verified by +construction and a manual VoiceOver pass (Phase 6), not by the suite. A green +audit is necessary, not sufficient, for the row and speech work. + +Additionally, the dense rows (`BatchResultRowView`, `WatchlistRowView`) and the +widget never render in the audit — the test simulator has no tracked domains or +batch results. Their treatment is unverified by the suite for the same reason the +Phase 3 `ViewThatFits` work was deferred: absence of findings is absence of data. + +## Notes + +- **Disabled controls are a false positive, and are suppressed.** WCAG 1.4.3 + exempts inactive components from contrast requirements, but the audit flags + them anyway — Inspect's Run button is disabled until a domain is typed, and + auditing the empty state reported a contrast failure that was never a real + defect. The harness now drops contrast findings whose element reports + `isEnabled == false`. Suppressing on the rule beats driving the UI to enable + the control: typing raises the keyboard, which then follows the audit onto + later screens and flags the system emoji picker's category buttons. +- **A dirty simulator inflates the burndown.** Keyboard state persists across + runs, so a simulator left with the emoji picker open reports ~9 phantom + hit-region findings per screen. If findings appear that name system UI + ("Flags category", "Frequently Used category"), erase the simulator + (`xcrun simctl erase `) and re-run before believing them. +- Audits retry up to three times. Slower machines can miss the audit's internal + deadline (`Audit failed to complete in time`, code `-56`), which is a tooling + timeout, not an app defect. A screen that still cannot be audited is reported + as an `XCTSkip`, never a pass — skips are visually distinct in CI, so an + unaudited screen stays visible instead of being silently counted as clean. +- The suite launches with `DOMAIN_DIG_FORCE_PRO_PLUS` so Pro-gated screens are + reachable. `PurchaseService` honours that argument in `DEBUG` builds only. +- Everything used is available at the iOS 17.6 deployment floor; + `performAccessibilityAudit` is `ios(17.0)`. diff --git a/Docs/ARCHITECTURE.md b/Docs/ARCHITECTURE.md deleted file mode 100644 index d0d2108..0000000 --- a/Docs/ARCHITECTURE.md +++ /dev/null @@ -1,102 +0,0 @@ -# DomainDig Architecture - -## Overview - -DomainDig is a local-first inspection and audit app built around one canonical output model: `DomainReport`. - -Inspection flow: - -1. `LookupRuntime` coordinates the section services that gather DNS, web, TLS, ownership, reachability, redirect, email, port, and enrichment data. -2. `DomainInspectionService` normalizes live and cached results into `LookupSnapshot`. -3. `DomainReportBuilder` converts each snapshot into the canonical `DomainReport`. -4. SwiftUI screens, exports, and the local API render from `DomainReport` or data derived from it. - -`LookupSnapshot` remains the internal persistence shape for raw inspection state. `DomainReport` is the stable presentation/export contract. - -## App Layers - -- Section services: network collection and local normalization only. -- `LookupRuntime`: orchestrates section services for a single inspection. -- `DomainInspectionService`: builds inspection snapshots with provenance, cache state, and failure metadata. -- `DomainReportBuilder`: assembles summaries, insights, risk scoring, workflow context, and report metadata. -- `DomainReportExporter`: renders TXT, CSV, JSON, Markdown, and PDF output for app and local API use. -- `DomainViewModel`: coordinates SwiftUI state, persistence, audit sessions, monitoring, workflows, batch operations, imports, and exports. Its surface is split by concern across `DomainViewModel+Audit`, `+Monitoring`, `+Export`, `+Workflows`, `+History`, and `+Widget` extensions; the core type keeps the stored state and the inspection pipeline. -- SwiftUI views: render screens and invoke view-model actions. The largest view file was decomposed too — Settings screens live in `SettingsViews.swift` and the result detail sections in `ResultSectionViews.swift`. - -## Audit Mode - -The app has one active Audit Mode implementation: - -- Models live in `DomainDig/DomainDig/AuditModels.swift`. -- UI lives in `DomainDig/DomainDig/AuditViews.swift`. -- Export rendering lives in `DomainDig/DomainDig/AuditExporter.swift`. -- Persistence is owned by `DomainViewModel` through `DomainDataPortabilityService`. - -An audit session captures: - -- Domain and reviewer metadata -- Session status -- Point-in-time `HistoryEntry` and `DomainReport` -- Historical snapshot context -- Evidence asset references -- Checklist progress -- Findings with severity, status, evidence references, notes, and checklist areas -- Reviewer notes - -Audit sessions are stored under the same local portability service as the rest of app data and are included in full backup/restore flows. - -The older standalone prototype files, `DomainDig/AuditMode.swift` and `DomainDig/AuditModeView.swift`, are preserved in the repository for reference but excluded from synchronized target membership. They are not the release audit path. - -## Data Portability - -`DomainDataPortabilityService` owns backup, import, validation, lifecycle counts, and merge/replace behavior for: - -- Tracked domains -- History snapshots -- Audit sessions -- Workflows -- Monitoring settings and logs -- App settings -- Local feature metadata - -Backup imports support merge and replace modes. Merge mode deduplicates by stable IDs or normalized domain keys, keeps local data where appropriate, and merges audit-session reviewer notes when the same audit session appears in multiple backups. - -## Feature Tiers - -`FeatureAccessService`, `PremiumAccessService`, `PurchaseService`, and `UsageCreditService` provide the app's feature-gating surfaces. - -The app remains local-first. Purchase and entitlement code is local app infrastructure and does not introduce a hosted DomainDig backend. - -## Local API - -`LocalAPIService` is an automation surface over the same inspection/reporting pipeline: - -- `DomainInspectionService` -- `DomainReportBuilder` -- `DomainReportExporter` -- `LocalAPIModels` - -`v5.0.0` stabilized this contract: `LocalAPIContract` is the single source of truth for the `v1` wire version and JSON encoder, the response envelope and payloads are documented, and the shape is regression-locked by `LocalAPIContractTests`. See [local-api.md](local-api.md) for the endpoint and compatibility reference, and [data-migration.md](data-migration.md) for how the persisted store is versioned across app updates. - -## Testing - -Two test targets run from the `DomainDig` scheme's test action: - -- `DomainDigTests` — unit coverage of the deterministic core: `DomainReportBuilder`, `DomainReportExporter`, `DiffService`, `DomainDataPortabilityService` (merge/replace dedup), the store-migration runner, and the Local API contract. `SnapshotFixture` builds the deep `LookupSnapshot`/`DomainReport` models through the real builder so tests construct inputs without wiring every field. -- `DomainDigUITests` — Apple's `performAccessibilityAudit()` over every primary screen at default and largest Dynamic Type, plus metadata and screenshot assertions. See [ACCESSIBILITY.md](ACCESSIBILITY.md). - -A plain `xcodebuild test` (and CI) runs both. The unit net went in first in `v5.0.0` and is what made the god-file decomposition safe to attempt. - -## Xcode Project Structure - -`DomainDig.xcodeproj` uses filesystem-synchronized groups for the `DomainDig` folder. Target membership exclusions are therefore important release metadata. Files that should remain in the tree but not compile, such as retired prototypes, must be listed in the appropriate synchronized build file exception set. - -## Adding A New Data Source - -1. Add the raw collection call to `LookupRuntime` or an existing section service. -2. Integrate it in `DomainInspectionService` with provenance, cache source, and normalized failures. -3. Extend `LookupSnapshot` only if the raw result must persist. -4. Add summarized representation to `DomainReportBuilder`. -5. Expose it through `DomainReportExporter` or `LocalAPIModels` when it is part of the external contract. -6. Render it in SwiftUI from `DomainReport` fields or view-model state. -7. Update backup/restore only when the data is user-authored state or long-lived app state. diff --git a/Docs/ARCHITECTURE.txt b/Docs/ARCHITECTURE.txt new file mode 100644 index 0000000..dbf66d1 --- /dev/null +++ b/Docs/ARCHITECTURE.txt @@ -0,0 +1,102 @@ +# DomainDig Architecture + +## Overview + +DomainDig is a local-first inspection and audit app built around one canonical output model: `DomainReport`. + +Inspection flow: + +1. `LookupRuntime` coordinates the section services that gather DNS, web, TLS, ownership, reachability, redirect, email, port, and enrichment data. +2. `DomainInspectionService` normalizes live and cached results into `LookupSnapshot`. +3. `DomainReportBuilder` converts each snapshot into the canonical `DomainReport`. +4. SwiftUI screens, exports, and the local API render from `DomainReport` or data derived from it. + +`LookupSnapshot` remains the internal persistence shape for raw inspection state. `DomainReport` is the stable presentation/export contract. + +## App Layers + +- Section services: network collection and local normalization only. +- `LookupRuntime`: orchestrates section services for a single inspection. +- `DomainInspectionService`: builds inspection snapshots with provenance, cache state, and failure metadata. +- `DomainReportBuilder`: assembles summaries, insights, risk scoring, workflow context, and report metadata. +- `DomainReportExporter`: renders TXT, CSV, JSON, Markdown, and PDF output for app and local API use. +- `DomainViewModel`: coordinates SwiftUI state, persistence, audit sessions, monitoring, workflows, batch operations, imports, and exports. Its surface is split by concern across `DomainViewModel+Audit`, `+Monitoring`, `+Export`, `+Workflows`, `+History`, and `+Widget` extensions; the core type keeps the stored state and the inspection pipeline. +- SwiftUI views: render screens and invoke view-model actions. The largest view file was decomposed too — Settings screens live in `SettingsViews.swift` and the result detail sections in `ResultSectionViews.swift`. + +## Audit Mode + +The app has one active Audit Mode implementation: + +- Models live in `DomainDig/DomainDig/AuditModels.swift`. +- UI lives in `DomainDig/DomainDig/AuditViews.swift`. +- Export rendering lives in `DomainDig/DomainDig/AuditExporter.swift`. +- Persistence is owned by `DomainViewModel` through `DomainDataPortabilityService`. + +An audit session captures: + +- Domain and reviewer metadata +- Session status +- Point-in-time `HistoryEntry` and `DomainReport` +- Historical snapshot context +- Evidence asset references +- Checklist progress +- Findings with severity, status, evidence references, notes, and checklist areas +- Reviewer notes + +Audit sessions are stored under the same local portability service as the rest of app data and are included in full backup/restore flows. + +The older standalone prototype files, `DomainDig/AuditMode.swift` and `DomainDig/AuditModeView.swift`, are preserved in the repository for reference but excluded from synchronized target membership. They are not the release audit path. + +## Data Portability + +`DomainDataPortabilityService` owns backup, import, validation, lifecycle counts, and merge/replace behavior for: + +- Tracked domains +- History snapshots +- Audit sessions +- Workflows +- Monitoring settings and logs +- App settings +- Local feature metadata + +Backup imports support merge and replace modes. Merge mode deduplicates by stable IDs or normalized domain keys, keeps local data where appropriate, and merges audit-session reviewer notes when the same audit session appears in multiple backups. + +## Feature Tiers + +`FeatureAccessService`, `PremiumAccessService`, `PurchaseService`, and `UsageCreditService` provide the app's feature-gating surfaces. + +The app remains local-first. Purchase and entitlement code is local app infrastructure and does not introduce a hosted DomainDig backend. + +## Local API + +`LocalAPIService` is an automation surface over the same inspection/reporting pipeline: + +- `DomainInspectionService` +- `DomainReportBuilder` +- `DomainReportExporter` +- `LocalAPIModels` + +`v5.0.0` stabilized this contract: `LocalAPIContract` is the single source of truth for the `v1` wire version and JSON encoder, the response envelope and payloads are documented, and the shape is regression-locked by `LocalAPIContractTests`. See [local-api.txt](local-api.txt) for the endpoint and compatibility reference, and [data-migration.txt](data-migration.txt) for how the persisted store is versioned across app updates. + +## Testing + +Two test targets run from the `DomainDig` scheme's test action: + +- `DomainDigTests` — unit coverage of the deterministic core: `DomainReportBuilder`, `DomainReportExporter`, `DiffService`, `DomainDataPortabilityService` (merge/replace dedup), the store-migration runner, and the Local API contract. `SnapshotFixture` builds the deep `LookupSnapshot`/`DomainReport` models through the real builder so tests construct inputs without wiring every field. +- `DomainDigUITests` — Apple's `performAccessibilityAudit()` over every primary screen at default and largest Dynamic Type, plus metadata and screenshot assertions. See [ACCESSIBILITY.txt](ACCESSIBILITY.txt). + +A plain `xcodebuild test` (and CI) runs both. The unit net went in first in `v5.0.0` and is what made the god-file decomposition safe to attempt. + +## Xcode Project Structure + +`DomainDig.xcodeproj` uses filesystem-synchronized groups for the `DomainDig` folder. Target membership exclusions are therefore important release metadata. Files that should remain in the tree but not compile, such as retired prototypes, must be listed in the appropriate synchronized build file exception set. + +## Adding A New Data Source + +1. Add the raw collection call to `LookupRuntime` or an existing section service. +2. Integrate it in `DomainInspectionService` with provenance, cache source, and normalized failures. +3. Extend `LookupSnapshot` only if the raw result must persist. +4. Add summarized representation to `DomainReportBuilder`. +5. Expose it through `DomainReportExporter` or `LocalAPIModels` when it is part of the external contract. +6. Render it in SwiftUI from `DomainReport` fields or view-model state. +7. Update backup/restore only when the data is user-authored state or long-lived app state. diff --git a/Docs/data-migration.md b/Docs/data-migration.md deleted file mode 100644 index 1ae4030..0000000 --- a/Docs/data-migration.md +++ /dev/null @@ -1,94 +0,0 @@ -# DomainDig Data Migration Policy - -How DomainDig's persisted data evolves across app versions without losing or -corrupting a user's on-device store. - -## What is persisted - -The store is a set of independent JSON blobs in `UserDefaults`, each under a -stable key (see `DomainDataPortabilityService.StorageKey`): - -| Data | Key | -|------|-----| -| Tracked domains | `trackedDomains` (legacy: `watchedDomains`) | -| Lookup history (snapshots) | `lookupHistory` | -| Audit sessions | `domainAudits` | -| Workflows | `domainWorkflows` | -| Monitoring settings / logs | `monitoring.settings`, `monitoring.logs` | -| App settings | `recentSearches`, `savedDomains`, resolver URL, density | -| Feature metadata | `purchase.cachedEntitlement`, `usageCredits.ledger` | - -A **backup export** (`DomainDigBackup`) is a separate, self-describing file that -bundles all of the above with its own `schemaVersion`. - -## Two version lines - -- **Store schema version** — `DataMigrationService.currentStoreSchemaVersion`, - persisted under `data.storeSchemaVersion`. Describes the shape of the - *on-device* `UserDefaults` store. Advanced by the migration runner. -- **Backup schema version** — `DomainDigBackup.currentSchemaVersion`, written - into every exported file. Describes the shape of an *export*. Checked on import - by `DataValidationService`. - -They advance independently: a store migration that doesn't change the export -shape need not bump the backup version, and vice versa. - -## How models evolve - -Prefer **additive, lenient decoding** — it needs no migration: - -- New optional field → add it with `decodeIfPresent(...) ?? default` in the - model's `init(from:)`. Old data simply lacks the key and falls back. -- New value in a `String`-backed enum → decode unknown values to a safe default - rather than throwing. - -Reach for a **migration step** only when lenient decoding can't express the -change: - -- Renaming or removing a storage key (e.g. `watchedDomains` → `trackedDomains`). -- Re-normalizing existing rows (dedup, canonicalizing domain casing). -- Reshaping a blob in a way old readers would misread. - -## The migration runner - -`DataMigrationService.migrateIfNeeded(defaults:)` runs at launch (and before any -backup export/import). Its contract: - -1. **Forward-only.** It reads the stored version and runs each step with a target - greater than it, in ascending order, up to `currentStoreSchemaVersion`, - stamping the new version after each step. -2. **Never downgrades.** A store stamped at a version *higher* than this build - understands (a user who ran a newer build first) is left untouched — no - rewrite, no data loss. -3. **Idempotent & safe on any state.** Every step must be safe to run on an empty - store and to re-run, because a downgrade-then-upgrade or a partial run can - replay it. v1 (the `watchedDomains` drop + dedup normalization) satisfies this - by loading through the deduplicating loaders and writing back. -4. **Pre-versioning installs.** Before this framework, a boolean marker - (`data.migrations.v3_4_0`) recorded that the v1 normalization had run. A set - marker is read as "already at version 1," so v1 never re-runs for those users. - -## Adding a migration - -1. Add a `case N:` to `DataMigrationService.runMigration(to:defaults:)` and a - private helper that performs the change. -2. Bump `currentStoreSchemaVersion` to `N`. -3. Make the helper idempotent and safe on an empty/older store. -4. Add a `DataMigrationServiceTests` case that seeds a pre-`N` fixture, runs - `migrateIfNeeded`, and asserts the upgrade plus the version stamp. -5. If the change also alters the export shape, bump - `DomainDigBackup.currentSchemaVersion` and update `Docs/local-api.md` / - backup validation as needed. - -## Backup import compatibility - -On import, `DataValidationService.validate(backup:)` compares the file's -`schemaVersion` to the current one: - -- **Newer** than this build → surfaced as an error (the build can't safely read - it). -- **Older** → imported under the same lenient decoders and merge/dedup rules that - govern the live store; a note is surfaced, not an error. - -Imported data flows through `migrateIfNeeded` and the same `save*` deduplication -as everything else, so an old backup lands in the store already normalized. diff --git a/Docs/data-migration.txt b/Docs/data-migration.txt new file mode 100644 index 0000000..c97a1be --- /dev/null +++ b/Docs/data-migration.txt @@ -0,0 +1,94 @@ +# DomainDig Data Migration Policy + +How DomainDig's persisted data evolves across app versions without losing or +corrupting a user's on-device store. + +## What is persisted + +The store is a set of independent JSON blobs in `UserDefaults`, each under a +stable key (see `DomainDataPortabilityService.StorageKey`): + +| Data | Key | +|------|-----| +| Tracked domains | `trackedDomains` (legacy: `watchedDomains`) | +| Lookup history (snapshots) | `lookupHistory` | +| Audit sessions | `domainAudits` | +| Workflows | `domainWorkflows` | +| Monitoring settings / logs | `monitoring.settings`, `monitoring.logs` | +| App settings | `recentSearches`, `savedDomains`, resolver URL, density | +| Feature metadata | `purchase.cachedEntitlement`, `usageCredits.ledger` | + +A **backup export** (`DomainDigBackup`) is a separate, self-describing file that +bundles all of the above with its own `schemaVersion`. + +## Two version lines + +- **Store schema version** — `DataMigrationService.currentStoreSchemaVersion`, + persisted under `data.storeSchemaVersion`. Describes the shape of the + *on-device* `UserDefaults` store. Advanced by the migration runner. +- **Backup schema version** — `DomainDigBackup.currentSchemaVersion`, written + into every exported file. Describes the shape of an *export*. Checked on import + by `DataValidationService`. + +They advance independently: a store migration that doesn't change the export +shape need not bump the backup version, and vice versa. + +## How models evolve + +Prefer **additive, lenient decoding** — it needs no migration: + +- New optional field → add it with `decodeIfPresent(...) ?? default` in the + model's `init(from:)`. Old data simply lacks the key and falls back. +- New value in a `String`-backed enum → decode unknown values to a safe default + rather than throwing. + +Reach for a **migration step** only when lenient decoding can't express the +change: + +- Renaming or removing a storage key (e.g. `watchedDomains` → `trackedDomains`). +- Re-normalizing existing rows (dedup, canonicalizing domain casing). +- Reshaping a blob in a way old readers would misread. + +## The migration runner + +`DataMigrationService.migrateIfNeeded(defaults:)` runs at launch (and before any +backup export/import). Its contract: + +1. **Forward-only.** It reads the stored version and runs each step with a target + greater than it, in ascending order, up to `currentStoreSchemaVersion`, + stamping the new version after each step. +2. **Never downgrades.** A store stamped at a version *higher* than this build + understands (a user who ran a newer build first) is left untouched — no + rewrite, no data loss. +3. **Idempotent & safe on any state.** Every step must be safe to run on an empty + store and to re-run, because a downgrade-then-upgrade or a partial run can + replay it. v1 (the `watchedDomains` drop + dedup normalization) satisfies this + by loading through the deduplicating loaders and writing back. +4. **Pre-versioning installs.** Before this framework, a boolean marker + (`data.migrations.v3_4_0`) recorded that the v1 normalization had run. A set + marker is read as "already at version 1," so v1 never re-runs for those users. + +## Adding a migration + +1. Add a `case N:` to `DataMigrationService.runMigration(to:defaults:)` and a + private helper that performs the change. +2. Bump `currentStoreSchemaVersion` to `N`. +3. Make the helper idempotent and safe on an empty/older store. +4. Add a `DataMigrationServiceTests` case that seeds a pre-`N` fixture, runs + `migrateIfNeeded`, and asserts the upgrade plus the version stamp. +5. If the change also alters the export shape, bump + `DomainDigBackup.currentSchemaVersion` and update `Docs/local-api.txt` / + backup validation as needed. + +## Backup import compatibility + +On import, `DataValidationService.validate(backup:)` compares the file's +`schemaVersion` to the current one: + +- **Newer** than this build → surfaced as an error (the build can't safely read + it). +- **Older** → imported under the same lenient decoders and merge/dedup rules that + govern the live store; a note is surfaced, not an error. + +Imported data flows through `migrateIfNeeded` and the same `save*` deduplication +as everything else, so an old backup lands in the store already normalized. diff --git a/Docs/local-api.md b/Docs/local-api.md deleted file mode 100644 index a0cebdc..0000000 --- a/Docs/local-api.md +++ /dev/null @@ -1,113 +0,0 @@ -# DomainDig Local API — `v1` - -The Local API exposes DomainDig's canonical report data to on-device automation -(Shortcuts, scripts, integrations). It is **off by default** and, when enabled, -binds only to loopback. - -- **Base URL:** `http://127.0.0.1:` (default port `47821`, configurable in - Settings → Local API) -- **Binding:** loopback only (`acceptLocalOnly`); never reachable off-device -- **Content type:** every response is `application/json` -- **Version:** `v1` (reported in every response envelope) - -This document is the stable contract. The response shape is pinned by -`DomainDigTests/LocalAPIContractTests.swift`; `LocalAPIContract` (in -`LocalAPIContract.swift`) is the single source of truth for the version string -and the JSON encoder. - -## Authentication - -Every request requires the token shown in Settings → Local API, supplied either -way: - -``` -Authorization: Bearer -``` -``` -X-API-Token: -``` - -A missing or wrong token returns `401 unauthorized`. Settings → Local API has a -**Copy cURL Command** button that emits a ready-to-run authenticated request. - -## Response envelope - -Every response — success or error — is wrapped in the same envelope: - -```json -{ - "success": true, - "version": "v1", - "data": { "...": "payload, present on success" } -} -``` -```json -{ - "success": false, - "version": "v1", - "error": { "code": "not_found", "message": "The requested Local API route does not exist." } -} -``` - -- On success, `data` holds the endpoint payload and `error` is **omitted**. -- On failure, `error` holds a machine `code` plus a human `message`, and `data` - is **omitted**. - -### Encoding conventions - -- **Dates** are ISO-8601 UTC strings, e.g. `"2023-11-14T22:13:20Z"`. -- **Absent optional fields are omitted, not `null`.** Consumers must treat a - missing key as "not present." -- Object keys are emitted in sorted order (deterministic output; not - contractually meaningful — do not depend on key order). - -## Endpoints - -| Method | Path | Payload (`data`) fields | -|--------|------|-------------------------| -| GET | `/portfolio` | `summary` → `{ totalDomains, healthyCount, warningCount, criticalCount, changedLast24h, expiringSoonCount, unreachableCount }` | -| GET | `/domains` | `domains: [TrackedDomain]` | -| GET | `/domains/{domain}` | `domain`, `trackedDomain?` (`TrackedDomain`), `latestReport?` (`DomainReport`) | -| GET | `/domains/{domain}/history` | `domain`, `history: [HistoryEntry]` | -| GET | `/events` | `events: [{ timestamp, domain, summary, status, severity }]` | -| GET | `/monitoring` | `isEnabled`, `scope` (`"allTracked"` \| `"selectedOnly"`), `alertsEnabled`, `monitoredDomains: [{ domain, monitoringEnabled, lastMonitoredAt?, lastAlertAt?, certificateWarningLevel }]` | -| POST | `/inspect` | body `{ "domain": "example.com" }` → `report` (`DomainReport`) | -| POST | `/inspect/{domain}` | `report` (`DomainReport`) | -| POST | `/monitoring/{domain}/enable` | `domain`, `monitoringEnabled` | -| POST | `/monitoring/{domain}/disable` | `domain`, `monitoringEnabled` | - -`certificateWarningLevel` encodes as `"none"`, `"warning"`, or `"critical"`. - -`DomainReport` is the app's canonical report model (the same shape the JSON -export produces); see `DomainReportBuilder.swift` for its fields. It is a large -object and is treated as an additive contract: new fields may appear without a -version bump. - -## Error codes - -| HTTP | `code` | When | -|------|--------|------| -| 400 | `bad_request` | The HTTP request line/path could not be parsed | -| 400 | `invalid_body` | `POST /inspect` body was not `{ "domain": "…" }` | -| 400 | `invalid_domain` | A path/body domain was empty or invalid | -| 401 | `unauthorized` | Missing or incorrect token | -| 404 | `not_found` | No such route | -| 404 | `domain_not_found` | No local data / tracked domain for the given name | -| 500 | `encoding_failed` | The response could not be encoded | -| 500 | `internal_error` | The request handler failed unexpectedly | - -## Compatibility policy - -The `version` field follows a semantic-version-style promise: - -- **Backward-compatible changes keep `version` at `v1`.** Adding a new endpoint, - or adding a new field to an existing payload, is non-breaking. **Consumers - must ignore unknown fields.** -- **Breaking changes bump `version`.** Renaming or removing a field, changing a - field's type, or changing the meaning/units of an existing field requires a new - version, an update to this document, and an update to - `LocalAPIContractTests.swift`. - -There are currently no deprecated fields or endpoints. When a field is -deprecated, it will be listed here with the version in which it becomes eligible -for removal, and will remain present for at least one subsequent version. diff --git a/Docs/local-api.txt b/Docs/local-api.txt new file mode 100644 index 0000000..a0cebdc --- /dev/null +++ b/Docs/local-api.txt @@ -0,0 +1,113 @@ +# DomainDig Local API — `v1` + +The Local API exposes DomainDig's canonical report data to on-device automation +(Shortcuts, scripts, integrations). It is **off by default** and, when enabled, +binds only to loopback. + +- **Base URL:** `http://127.0.0.1:` (default port `47821`, configurable in + Settings → Local API) +- **Binding:** loopback only (`acceptLocalOnly`); never reachable off-device +- **Content type:** every response is `application/json` +- **Version:** `v1` (reported in every response envelope) + +This document is the stable contract. The response shape is pinned by +`DomainDigTests/LocalAPIContractTests.swift`; `LocalAPIContract` (in +`LocalAPIContract.swift`) is the single source of truth for the version string +and the JSON encoder. + +## Authentication + +Every request requires the token shown in Settings → Local API, supplied either +way: + +``` +Authorization: Bearer +``` +``` +X-API-Token: +``` + +A missing or wrong token returns `401 unauthorized`. Settings → Local API has a +**Copy cURL Command** button that emits a ready-to-run authenticated request. + +## Response envelope + +Every response — success or error — is wrapped in the same envelope: + +```json +{ + "success": true, + "version": "v1", + "data": { "...": "payload, present on success" } +} +``` +```json +{ + "success": false, + "version": "v1", + "error": { "code": "not_found", "message": "The requested Local API route does not exist." } +} +``` + +- On success, `data` holds the endpoint payload and `error` is **omitted**. +- On failure, `error` holds a machine `code` plus a human `message`, and `data` + is **omitted**. + +### Encoding conventions + +- **Dates** are ISO-8601 UTC strings, e.g. `"2023-11-14T22:13:20Z"`. +- **Absent optional fields are omitted, not `null`.** Consumers must treat a + missing key as "not present." +- Object keys are emitted in sorted order (deterministic output; not + contractually meaningful — do not depend on key order). + +## Endpoints + +| Method | Path | Payload (`data`) fields | +|--------|------|-------------------------| +| GET | `/portfolio` | `summary` → `{ totalDomains, healthyCount, warningCount, criticalCount, changedLast24h, expiringSoonCount, unreachableCount }` | +| GET | `/domains` | `domains: [TrackedDomain]` | +| GET | `/domains/{domain}` | `domain`, `trackedDomain?` (`TrackedDomain`), `latestReport?` (`DomainReport`) | +| GET | `/domains/{domain}/history` | `domain`, `history: [HistoryEntry]` | +| GET | `/events` | `events: [{ timestamp, domain, summary, status, severity }]` | +| GET | `/monitoring` | `isEnabled`, `scope` (`"allTracked"` \| `"selectedOnly"`), `alertsEnabled`, `monitoredDomains: [{ domain, monitoringEnabled, lastMonitoredAt?, lastAlertAt?, certificateWarningLevel }]` | +| POST | `/inspect` | body `{ "domain": "example.com" }` → `report` (`DomainReport`) | +| POST | `/inspect/{domain}` | `report` (`DomainReport`) | +| POST | `/monitoring/{domain}/enable` | `domain`, `monitoringEnabled` | +| POST | `/monitoring/{domain}/disable` | `domain`, `monitoringEnabled` | + +`certificateWarningLevel` encodes as `"none"`, `"warning"`, or `"critical"`. + +`DomainReport` is the app's canonical report model (the same shape the JSON +export produces); see `DomainReportBuilder.swift` for its fields. It is a large +object and is treated as an additive contract: new fields may appear without a +version bump. + +## Error codes + +| HTTP | `code` | When | +|------|--------|------| +| 400 | `bad_request` | The HTTP request line/path could not be parsed | +| 400 | `invalid_body` | `POST /inspect` body was not `{ "domain": "…" }` | +| 400 | `invalid_domain` | A path/body domain was empty or invalid | +| 401 | `unauthorized` | Missing or incorrect token | +| 404 | `not_found` | No such route | +| 404 | `domain_not_found` | No local data / tracked domain for the given name | +| 500 | `encoding_failed` | The response could not be encoded | +| 500 | `internal_error` | The request handler failed unexpectedly | + +## Compatibility policy + +The `version` field follows a semantic-version-style promise: + +- **Backward-compatible changes keep `version` at `v1`.** Adding a new endpoint, + or adding a new field to an existing payload, is non-breaking. **Consumers + must ignore unknown fields.** +- **Breaking changes bump `version`.** Renaming or removing a field, changing a + field's type, or changing the meaning/units of an existing field requires a new + version, an update to this document, and an update to + `LocalAPIContractTests.swift`. + +There are currently no deprecated fields or endpoints. When a field is +deprecated, it will be listed here with the version in which it becomes eligible +for removal, and will remain present for at least one subsequent version. diff --git a/LICENSE b/LICENSE index fd9f61b..419813e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,12 @@ -MIT License - -Copyright (c) 2026 Christian Cleberg - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +Copyright (C) 2026 krazy warez + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md deleted file mode 100644 index 961560c..0000000 --- a/README.md +++ /dev/null @@ -1,108 +0,0 @@ -# DomainDig - -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=krz_domain-dig&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=krz_domain-dig) -[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=krz_domain-dig&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=krz_domain-dig) -[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=krz_domain-dig&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=krz_domain-dig) -[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=krz_domain-dig&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=krz_domain-dig) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) - -[![App Store Badge](https://krz.sh/storebutton.svg)](https://apps.apple.com/us/app/domaindig/id6760368004) - -DomainDig is a local-first iOS domain inspection toolkit for DNS, web, ownership, monitoring, reporting, and audit workflows. The app gathers a point-in-time domain snapshot, normalizes it into a canonical `DomainReport`, and keeps user data on device unless the user exports, shares, or syncs it. - -## Features - -- Domain inspection for DNS records, email security, TLS certificates, HTTP headers, redirects, IP geolocation, reachability, open ports, RDAP, ownership, subdomain discovery, and availability. -- Canonical `DomainReport` output used by the app UI and exports. -- History snapshots with change summaries, risk scoring, notes, and saved domain context. -- Dashboard, watchlist, monitoring, workflows, batch results, integrations, and data portability screens. -- Audit Mode with sessions, checklist progress, reviewer notes, findings, evidence snapshots, audit timelines, and markdown/json/pdf export. -- Backup and restore for tracked domains, history, audit sessions, workflows, monitoring settings/logs, app settings, and local feature metadata. -- Local-first operation with no required backend. -- Optional local API surface for automation-compatible report output. - -## Data And Privacy - -DomainDig stores local app data in on-device persistence. Backup exports can include tracked domains, lookup history, audit sessions, workflow definitions, monitoring configuration, monitoring logs, app settings, and cached feature metadata. Imports are processed on device. - -Network inspection requests are made only to perform the requested domain checks or configured resolver lookups. The app does not require a hosted DomainDig backend. - -## Development - -### Requirements - -- Xcode with current iOS SDK support -- iOS Simulator or physical iOS device -- Swift/Xcode support for filesystem-synchronized groups used by the project - -### Getting Started - -1. Clone the repository. - ```sh - git clone https://github.com/krazywarez/domain-dig.git - ``` -2. Open `DomainDig.xcodeproj` in Xcode. -3. Select the `DomainDig` scheme. -4. Build and run on a simulator or device. - -### Useful Checks - -```sh -xcodebuild -project DomainDig.xcodeproj -scheme DomainDig -destination 'platform=iOS Simulator,name=iPhone 16' build -``` - -The app and local API share the canonical report pipeline through `DomainInspectionService`, `DomainReportBuilder`, and `DomainReportExporter`. The Local API's endpoints, response envelope, and `v1` compatibility policy are documented in [Docs/local-api.md](Docs/local-api.md). How the on-device store evolves across app versions is documented in [Docs/data-migration.md](Docs/data-migration.md). - -### Tests - -Unit coverage of the deterministic core — `DomainReportBuilder`, `DomainReportExporter`, `DiffService`, `DomainDataPortabilityService` (merge/replace dedup), the migration runner, and the Local API contract — lives in the `DomainDigTests` target: - -```sh -xcodebuild test -project DomainDig.xcodeproj -scheme DomainDig -destination 'platform=iOS Simulator,name=iPhone 16' -only-testing:DomainDigTests -``` - -The `DomainDig` scheme's test action runs both `DomainDigTests` and the `DomainDigUITests` accessibility audit, so a plain `xcodebuild test` (and CI) exercises both. - -### Accessibility Audit - -`DomainDigUITests` runs `performAccessibilityAudit()` over every primary screen, -at default and at the largest accessibility text size. - -```sh -./Scripts/audit-a11y.sh # oldest supported + newest runtime -./Scripts/audit-a11y.sh floor # oldest supported runtime only (~85s) -``` - -Findings are **reported, not enforced** — they are the burndown list for the -accessibility pass. Widen `AccessibilityAuditHarness.enforcedAuditTypes` to turn -a category into a build failure as each phase lands. - -Optional pre-push hook, which runs the floor audit when Swift or project files -change: - -```sh -git config core.hooksPath .githooks -``` - -See [Docs/ACCESSIBILITY.md](Docs/ACCESSIBILITY.md) for why coverage is split -between this script and CI. - -## Release Planning - -See `RELEASE_ROADMAP.md` for the semver release history from `v4.4.1` through the `v5.0.0` contract-stabilization milestone. - -## Contributing - -Contributions are welcome. Keep changes scoped, include tests or build verification when behavior changes, and open a pull request with a clear summary of user-facing impact. - -## Security - -If you discover a security issue, see `SECURITY.md`. - -## License - -This project is licensed under the MIT License. See `LICENSE`. - -## Contact - -Questions or feedback: root@krz.sh diff --git a/README.nfo b/README.nfo new file mode 100644 index 0000000..3b26ee7 --- /dev/null +++ b/README.nfo @@ -0,0 +1,74 @@ +┌──────────────────────────────────────────────────────────────┐ +│ D O M A I N D I G [ KRZ ] krz.sh │ +└──────────────────────────────────────────────────────────────┘ + +WHAT + local-first ios domain inspection toolkit. dns, web, ownership, + monitoring, reporting, audit. takes a point-in-time snapshot of a + domain, normalizes it into a DomainReport, and keeps data on device + unless you export, share, or sync it. + +DOES + - inspects dns records, email security, tls certs, http headers, + redirects, ip geolocation, reachability, open ports, rdap, + ownership, subdomains, availability + - DomainReport output used by the ui and exports + - history snapshots with change summaries, risk scoring, notes + - dashboard, watchlist, monitoring, workflows, batch results, + integrations, data portability + - audit mode: sessions, checklists, reviewer notes, findings, + evidence snapshots, timelines, markdown/json/pdf export + - backup and restore for tracked domains, history, audit sessions, + workflows, monitoring, settings, feature metadata + - optional local api for automation-compatible output + +PRIVACY + app data lives in on-device storage. network requests go out only + to run the checks you asked for or the resolver you configured. no + hosted backend required. + +BUILD + needs xcode with a current ios sdk and a simulator or device. + + git clone https://github.com/krazywarez/domain-dig.git + + open DomainDig.xcodeproj, pick the DomainDig scheme, build and run. + + build check: + + xcodebuild -project DomainDig.xcodeproj -scheme DomainDig \ + -destination 'platform=iOS Simulator,name=iPhone 16' build + +TESTS + deterministic core coverage lives in DomainDigTests: + + xcodebuild test -project DomainDig.xcodeproj -scheme DomainDig \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + -only-testing:DomainDigTests + + the scheme's test action also runs the DomainDigUITests + accessibility audit. + + ./Scripts/audit-a11y.sh # oldest supported + newest + ./Scripts/audit-a11y.sh floor # oldest only (~85s) + + findings are reported, not enforced. optional pre-push hook: + + git config core.hooksPath .githooks + +DOCS + local api: Docs/local-api.txt + data migration: Docs/data-migration.txt + accessibility: Docs/ACCESSIBILITY.txt + releases: RELEASE_ROADMAP.txt (v4.4.1 -> v5.0.0) + security: SECURITY.txt + +LICENSE + 0bsd. see LICENSE. + +CONTACT + root@krz.sh + +┌──────────────────────────────────────────────────────────────┐ +│ krz.sh │ +└──────────────────────────────────────────────────────────────┘ diff --git a/RELEASE_ROADMAP.md b/RELEASE_ROADMAP.md deleted file mode 100644 index 1e6af13..0000000 --- a/RELEASE_ROADMAP.md +++ /dev/null @@ -1,346 +0,0 @@ -# DomainDig Release Roadmap - -Priority lens: **new user-facing features.** The inspection engine is already -deep (DNS, DNSSEC, CAA, TLS, TLSA/DANE, email security incl. BIMI/MTA-STS, RDAP, -ports, geolocation, subdomains, availability). The next several releases invest -in *reach and surfacing* — getting that data onto more iOS surfaces and into more -workflows — rather than adding raw protocol checks. - -Current version: `v5.0.2`. - -## v4.4.1 Patch: Release Readiness — ✅ shipped - -- Consolidated Audit Mode onto the single `DomainDig/DomainDig/Audit*` - implementation and retired the prototype files. -- Aligned `AppVersion.current`, Xcode marketing version, and build number. -- Included audit sessions in backup/restore counts, summaries, and merge behavior. -- Removed the retired `DomainDigCLI` target and refreshed README/architecture docs. - -## v4.5.0 Minor: Home Screen & Shortcuts Reach — ✅ shipped - -Goal: put DomainDig data and actions where the user already is. - -- **App Intents / Shortcuts** — `InspectDomainIntent`, `AddToWatchlistIntent`, and - `RunSweepIntent`, exposed via `DomainDigShortcuts` for Shortcuts, Spotlight, the - Action button, and Siri. -- **`domaindig://` deep links** — `inspect`, `watch`, `domain` (detail), and - `sweep`, routed in `RootTabView`. -- **WidgetKit portfolio widget** (Home Screen small/medium/large) — per-domain - health, certificate countdowns, and portfolio health counts, shared from the app - via an App Group; tapping a domain deep-links into its detail. - -Deferred to a later minor: **Lock Screen accessory widget families** and a richer -per-widget "last change" indicator. - -## v4.6.0 Minor: Alerts, Glances & iPad — ✅ shipped - -Goal: make monitoring and results feel first-class across contexts. - -- **Sweep Live Activity** — a batch/watchlist sweep drives a Live Activity with a - progress bar, current domain, and change/warning counts on the Lock Screen and - in the Dynamic Island (`SweepActivityController` around the batch pipeline). -- **Share extension** (`DomainDigShareExtension`) — "Dig Domain" accepts a web URL - from the system share sheet, extracts the host, and hands it to the app via the - App Group inbox; the app inspects it on next activation. -- **iPad-optimized layout** — `RootTabView` renders a `NavigationSplitView` - (sidebar + detail) in the regular size class and the tab bar in compact. -- **Actionable notifications** — per-domain `threadIdentifier` grouping, a - "Re-inspect" action, and taps that route into the domain's detail. - -Deferred: monitoring-alert Live Activities (only the sweep activity shipped) and -Lock Screen accessory widget families (carried over from v4.5.0). - -## v4.7.0 Minor: Intelligence & Comparison — ✅ shipped - -Goal: help users interpret and organize, not just collect. - -- **Domain-vs-domain comparison** — `DiffService.compare(domainA:domainB:)` - reuses the existing section-diff builders; `DomainCompareView` (Watchlist - toolbar → "Compare Domains") picks two tracked domains and renders the result - with the existing diff section UI. -- **Reputation / blocklist signals** — a new pluggable data source - (`ExternalDataService.reputation(domain:)`, Pro+) mirroring the existing - ownership/DNS-history/pricing enrichment pattern. Ships with no bundled - third-party endpoint; folds a listed status into risk score/factors and - insights, so it rides the existing report and monitoring change-severity - pipeline rather than needing bespoke monitoring wiring. -- **Tags and saved views** for the watchlist — freeform tags per tracked - domain, tag filter chips, and named saved filter/sort/tag presets - (UserDefaults-backed; not yet part of backup/restore). - -## v4.8.0 Minor: Reporting & Sharing — ✅ shipped - -Goal: turn point-in-time snapshots into shareable, scheduled deliverables. - -- **Markdown and PDF export formats** — `DomainExportFormat` gains `.markdown` - and `.pdf` alongside text/csv/json. Markdown reuses the existing text-export - content via a line-based transform (never drifts from the text export); PDF - renders that Markdown via `UIGraphicsPDFRenderer`, mirroring the approach - `AuditExporter` already used for audit sessions. -- **Scheduled report generation** — `ScheduledReportService` / - `ScheduledReportScheduler` (Settings → Scheduled Reports): a BGTaskScheduler- - driven daily/weekly job that builds a markdown/PDF/JSON report bundle for all - tracked domains, writes it locally, logs the run, and notifies when ready. - Mirrors `DomainMonitoringService`'s headless, storage-backed design; gated - behind the same Pro `.automatedMonitoring` capability. -- **Stronger share affordances** — "Export Markdown"/"Export PDF" added to the - single-result, batch, watchlist, and workflow export menus; generated - scheduled reports are individually shareable from their log. -- **Export consistency verified** — the local API already serves the canonical - `DomainReport` directly (no field allowlist), so `reputation`, `domainPricing`, - and every other field added since v4.7.0 already flow through automatically. - No code change was needed there. - -Deferred/scoped out: scheduled-report settings and logs are UserDefaults-only -(not part of `DomainDataPortabilityService` backup/restore), same reasoning as -v4.7.0's watchlist saved views — this is local automation config, not -user-authored content. - -## v4.8.1 Patch: Reporting & Sharing Fixes — ✅ shipped - -Goal: fix what UAT of v4.8.0 turned up. - -- **Scheduled reports were unreachable manually** — the Overview section wrapped - every control in a single `VStack` inside one `List` row, so SwiftUI collapsed - them into one tap target and the Cadence `Picker` captured taps meant for - "Generate Now". Each control is now its own row. -- **Pro gate completed on that screen** — `.automatedMonitoring` previously - disabled only the toggle, leaving both pickers and "Generate Now" interactive - on Free where they silently no-opped against the service-side guard. -- **Markdown/PDF reports rendered `=` underlines as bullets** — the plain-text - transform only recognized `-`, so `batchText`'s title underline and its - 48-character inter-report separators leaked through as literal list items. -- **Duplicate DNS record values** — the report concatenated apex and wildcard - records without dedup, listing every value twice on domains with wildcard DNS. -- **Inspect tab keyboard behavior** — removed the "Dismiss Keyboard" toolbar - button and the launch-time focus that raised the keyboard on app open. -- **In-app purchases were unbuyable** — none of the four product ID constants in - `PurchaseService` matched the auto-renewable subscriptions configured in App - Store Connect, so `Product.products(for:)` returned nothing and `tier(for:)` - resolved every purchase to `.free`. Product IDs are permanent once created, so - the constants were corrected to match the store rather than the reverse. -- **Local StoreKit testing** — added `DomainDig.storekit` mirroring the App Store - Connect group (Pro+ at level 1, Pro at level 2) and wired it into the Run - action, so the purchase and entitlement paths can be exercised without the - `DOMAIN_DIG_FORCE_PRO_PLUS` launch argument that bypasses StoreKit entirely. - -Follow-ups filed during UAT (#8, #9, #10) were all resolved in v4.8.2. - -## v4.8.2 Patch: Delivery Visibility & Build Health — ✅ shipped - -Goal: close the UAT follow-ups and make failures legible instead of silent. - -- **Disabled integrations no longer swallow events** (#8) — `enqueue(events:)` - filtered to enabled targets before writing any `DeliveryRecord`, so events - routed to a disabled integration vanished entirely. They now log a `.skipped` - entry with a reason. `sendTest` also respects `isEnabled`, which previously - delivered against targets that dropped every real event. -- **"Process Queue Now" forces backed-off retries** (#9) — it only restarted the - processing task, never moving `nextAttemptAt`, so an item in backoff (up to an - hour) stayed undue and the button appeared inert. It now pulls queued items - forward, and reports an empty queue instead of doing nothing silently. -- **Unreachable domains report as unreachable** (#10) — when the snapshot - fallback fired, the run compared old data against itself and claimed "No - meaningful changes" for a domain it never reached. `MonitoringDomainResult` - now carries `unreachableReason`, the summary says so, and a warning-severity - `monitoringFailure` reaches configured integrations. -- **Swift 6 concurrency warnings cleared** — `SweepActivityAttributes` is - explicitly `nonisolated` (the app target sets - `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` while the widget target does not), - and `LocalAPIService`'s logger closures capture `self` coherently. Build is - warning-free. -- **StoreKit configuration corrected and synced** — the scheme's path was wrong, - and the hand-authored file has been replaced by `SyncedProducts.storekit`, - synced against App Store Connect. Registered in the project without target - membership so it is not bundled into shipping builds. - -## v4.8.3 Patch: Static Analysis Cleanup — ✅ shipped - -Goal: clear the SonarCloud new-code backlog without changing behavior. - -- **Dead confidence conditionals fixed** (4 bugs) — - `DomainInspectionService`'s `confidenceFor*` helpers each returned - `error == nil ? .low : .low`. The conditional was inert, so the unused `error` - parameter was dropped alongside it. -- **Identical switch branches merged** — 14 sites in `DomainViewModel` handled - `.empty(message)` and `.error(message)` with byte-identical bodies; they now - share one `case let .empty(message), let .error(message):`. -- **Duplicate implementations consolidated** — `clearPresentedResults()` now - delegates to `reset()`, `String.nonEmpty` was folded into `nilIfEmpty`, and - `ExportFormat.id` derives from `fileExtension`. -- **Nested ternaries extracted** — grade-to-tone and impact-to-color mappings - became `TLSGrade.tone`, `EmailSecurityGrade.tone`, and - `ChangeImpactClassification.color`, replacing `ContentView`'s private - `impactColor` and the duplicate mapping in `BatchResultsView`. -- **Remaining smells** — empty closures and singleton inits documented, unused - protocol-conformance parameters marked `_`, `CloudSyncTrigger.import` renamed - to `imported` (raw value preserved), `_serverTrust`/`_tlsMetadata` renamed, - nested `if`s merged in the DER parser, and deep closure nesting flattened in - `PortScanService` and `IntegrationService`. - -Left open deliberately: `swift:S107` (initializer parameter counts on model -memberwise inits), `swift:S115` (constants mirroring DoH/ipapi JSON keys), -`swift:S1075` (false positives on `https://` literals), and two `swift:S117` -hits on SwiftUI `$binding` shorthand in `AuditModeView`, which cannot be -renamed. These want a *Won't Fix* / *Safe* resolution in SonarCloud rather than -a code change. - -## v4.9.0 Minor: Accessibility, Appearance & Engineering Health — ✅ shipped - -Goal: make the app usable by every iOS user — full accessibility pass (#21), -light mode, and the engineering scaffolding to keep both from regressing. - -- **Semantic colour system** — every hard-coded colour replaced with adaptive - colorsets in `Shared/Colors.xcassets` (Any/Dark + High Contrast variants), - shared by app, widget, and share extension via the synchronized `Shared` - group. Every status colour clears WCAG AA on its page, its card, and its - badge surface, in both schemes; measured, not asserted. The accent is now - blue (`#0000FF` light / `#4DA3FF` dark), split into foreground - (`StatusInfo`), fill (`AccentFill`), and on-fill (`AppOnAccent`) roles - because one value cannot serve as both text-on-dark and fill-behind-white. - `AppStatusTone` pairs each status foreground with an authored surface. -- **Light mode unlocked** — the 16 scattered `.preferredColorScheme(.dark)` - calls removed; appearance (System/Light/Dark) is applied once at the - `WindowGroup` and exposed under Settings → Display. `.secondary` (3.29:1 on a - light card) replaced with `AppTextSecondary` across 191 sites. -- **Dynamic Type & reflow** — `Label`-clipped empty-state titles fixed, the - 44pt tap-target floor enforced (`AppCopyButton` was 30×30; - `controlMinHeight` was 42), `CardView`'s horizontal-scroll default flipped - to reflow, dense rows (`WatchlistRowView`, `BatchResultRowView`, - `PortfolioExpiryRow`) and the collapsible section headers rebuilt on - `ViewThatFits` so badges and buttons can never letter-wrap vertically, and - the widget clamped at `accessibility1` (fixed canvas, no scroll). -- **VoiceOver** — labels on every icon-only control (label-in-name preserved - for Voice Control), selected-state on all toggles, badges read as one word, - heading-rotor navigation, dense rows collapsed to one element with the - detail on the More Content rotor (`accessibilityCustomContent`), technical - strings (DNS records, cipher suites) spoken with punctuation, and lookup/ - sweep completion announcements. Widget rows read as a single phrase. -- **Colour independence, motion, transparency** — widget status uses the badge - symbol vocabulary instead of colour-only dots; `differentiateWithoutColor` - adds symbols/borders on demand; all five animation sites honour - `reduceMotion`; the one material honours `reduceTransparency`. -- **Accessibility audit harness** — `DomainDigUITests` runs - `performAccessibilityAudit()` over every primary screen at default and - AccessibilityXXXL, on CI (newest runtime, clean merge-result checkout) and - locally (`Scripts/audit-a11y.sh`, real floor runtime, wired to an opt-in - pre-push hook). `DOMAIN_DIG_SEED_FIXTURES` seeds deterministic in-memory - rows so the dense paths actually render under audit. The **enforcement - ratchet is engaged**: named findings in six categories fail CI, with - narrowly characterised, always-logged noise suppressions. Findings burndown - 20 → 11, with every remaining item characterised as system noise. -- **Phase 6 verification** — the simulator-executable half of the manual pass - was run and converted into permanent tests: `AccessibilityMetadataTests` - asserts the icon-only control labels, toggle selected-states, and dense-row - label/value pairs; `AccessibilityScreenshotTests` captures both appearances - across classic chrome and Liquid Glass. A middle-band Dynamic Type sweep was - added after two real layout bugs turned up *between* the default and - AccessibilityXXXL test points. The pass also caught a genuine enforced - `.dynamicType` failure on iOS 27.0 against UIKit-rendered Settings section - headers; the app applies no font to those, so it is carved out by exact - header title, scoped to that one audit type. -- **Swift 6 language mode** (#27) — all three product targets build under - `SWIFT_VERSION = 6.0` with zero warnings. `SMTPChannel` became an actor - (fixing a real `CheckedContinuation` double-resume hazard), - `SweepActivityController` stores a Sendable activity id, and the remaining - isolation issues were resolved layer by layer. The UITests target stays on - Swift 5 (XCTest override isolation), recorded as a decision. -- **Project hygiene** — the misleading project-level deployment target - (26.2 shadowing the real 17.6) reconciled; CI selects simulators - floor-aware instead of first-match. - -Deferred — genuinely physical-device-only, since the Simulator cannot run -VoiceOver or Voice Control at all: - -- **VoiceOver speech**, the More Content rotor, custom-content ordering, and the - spoken lookup/sweep announcements. The underlying metadata (labels, values, - selected states) *is* asserted in `AccessibilityMetadataTests`; what remains - unverified is how it is spoken. -- **Voice Control** activation of every control by its printed label (WCAG - 2.5.3). -- **iPad Full Keyboard Access** focus order across the split layout. -- **Smart Invert**. - -The Liquid Glass (iOS 26+) runtime check is **done** — it ran on simulator -alongside the classic-chrome floor. - -## v5.0.0 Major: Contract Stabilization & Engineering Health — ✅ shipped - -Goal: earn long-term compatibility promises — and pay down the debt the feature -releases above accumulated. All four workstreams landed on `main` behind the new -test net. - -- **Deterministic-core test net — done first**, as the cross-cutting note below - required. Added `DomainDigTests`, the project's first XCTest unit target, - hosted by the app with `@testable import`. `SnapshotFixture` builds the deep - `LookupSnapshot`/`DomainReport` models through the real builder; 58 tests cover - `DiffService`, `DomainReportBuilder`, `DomainReportExporter`, - `DomainDataPortabilityService` (merge/replace dedup), the migration runner, and - the Local API contract. Runs in CI and the pre-push hook. (#43) -- **Local API `v1` contract stabilized.** `LocalAPIContract` is the single source - of truth for the wire version and JSON encoder; the response envelope and every - payload are promoted to a first-class, documented contract. - `Docs/local-api.md` documents each endpoint, the envelope, the encoding - conventions (notably: absent optionals are omitted, not null), and a - semantic-version compatibility policy. 16 golden structure tests pin the JSON - shape so a renamed/removed field fails CI. (#45) -- **Versioned store-migration policy** for persisted snapshots, backups, audits, - workflows, and settings. `DataMigrationService` became a forward-only, - idempotent, never-downgrades runner keyed by an integer store schema version, - replacing the one-shot boolean marker. `Docs/data-migration.md` documents the - policy, the two independent version lines (on-device store vs. backup export), - and when to use lenient decoding vs. a migration step; legacy-fixture tests - cover it. (#46) -- **God-files decomposed** behind that test net, one behavior-preserving slice - per PR (each built clean with 58/58 tests, no logic changes): - - `DomainViewModel.swift` **4864 → 4170 lines** — audit, monitoring, export, - workflow, and history surfaces moved to `DomainViewModel+*.swift` - extensions. (#47–#51) - - `ContentView.swift` **3881 → 1625 lines** — Settings screens to - `SettingsViews.swift` and the nine result section views to - `ResultSectionViews.swift`. (#52–#53) - - Left in place deliberately: the tightly-coupled inspection core (the section - runners, `performLookup`/`saveHistoryEntry`, batch orchestration) and a few - remaining `ContentView` cards/primitives. Splitting the inspection core further - is a *design* change — extracting a collaborator object — not a mechanical move, - so it is deferred rather than forced through visibility promotions. - -Release cut: `MARKETING_VERSION` 4.9.0 → 5.0.0, `CURRENT_PROJECT_VERSION` -44 → 45, and `AppVersion.current` bumped in lockstep. App Store archive/submit is -the only step left, and it is a manual action outside the repo. - -## v5.0.1 Patch: Owner entitlement — ✅ shipped - -- **Owner Pro+ allowlist.** `OwnerAccess` identifies the app owner by their - CloudKit user-record ID (an opaque, per-Apple-ID value scoped to the app's - container). `PurchaseService` resolves it against CloudKit once per launch and, - on a match, grants `.proPlus` — persisted so it applies instantly and offline - thereafter. It only ever elevates the tier and defers to the existing `#if - DEBUG` overrides, so real purchases and free/pro testing are unaffected. -- Release cut: `MARKETING_VERSION` 5.0.0 → 5.0.1, `CURRENT_PROJECT_VERSION` - 45 → 46, `AppVersion.current` in lockstep. - -## v5.0.2 Patch: krazywarez migration — ✅ shipped - -- **Org/domain migration.** Moved all outward-facing references from - `zerolabsco`/`zerolabs.sh` to `krazywarez`/`krz.sh`: source, documentation, - privacy, and support links in `AppLinks`, the support email (`root@krz.sh`), - the SonarCloud project badges, the `DomainDebugLog` logging subsystem, and the - docs (`README`, `SECURITY`, `ACCESSIBILITY`). Stable identity — bundle IDs, App - Group, iCloud/CloudKit container, and background-task identifiers — stays on - `net.cleberg.DomainDig`, tied to the existing App Store listing. -- Release cut: `MARKETING_VERSION` 5.0.1 → 5.0.2, `CURRENT_PROJECT_VERSION` - 46 → 47, `AppVersion.current` in lockstep. - -## Cross-cutting note - -New feature surfaces (widgets, intents, extensions) each add a target and a -persistence/entitlement seam. Through v4.9.0 the project had **no XCTest unit -target** — v4.5.0 through v4.7.0 all shipped without the characterization-test -safety net originally recommended before v4.7.0, and that gap only grew -(comparison, reputation, and tags/saved-views all touch persisted models with -hand-written backward-compatible decoders). v5.0.0 closed it first: the -`DomainDigTests` deterministic-core net went in before anything else, which is -what made stabilizing the external contracts and decomposing the god-files safe -to attempt. diff --git a/RELEASE_ROADMAP.txt b/RELEASE_ROADMAP.txt new file mode 100644 index 0000000..dd2ca6a --- /dev/null +++ b/RELEASE_ROADMAP.txt @@ -0,0 +1,346 @@ +# DomainDig Release Roadmap + +Priority lens: **new user-facing features.** The inspection engine is already +deep (DNS, DNSSEC, CAA, TLS, TLSA/DANE, email security incl. BIMI/MTA-STS, RDAP, +ports, geolocation, subdomains, availability). The next several releases invest +in *reach and surfacing* — getting that data onto more iOS surfaces and into more +workflows — rather than adding raw protocol checks. + +Current version: `v5.0.2`. + +## v4.4.1 Patch: Release Readiness — ✅ shipped + +- Consolidated Audit Mode onto the single `DomainDig/DomainDig/Audit*` + implementation and retired the prototype files. +- Aligned `AppVersion.current`, Xcode marketing version, and build number. +- Included audit sessions in backup/restore counts, summaries, and merge behavior. +- Removed the retired `DomainDigCLI` target and refreshed README/architecture docs. + +## v4.5.0 Minor: Home Screen & Shortcuts Reach — ✅ shipped + +Goal: put DomainDig data and actions where the user already is. + +- **App Intents / Shortcuts** — `InspectDomainIntent`, `AddToWatchlistIntent`, and + `RunSweepIntent`, exposed via `DomainDigShortcuts` for Shortcuts, Spotlight, the + Action button, and Siri. +- **`domaindig://` deep links** — `inspect`, `watch`, `domain` (detail), and + `sweep`, routed in `RootTabView`. +- **WidgetKit portfolio widget** (Home Screen small/medium/large) — per-domain + health, certificate countdowns, and portfolio health counts, shared from the app + via an App Group; tapping a domain deep-links into its detail. + +Deferred to a later minor: **Lock Screen accessory widget families** and a richer +per-widget "last change" indicator. + +## v4.6.0 Minor: Alerts, Glances & iPad — ✅ shipped + +Goal: make monitoring and results feel first-class across contexts. + +- **Sweep Live Activity** — a batch/watchlist sweep drives a Live Activity with a + progress bar, current domain, and change/warning counts on the Lock Screen and + in the Dynamic Island (`SweepActivityController` around the batch pipeline). +- **Share extension** (`DomainDigShareExtension`) — "Dig Domain" accepts a web URL + from the system share sheet, extracts the host, and hands it to the app via the + App Group inbox; the app inspects it on next activation. +- **iPad-optimized layout** — `RootTabView` renders a `NavigationSplitView` + (sidebar + detail) in the regular size class and the tab bar in compact. +- **Actionable notifications** — per-domain `threadIdentifier` grouping, a + "Re-inspect" action, and taps that route into the domain's detail. + +Deferred: monitoring-alert Live Activities (only the sweep activity shipped) and +Lock Screen accessory widget families (carried over from v4.5.0). + +## v4.7.0 Minor: Intelligence & Comparison — ✅ shipped + +Goal: help users interpret and organize, not just collect. + +- **Domain-vs-domain comparison** — `DiffService.compare(domainA:domainB:)` + reuses the existing section-diff builders; `DomainCompareView` (Watchlist + toolbar → "Compare Domains") picks two tracked domains and renders the result + with the existing diff section UI. +- **Reputation / blocklist signals** — a new pluggable data source + (`ExternalDataService.reputation(domain:)`, Pro+) mirroring the existing + ownership/DNS-history/pricing enrichment pattern. Ships with no bundled + third-party endpoint; folds a listed status into risk score/factors and + insights, so it rides the existing report and monitoring change-severity + pipeline rather than needing bespoke monitoring wiring. +- **Tags and saved views** for the watchlist — freeform tags per tracked + domain, tag filter chips, and named saved filter/sort/tag presets + (UserDefaults-backed; not yet part of backup/restore). + +## v4.8.0 Minor: Reporting & Sharing — ✅ shipped + +Goal: turn point-in-time snapshots into shareable, scheduled deliverables. + +- **Markdown and PDF export formats** — `DomainExportFormat` gains `.markdown` + and `.pdf` alongside text/csv/json. Markdown reuses the existing text-export + content via a line-based transform (never drifts from the text export); PDF + renders that Markdown via `UIGraphicsPDFRenderer`, mirroring the approach + `AuditExporter` already used for audit sessions. +- **Scheduled report generation** — `ScheduledReportService` / + `ScheduledReportScheduler` (Settings → Scheduled Reports): a BGTaskScheduler- + driven daily/weekly job that builds a markdown/PDF/JSON report bundle for all + tracked domains, writes it locally, logs the run, and notifies when ready. + Mirrors `DomainMonitoringService`'s headless, storage-backed design; gated + behind the same Pro `.automatedMonitoring` capability. +- **Stronger share affordances** — "Export Markdown"/"Export PDF" added to the + single-result, batch, watchlist, and workflow export menus; generated + scheduled reports are individually shareable from their log. +- **Export consistency verified** — the local API already serves the canonical + `DomainReport` directly (no field allowlist), so `reputation`, `domainPricing`, + and every other field added since v4.7.0 already flow through automatically. + No code change was needed there. + +Deferred/scoped out: scheduled-report settings and logs are UserDefaults-only +(not part of `DomainDataPortabilityService` backup/restore), same reasoning as +v4.7.0's watchlist saved views — this is local automation config, not +user-authored content. + +## v4.8.1 Patch: Reporting & Sharing Fixes — ✅ shipped + +Goal: fix what UAT of v4.8.0 turned up. + +- **Scheduled reports were unreachable manually** — the Overview section wrapped + every control in a single `VStack` inside one `List` row, so SwiftUI collapsed + them into one tap target and the Cadence `Picker` captured taps meant for + "Generate Now". Each control is now its own row. +- **Pro gate completed on that screen** — `.automatedMonitoring` previously + disabled only the toggle, leaving both pickers and "Generate Now" interactive + on Free where they silently no-opped against the service-side guard. +- **Markdown/PDF reports rendered `=` underlines as bullets** — the plain-text + transform only recognized `-`, so `batchText`'s title underline and its + 48-character inter-report separators leaked through as literal list items. +- **Duplicate DNS record values** — the report concatenated apex and wildcard + records without dedup, listing every value twice on domains with wildcard DNS. +- **Inspect tab keyboard behavior** — removed the "Dismiss Keyboard" toolbar + button and the launch-time focus that raised the keyboard on app open. +- **In-app purchases were unbuyable** — none of the four product ID constants in + `PurchaseService` matched the auto-renewable subscriptions configured in App + Store Connect, so `Product.products(for:)` returned nothing and `tier(for:)` + resolved every purchase to `.free`. Product IDs are permanent once created, so + the constants were corrected to match the store rather than the reverse. +- **Local StoreKit testing** — added `DomainDig.storekit` mirroring the App Store + Connect group (Pro+ at level 1, Pro at level 2) and wired it into the Run + action, so the purchase and entitlement paths can be exercised without the + `DOMAIN_DIG_FORCE_PRO_PLUS` launch argument that bypasses StoreKit entirely. + +Follow-ups filed during UAT (#8, #9, #10) were all resolved in v4.8.2. + +## v4.8.2 Patch: Delivery Visibility & Build Health — ✅ shipped + +Goal: close the UAT follow-ups and make failures legible instead of silent. + +- **Disabled integrations no longer swallow events** (#8) — `enqueue(events:)` + filtered to enabled targets before writing any `DeliveryRecord`, so events + routed to a disabled integration vanished entirely. They now log a `.skipped` + entry with a reason. `sendTest` also respects `isEnabled`, which previously + delivered against targets that dropped every real event. +- **"Process Queue Now" forces backed-off retries** (#9) — it only restarted the + processing task, never moving `nextAttemptAt`, so an item in backoff (up to an + hour) stayed undue and the button appeared inert. It now pulls queued items + forward, and reports an empty queue instead of doing nothing silently. +- **Unreachable domains report as unreachable** (#10) — when the snapshot + fallback fired, the run compared old data against itself and claimed "No + meaningful changes" for a domain it never reached. `MonitoringDomainResult` + now carries `unreachableReason`, the summary says so, and a warning-severity + `monitoringFailure` reaches configured integrations. +- **Swift 6 concurrency warnings cleared** — `SweepActivityAttributes` is + explicitly `nonisolated` (the app target sets + `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` while the widget target does not), + and `LocalAPIService`'s logger closures capture `self` coherently. Build is + warning-free. +- **StoreKit configuration corrected and synced** — the scheme's path was wrong, + and the hand-authored file has been replaced by `SyncedProducts.storekit`, + synced against App Store Connect. Registered in the project without target + membership so it is not bundled into shipping builds. + +## v4.8.3 Patch: Static Analysis Cleanup — ✅ shipped + +Goal: clear the SonarCloud new-code backlog without changing behavior. + +- **Dead confidence conditionals fixed** (4 bugs) — + `DomainInspectionService`'s `confidenceFor*` helpers each returned + `error == nil ? .low : .low`. The conditional was inert, so the unused `error` + parameter was dropped alongside it. +- **Identical switch branches merged** — 14 sites in `DomainViewModel` handled + `.empty(message)` and `.error(message)` with byte-identical bodies; they now + share one `case let .empty(message), let .error(message):`. +- **Duplicate implementations consolidated** — `clearPresentedResults()` now + delegates to `reset()`, `String.nonEmpty` was folded into `nilIfEmpty`, and + `ExportFormat.id` derives from `fileExtension`. +- **Nested ternaries extracted** — grade-to-tone and impact-to-color mappings + became `TLSGrade.tone`, `EmailSecurityGrade.tone`, and + `ChangeImpactClassification.color`, replacing `ContentView`'s private + `impactColor` and the duplicate mapping in `BatchResultsView`. +- **Remaining smells** — empty closures and singleton inits documented, unused + protocol-conformance parameters marked `_`, `CloudSyncTrigger.import` renamed + to `imported` (raw value preserved), `_serverTrust`/`_tlsMetadata` renamed, + nested `if`s merged in the DER parser, and deep closure nesting flattened in + `PortScanService` and `IntegrationService`. + +Left open deliberately: `swift:S107` (initializer parameter counts on model +memberwise inits), `swift:S115` (constants mirroring DoH/ipapi JSON keys), +`swift:S1075` (false positives on `https://` literals), and two `swift:S117` +hits on SwiftUI `$binding` shorthand in `AuditModeView`, which cannot be +renamed. These want a *Won't Fix* / *Safe* resolution in SonarCloud rather than +a code change. + +## v4.9.0 Minor: Accessibility, Appearance & Engineering Health — ✅ shipped + +Goal: make the app usable by every iOS user — full accessibility pass (#21), +light mode, and the engineering scaffolding to keep both from regressing. + +- **Semantic colour system** — every hard-coded colour replaced with adaptive + colorsets in `Shared/Colors.xcassets` (Any/Dark + High Contrast variants), + shared by app, widget, and share extension via the synchronized `Shared` + group. Every status colour clears WCAG AA on its page, its card, and its + badge surface, in both schemes; measured, not asserted. The accent is now + blue (`#0000FF` light / `#4DA3FF` dark), split into foreground + (`StatusInfo`), fill (`AccentFill`), and on-fill (`AppOnAccent`) roles + because one value cannot serve as both text-on-dark and fill-behind-white. + `AppStatusTone` pairs each status foreground with an authored surface. +- **Light mode unlocked** — the 16 scattered `.preferredColorScheme(.dark)` + calls removed; appearance (System/Light/Dark) is applied once at the + `WindowGroup` and exposed under Settings → Display. `.secondary` (3.29:1 on a + light card) replaced with `AppTextSecondary` across 191 sites. +- **Dynamic Type & reflow** — `Label`-clipped empty-state titles fixed, the + 44pt tap-target floor enforced (`AppCopyButton` was 30×30; + `controlMinHeight` was 42), `CardView`'s horizontal-scroll default flipped + to reflow, dense rows (`WatchlistRowView`, `BatchResultRowView`, + `PortfolioExpiryRow`) and the collapsible section headers rebuilt on + `ViewThatFits` so badges and buttons can never letter-wrap vertically, and + the widget clamped at `accessibility1` (fixed canvas, no scroll). +- **VoiceOver** — labels on every icon-only control (label-in-name preserved + for Voice Control), selected-state on all toggles, badges read as one word, + heading-rotor navigation, dense rows collapsed to one element with the + detail on the More Content rotor (`accessibilityCustomContent`), technical + strings (DNS records, cipher suites) spoken with punctuation, and lookup/ + sweep completion announcements. Widget rows read as a single phrase. +- **Colour independence, motion, transparency** — widget status uses the badge + symbol vocabulary instead of colour-only dots; `differentiateWithoutColor` + adds symbols/borders on demand; all five animation sites honour + `reduceMotion`; the one material honours `reduceTransparency`. +- **Accessibility audit harness** — `DomainDigUITests` runs + `performAccessibilityAudit()` over every primary screen at default and + AccessibilityXXXL, on CI (newest runtime, clean merge-result checkout) and + locally (`Scripts/audit-a11y.sh`, real floor runtime, wired to an opt-in + pre-push hook). `DOMAIN_DIG_SEED_FIXTURES` seeds deterministic in-memory + rows so the dense paths actually render under audit. The **enforcement + ratchet is engaged**: named findings in six categories fail CI, with + narrowly characterised, always-logged noise suppressions. Findings burndown + 20 → 11, with every remaining item characterised as system noise. +- **Phase 6 verification** — the simulator-executable half of the manual pass + was run and converted into permanent tests: `AccessibilityMetadataTests` + asserts the icon-only control labels, toggle selected-states, and dense-row + label/value pairs; `AccessibilityScreenshotTests` captures both appearances + across classic chrome and Liquid Glass. A middle-band Dynamic Type sweep was + added after two real layout bugs turned up *between* the default and + AccessibilityXXXL test points. The pass also caught a genuine enforced + `.dynamicType` failure on iOS 27.0 against UIKit-rendered Settings section + headers; the app applies no font to those, so it is carved out by exact + header title, scoped to that one audit type. +- **Swift 6 language mode** (#27) — all three product targets build under + `SWIFT_VERSION = 6.0` with zero warnings. `SMTPChannel` became an actor + (fixing a real `CheckedContinuation` double-resume hazard), + `SweepActivityController` stores a Sendable activity id, and the remaining + isolation issues were resolved layer by layer. The UITests target stays on + Swift 5 (XCTest override isolation), recorded as a decision. +- **Project hygiene** — the misleading project-level deployment target + (26.2 shadowing the real 17.6) reconciled; CI selects simulators + floor-aware instead of first-match. + +Deferred — genuinely physical-device-only, since the Simulator cannot run +VoiceOver or Voice Control at all: + +- **VoiceOver speech**, the More Content rotor, custom-content ordering, and the + spoken lookup/sweep announcements. The underlying metadata (labels, values, + selected states) *is* asserted in `AccessibilityMetadataTests`; what remains + unverified is how it is spoken. +- **Voice Control** activation of every control by its printed label (WCAG + 2.5.3). +- **iPad Full Keyboard Access** focus order across the split layout. +- **Smart Invert**. + +The Liquid Glass (iOS 26+) runtime check is **done** — it ran on simulator +alongside the classic-chrome floor. + +## v5.0.0 Major: Contract Stabilization & Engineering Health — ✅ shipped + +Goal: earn long-term compatibility promises — and pay down the debt the feature +releases above accumulated. All four workstreams landed on `main` behind the new +test net. + +- **Deterministic-core test net — done first**, as the cross-cutting note below + required. Added `DomainDigTests`, the project's first XCTest unit target, + hosted by the app with `@testable import`. `SnapshotFixture` builds the deep + `LookupSnapshot`/`DomainReport` models through the real builder; 58 tests cover + `DiffService`, `DomainReportBuilder`, `DomainReportExporter`, + `DomainDataPortabilityService` (merge/replace dedup), the migration runner, and + the Local API contract. Runs in CI and the pre-push hook. (#43) +- **Local API `v1` contract stabilized.** `LocalAPIContract` is the single source + of truth for the wire version and JSON encoder; the response envelope and every + payload are promoted to a first-class, documented contract. + `Docs/local-api.txt` documents each endpoint, the envelope, the encoding + conventions (notably: absent optionals are omitted, not null), and a + semantic-version compatibility policy. 16 golden structure tests pin the JSON + shape so a renamed/removed field fails CI. (#45) +- **Versioned store-migration policy** for persisted snapshots, backups, audits, + workflows, and settings. `DataMigrationService` became a forward-only, + idempotent, never-downgrades runner keyed by an integer store schema version, + replacing the one-shot boolean marker. `Docs/data-migration.txt` documents the + policy, the two independent version lines (on-device store vs. backup export), + and when to use lenient decoding vs. a migration step; legacy-fixture tests + cover it. (#46) +- **God-files decomposed** behind that test net, one behavior-preserving slice + per PR (each built clean with 58/58 tests, no logic changes): + - `DomainViewModel.swift` **4864 → 4170 lines** — audit, monitoring, export, + workflow, and history surfaces moved to `DomainViewModel+*.swift` + extensions. (#47–#51) + - `ContentView.swift` **3881 → 1625 lines** — Settings screens to + `SettingsViews.swift` and the nine result section views to + `ResultSectionViews.swift`. (#52–#53) + + Left in place deliberately: the tightly-coupled inspection core (the section + runners, `performLookup`/`saveHistoryEntry`, batch orchestration) and a few + remaining `ContentView` cards/primitives. Splitting the inspection core further + is a *design* change — extracting a collaborator object — not a mechanical move, + so it is deferred rather than forced through visibility promotions. + +Release cut: `MARKETING_VERSION` 4.9.0 → 5.0.0, `CURRENT_PROJECT_VERSION` +44 → 45, and `AppVersion.current` bumped in lockstep. App Store archive/submit is +the only step left, and it is a manual action outside the repo. + +## v5.0.1 Patch: Owner entitlement — ✅ shipped + +- **Owner Pro+ allowlist.** `OwnerAccess` identifies the app owner by their + CloudKit user-record ID (an opaque, per-Apple-ID value scoped to the app's + container). `PurchaseService` resolves it against CloudKit once per launch and, + on a match, grants `.proPlus` — persisted so it applies instantly and offline + thereafter. It only ever elevates the tier and defers to the existing `#if + DEBUG` overrides, so real purchases and free/pro testing are unaffected. +- Release cut: `MARKETING_VERSION` 5.0.0 → 5.0.1, `CURRENT_PROJECT_VERSION` + 45 → 46, `AppVersion.current` in lockstep. + +## v5.0.2 Patch: krazywarez migration — ✅ shipped + +- **Org/domain migration.** Moved all outward-facing references from + `zerolabsco`/`zerolabs.sh` to `krazywarez`/`krz.sh`: source, documentation, + privacy, and support links in `AppLinks`, the support email (`root@krz.sh`), + the SonarCloud project badges, the `DomainDebugLog` logging subsystem, and the + docs (`README`, `SECURITY`, `ACCESSIBILITY`). Stable identity — bundle IDs, App + Group, iCloud/CloudKit container, and background-task identifiers — stays on + `net.cleberg.DomainDig`, tied to the existing App Store listing. +- Release cut: `MARKETING_VERSION` 5.0.1 → 5.0.2, `CURRENT_PROJECT_VERSION` + 46 → 47, `AppVersion.current` in lockstep. + +## Cross-cutting note + +New feature surfaces (widgets, intents, extensions) each add a target and a +persistence/entitlement seam. Through v4.9.0 the project had **no XCTest unit +target** — v4.5.0 through v4.7.0 all shipped without the characterization-test +safety net originally recommended before v4.7.0, and that gap only grew +(comparison, reputation, and tags/saved-views all touch persisted models with +hand-written backward-compatible decoders). v5.0.0 closed it first: the +`DomainDigTests` deterministic-core net went in before anything else, which is +what made stabilizing the external contracts and decomposing the god-files safe +to attempt. diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index a51c570..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,33 +0,0 @@ -# Security Policy - -## Supported Versions - -|Version|Supported| -|-------|---------| -| 5.x | ✅ Yes | -| < 5.0 | ❌ No | - ---- - -## Reporting a Vulnerability - -If you discover a security vulnerability, **do not open a public issue**. -Instead: - -1. **Email** your report to [security@krz.sh](mailto:security@krz.sh). - Include: - - A detailed description of the vulnerability - - Steps to reproduce - - Any relevant context (e.g., affected versions, environment) -2. **Response Time**: We will acknowledge your report within **3 business days** - and keep you updated on the progress. -3. **Resolution**: If confirmed, we will: - - Provide an estimated timeline for a fix - - Work with you to verify the resolution - - Credit you for the discovery (if you wish) -4. **Declined Reports**: If the report is invalid or out of scope, we will - explain why and close the issue. - ---- - -**Thank you for helping improve the security of our project!** diff --git a/SECURITY.txt b/SECURITY.txt new file mode 100644 index 0000000..a51c570 --- /dev/null +++ b/SECURITY.txt @@ -0,0 +1,33 @@ +# Security Policy + +## Supported Versions + +|Version|Supported| +|-------|---------| +| 5.x | ✅ Yes | +| < 5.0 | ❌ No | + +--- + +## Reporting a Vulnerability + +If you discover a security vulnerability, **do not open a public issue**. +Instead: + +1. **Email** your report to [security@krz.sh](mailto:security@krz.sh). + Include: + - A detailed description of the vulnerability + - Steps to reproduce + - Any relevant context (e.g., affected versions, environment) +2. **Response Time**: We will acknowledge your report within **3 business days** + and keep you updated on the progress. +3. **Resolution**: If confirmed, we will: + - Provide an estimated timeline for a fix + - Work with you to verify the resolution + - Credit you for the discovery (if you wish) +4. **Declined Reports**: If the report is invalid or out of scope, we will + explain why and close the issue. + +--- + +**Thank you for helping improve the security of our project!** -- cgit v1.2.3