summaryrefslogtreecommitdiff
path: root/DomainDig
Commit message (Collapse)AuthorAgeFilesLines
* chore: cut v5.0.2 (krazywarez migration release)Christian Cleberg43 hours1-1/+1
| | | | | | | | Release cut for the zerolabs -> krazywarez / krz.sh migration: MARKETING_VERSION 5.0.1 -> 5.0.2, CURRENT_PROJECT_VERSION 46 -> 47 across all targets, AppVersion.current in lockstep, roadmap updated. App builds clean; unit suite 63/63.
* update org nameChristian Cleberg43 hours2-4/+4
|
* update emailChristian Cleberg43 hours1-1/+1
|
* feat: owner Pro+ allowlist via CloudKit, cut v5.0.1v5.0.1Christian Cleberg10 days3-4/+85
| | | | | | | | | | | | | | | | | | | | | Grants the app owner Pro+ without a purchase, keyed to their CloudKit user-record ID so it works on the release App Store build. - OwnerAccess holds the owner's CloudKit user-record ID (opaque, per-Apple-ID, scoped to the app's container; safe to publish — CloudKit verifies identity server-side, so it can't be presented by anyone else). - PurchaseService resolves the allowlist against CloudKit once per launch and, on a match, records a persisted owner grant so it applies instantly and offline thereafter. The grant only ever elevates the tier to .proPlus and defers to the existing #if DEBUG overrides, so real purchases and free/pro testing are unaffected. cachedTier / cachedEntitlement were refactored to fall back to the owner grant only when no debug override or stored purchase applies. - Supersedes the DEBUG record-ID reveal (PR #60): its only purpose was to read the owner's ID, which is now hardcoded, so the reveal is not shipped. Release cut: MARKETING_VERSION 5.0.0 -> 5.0.1, CURRENT_PROJECT_VERSION 45 -> 46, AppVersion.current -> 5.0.1, roadmap updated. App builds clean; unit suite 63/63.
* fix: align the redirect-row copy button to the trailing edgeChristian Cleberg10 days1-1/+2
| | | | | | | | | | In the Redirects section, each hop rendered the 44pt AppCopyButton inline between the URL and the "(final)" label with no spacer, so the tall tap target broke the row baseline and pushed "(final)" to the right. Move the copy button to the trailing edge behind a Spacer and keep "(final)" next to the URL — matching the certificate-SAN rows and the "Final URL" row in the same card, which already lay their copy buttons out this way.
* fix: point Rate at the App Store and drop the empty Legal section (#56)Christian Cleberg10 days1-9/+6
| | | | | | | | | - Rate DomainDig now opens the App Store write-review URL (AppLinks.writeReview, derived from the real app id) instead of the in-app StoreKit prompt, so an explicit tap always reaches the review composer. Drops the now-unused StoreKit import and requestReview environment. - The copyright line moves into the "Support the App" section footer, removing the empty Section content closure SonarCloud flagged (swift:S1186).
* feat: use the real App Store ID in AppLinks (#56)Christian Cleberg10 days1-5/+4
| | | | | | The listing is already live (per the README badge), so replace the placeholder appStoreID with the real 6760368004. The App Store listing, write-review, and Share URLs now resolve to the actual app. Removes the release TODO.
* feat: enrich Settings → App Info with metadata & resource links (#56)Christian Cleberg10 days6-23/+367
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Replaces the three-row App Info screen with a full About/Resources/Support/ Legal layout, driven by a declarative AppInfoRow model with all URLs centralized in one AppLinks namespace. - About: Version now shows "5.0.0 (build N)" (CFBundleShortVersionString + CFBundleVersion), plus Storage, Backup Schema, and Minimum iOS (17.6). - Resources: What's New (bundled ReleaseNotes.json sheet, no network), Documentation & FAQ, Source Code, Privacy Policy, and Acknowledgements ("no third-party dependencies" + MIT license). - Support: Report an Issue (a sheet that shows the locally-assembled version/OS/device diagnostics before offering Email or GitHub — nothing is collected silently) and Contact (mailto). - Support the App: Rate (SwiftUI's @Environment(\.requestReview), which handles the scene internally and respects Apple's throttling — the modern, safer equivalent of the issue's SKStoreReviewController + connectedScenes path) and Share (ShareLink to the App Store listing). - Legal: copyright footer with the current year. External rows use Link/openURL with an "opens outside the app" accessibility hint; the screen is standard adaptive Form controls with semantic colors, so it tracks Dynamic Type and light/dark automatically. New files (AppLinks, AppInfo, ReleaseNotes[.swift/.json], AppInfoView) auto- compile/bundle via the synchronized DomainDig/ group. AppInfoTests covers the mailto builder, version format, diagnostics contents, and that the bundled notes ship and parse. Unit suite green. Placeholder pending the App Store listing: AppLinks.appStoreID (the review/share URLs derive from it), flagged with a TODO.
* chore: cut v5.0.0 release (version bump)v5.0.0Christian Cleberg10 days1-1/+1
| | | | | | | | | | | | | Bumps the app to 5.0.0 now that all four v5.0.0 workstreams have landed: - AppVersion.current 4.9.0 -> 5.0.0 - MARKETING_VERSION 4.9.0 -> 5.0.0 across all targets (app, widget, share extension, and both test bundles) - CURRENT_PROJECT_VERSION 44 -> 45 across all targets - RELEASE_ROADMAP.md: v5.0.0 marked shipped, "Current version" -> v5.0.0 The three version sources are kept in lockstep, matching the v4.4.1 alignment policy. App Store archive/submit remains a manual step outside the repo.
* refactor: extract result section views into ResultSectionViews (v5 step 4, 7/n)Christian Cleberg10 days2-1114/+1122
| | | | | | | | | | | | | | | | | | | Seventh slice of the god-file decomposition; second ContentView.swift slice. - Moves the nine result detail section views into ResultSectionViews.swift: DomainSectionView, OwnershipSectionView, IntelligenceSectionView, SubdomainsSectionView, DNSSectionView, WebSectionView, EmailSectionView, NetworkSectionView, and PortRowsView. Pure move. - Promotes the one-line appLoadingStyle() View helper private -> internal: the moved section views and the staying LoadingCardView both use it. That is the only non-deletion edit to ContentView.swift. The shared primitives (CardView, SectionTitleView, LabeledValueRow, ResultColors) stay in ContentView.swift and are reached cross-file. ContentView.swift: 2738 -> 1626 lines (3881 at the start of the ContentView pivot; -2255 total). App builds clean; unit suite 58/58. No project.pbxproj change.
* refactor: extract Settings screens into SettingsViews.swift (v5 step 4, 6/n)Christian Cleberg10 days2-1143/+1149
| | | | | | | | | | | | | | | | | | Pivots the god-file decomposition to ContentView.swift (the other ~3.9k-line file). Unlike DomainViewModel, this is a set of independent SwiftUI View structs, so it splits with no shared-state entanglement. - Moves the Settings surface into SettingsViews.swift: SettingsView (the tab root, presented from RootTabView) plus its nine file-private section screens (Display, History & Network, iCloud Sync, Local API, Monitoring, Import & Export, Data Management, About) and the DataImportPreviewSheet. Pure move. - The private section screens stay file-private together in the new file, reached only through SettingsView's navigation links. Zero visibility changes were needed: the block references nothing file-private to ContentView.swift, so the ContentView diff is pure deletion. ContentView.swift: 3881 -> 2739 lines. App builds clean; unit suite 58/58. No project.pbxproj change (DomainDig/ is a synchronized group).
* refactor: extract history query surface into DomainViewModel+History (v5 ↵Christian Cleberg10 days2-136/+147
| | | | | | | | | | | | | | | | | | | | | | | step 4, 5/n) Fifth slice of the DomainViewModel decomposition. - Moves the history query/compare surface into DomainViewModel+History.swift: per-domain snapshot reads (historyEntries/historyEntry/previousHistoryEntry), timeline grouping (timelineEntries/timelineSections), the two-snapshot selection model (toggle/clear/selectedSnapshots), diff generation and navigation (generateDiff*/moveTo*DiffChange/currentDiffTargetSectionID), and the resolver-mismatch notes. Pure move. - The inspection *pipeline* that produces history — performLookup, applySnapshot, saveHistoryEntry, persistHistory, snapshot-metadata bookkeeping — stays on the main type with the inspection code. This slice needs zero visibility changes: every dependency (the history array, diff state, latestSnapshot) was already internal. The main-file diff is pure deletion. DomainViewModel.swift: 4306 -> 4171 lines (4864 at the start of step 4; -693 total). App builds clean; unit suite 58/58. No project.pbxproj change.
* refactor: extract workflow surface into DomainViewModel+Workflows (v5 step ↵Christian Cleberg10 days2-134/+151
| | | | | | | | | | | | | | | | | | | | | | | 4, 4/n) Fourth slice of the DomainViewModel decomposition (off main; the audit/ monitoring/export stack has merged). - Moves the workflow surface into DomainViewModel+Workflows.swift: workflow lookup (workflow(withID:)/workflowsContaining), the DomainWorkflow collaboration checks, the CRUD mutators (create/update/delete/add/remove/move), runWorkflow/rerunCurrentDomain, and refreshWorkflowList. Pure move. The TrackedDomain overloads of canEdit/canDelete/collaborationLabel stay on the main type (they're watchlist collaboration, not workflow). - Promotes the shared helpers the moved methods reach to internal, all staying on the main type: persistWorkflows (also called by clearWorkflows), startBatchLookup (the batch primitive shared with manual/watchlist runs), normalizedDomain/normalizedDomains, loadWorkflows, and the activeWorkflowRunID/ Name state. The extension carries its own fileprivate String.nilIfEmpty, matching the per-file pattern already used across the codebase. DomainViewModel.swift: 4433 -> 4306 lines (4864 at the start of step 4). App builds clean; unit suite 58/58. No project.pbxproj change.
* refactor: extract export/portability into DomainViewModel+Export (v5 step 4, ↵Christian Cleberg10 days2-171/+184
| | | | | | | | | | | | | | | | | | | | | | 3/n) Third slice of the DomainViewModel decomposition, stacked on the monitoring split. - Moves the export/data-portability surface into DomainViewModel+Export.swift: the single-report/batch/tracked-domain/timeline/workflow exporters, the full-backup and portable-slice exporters, prepareDataImport/applyDataImport, and persistCurrentAppSettings. The export-only WorkflowExportPayload struct moves with them. Pure move, no logic changes. - The four report-projection helpers the exporters call (currentBatchReports, reports(for:), timelineReports, workflowReports) are promoted private -> internal and stay on the main type: they build DomainReports through the shared report layer (report(for:)/reportBuilder), so they belong with inspection, not export. Those four visibility drops are the only non-deletion edits to DomainViewModel. DomainViewModel.swift: 4601 -> 4434 lines. App builds clean; unit suite 58/58. No project.pbxproj change (DomainDig/ is a synchronized group).
* refactor: extract monitoring surface into DomainViewModel+Monitoring (v5 ↵Christian Cleberg10 days2-163/+176
| | | | | | | | | | | | | | | | | | | step 4, 2/n) Second slice of the DomainViewModel decomposition, stacked on the audit split. - Moves the monitoring configuration surface — notification authorization, the settings mutators (enabled/scope/interval/adaptive/sensitivity/quiet-hours/ alert filter/alerts/selection), toggleMonitoring, runMonitoringNow, the manual run, and the per-domain interval/status labels — plus the private intervalLabel helper into DomainViewModel+Monitoring.swift. Pure move. - persistMonitoringSettings(...) and sanitizeMonitoringSelection() are promoted private -> internal: the tracked-domain lifecycle (add/delete/clear) on the main type calls them too, so they stay put and are now reachable cross-file. The two visibility drops are the only non-deletion edits to DomainViewModel. DomainViewModel.swift: 4761 -> 4601 lines. App builds clean; unit suite 58/58. No project.pbxproj change (DomainDig/ is a synchronized group).
* refactor: extract audit surface into DomainViewModel+Audit (v5 step 4, 1/n)Christian Cleberg10 days2-104/+115
| | | | | | | | | | | | | | | | | | | | | First slice of the v5.0.0 god-file decomposition. DomainViewModel.swift is a single ~4.9k-line class body; this begins splitting it into focused `DomainViewModel+<Concern>.swift` extensions, one cohesive concern at a time, with each move behavior-preserving and verified by build + the test net. - Moves the audit read/CRUD/export surface (audits/auditSession/auditTimeline, updateAuditStatus/Notes, toggleAuditChecklistItem, add/update/remove AuditFinding, exportAuditData) into DomainViewModel+Audit.swift as an extension. Pure move — no logic changes. - `startAudit(for:)` intentionally stays on the main type: it drives a live inspection to seed the session, so it belongs with the inspection pipeline until that is extracted. `persistAuditSessions()` is promoted from private to internal so both files can call it (its only cross-file dependency). No project.pbxproj change is needed — DomainDig/ is a file-system-synchronized group, so the new file is picked up automatically. DomainViewModel.swift: 4864 -> 4761 lines. App builds clean; unit suite 58/58.
* feat: add Copy cURL Command button to the Local API page (#30)Christian Cleberg10 days1-0/+4
| | | | | | | | | | | | Adds a one-tap way to get a working, authenticated request from the Local API settings page. copyCurlCommand() copies a curl invocation using the current bound address and token via the Authorization header: curl -H "Authorization: Bearer <token>" http://127.0.0.1:<port>/portfolio Keeps the token in a header rather than a query string, so it stays out of URLs, browser history, and the request-log view (which masks the token). Guards on an empty token, matching copyToken().
* fix: sync CloudKit through a custom zone instead of queries (#29)Christian Cleberg10 days1-109/+131
| | | | | | | | | | | | | | | | | | | | | | | | | With the entitlement gate removed, enabling iCloud sync surfaced a chain of CloudKit errors, each rooted in the query-based fetch: - "Did not find record type: TrackedDomain" — a fresh container has no schema until the first save, so querying any type failed. - "SharedDB does not support Zone Wide queries" — the shared database rejects database-wide queries. - "field 'recordName' is not marked queryable" — TRUEPREDICATE queries require a Queryable index on recordName, which auto-created development schemas do not have. All three are inherent to reading with CKQuery. Move the user's own records into a single custom record zone and read every record with CKFetchRecordZoneChangesOperation, which needs no queryable indexes and works on a brand-new zone. The shared database is read the same way, one zone per accepted share. A custom zone is also a prerequisite for CloudKit sharing, so share root records now live there too. - Ensure the custom zone exists before every push and fetch (idempotent). - Save all records — and build tombstone delete IDs — in the custom zone. - Replace the per-type CKQuery fetch with a per-zone change fetch that drains truncated responses via the server change token.
* fix: reach CloudKit instead of gating on unreliable entitlement ↵Christian Cleberg10 days1-40/+13
| | | | | | | | | | | | | | | | | | | | introspection (#29) iCloud sync always failed with "This build does not have the CloudKit entitlement required for iCloud sync." on normal Debug/device builds. `cloudKitContainer()` gated construction on `isEntitlementConfigurationAvailable()`, which inspected `Info.plist` keys and an `archived-expanded-entitlements.xcent` file. Entitlements live in the code signature, not `Info.plist`, and the `.xcent` file only exists in archived/distribution builds — so the check returned false for a correctly-signed development build, the container was never created, and every path reported `.missingEntitlement`. Stop introspecting entitlements. Always construct `CKContainer.default()` and let CloudKit's own `accountStatus()` drive availability. When the entitlement is genuinely absent, CloudKit surfaces `.missingEntitlement` / `.badContainer` / `.permissionFailure`, which `accountStatus()` now maps back to the "no entitlement" message — preserving that signal without the false negative.
* chore: bump to v4.9.0; record the release in the roadmapv4.9.0Christian Cleberg12 days1-1/+1
| | | | | | | | | | | | | | | | | | MARKETING_VERSION 4.8.3 -> 4.9.0 and CURRENT_PROJECT_VERSION 43 -> 44 across all targets, with AppVersion.current aligned — the three-way consistency v4.4.1 established. The roadmap gains the v4.9.0 entry: the full accessibility pass (#21 phases 0-5 — semantic colours, light mode and the appearance setting, Dynamic Type reflow and tap targets, VoiceOver, colour independence and motion/transparency), the audit harness with seeded fixtures and the engaged enforcement ratchet, the manual verification checklist, and Swift 6 language mode adoption (#27). Deferred device passes are named rather than implied. The v5.0.0 "establish a test target" bullet is rewritten to match reality: a UI test target now exists with an enforcement gate; what remains for v5.0.0 is unit coverage of the deterministic core.
* fix: adopt Swift 6 language mode; resolve all concurrency issues (#27)Christian Cleberg12 days7-50/+108
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | All three product targets (app, widget, share extension) now build under SWIFT_VERSION = 6.0 with zero errors and zero warnings. The UITests target stays on 5.0: XCTestCase's nonisolated setUp/init overrides conflict with the target's MainActor default isolation under 6, and test tooling is not shipping code. The original seven diagnostics, plus the layers Swift 6 mode surfaced once those cleared: - SMTPChannel is an actor. It was implicitly MainActor while running its receive loop on a background queue, so parsedLines/lineWaiters/ receiveBuffer were declared main-actor-protected and mutated off it — concurrent mutation while resuming a CheckedContinuation can double-resume, which traps. The actor serialises all state; Network callbacks hop in via Task. The start() continuation also gains an OSAllocatedUnfairLock resume-once guard: the state handler can fire .ready and later .failed, and resuming twice was a pre-existing trap of the same family. - CachedLookupResult is nonisolated (a value pair built inside actor LookupRuntime cannot have a MainActor-bound memberwise init) with conditional Sendable — opting out of MainActor isolation also opted out of the implicit Sendable that globally-isolated types get. - PortScanService.printableBanner is nonisolated: a pure transformation called from the connection's queue. - SweepActivityController stores the activity's Sendable id instead of the non-Sendable Activity, re-resolving via Activity.activities inside each fire-and-forget task, so nothing non-Sendable crosses isolation. - App Intents' static title/description/openAppWhenRun become lets (get-only protocol requirements; static var is shared mutable global state), and the summary helpers are @MainActor to match the model properties they read and the perform() implementations that call them. - ExternalDataService's ISO8601DateFormatter is nonisolated(unsafe), citing Apple's documented thread-safety, rather than risking a parser behaviour change by switching APIs with no test coverage. - TaskMetricsDelegate.metrics is nonisolated(unsafe): written on the session's delegate queue, read only after the request completes, and URLSession guarantees didFinishCollecting precedes task completion. - The share extension extracts the host via async/withCheckedContinuation instead of sending a non-Sendable completion into loadItem's @Sendable handler; Task inherits the view controller's MainActor so the manual DispatchQueue.main hop goes too. Validated: clean Swift 6 build of all product targets, and the full enforced 11-test audit suite green on the floor runtime — Swift 6's runtime isolation checks ran the app through every screen without a trap.
* fix(a11y): stop section-header trailing controls letter-wrappingChristian Cleberg13 days2-16/+54
| | | | | | | | | | | | | | | | | | | | | | | | | Reported on device: the Domain section header's Note button rendered vertically — "N o t e", one character per line in a screen-tall capsule — at a larger (not even accessibility-tier) text size. Same pathology the row badges had: text inside a squeezed HStack compresses to a one-character column instead of the layout adapting. Three-part fix, mirroring the proven row treatment: - CollapsibleSectionView's header is now a ViewThatFits: title, trailing controls, and chevron on one line while they genuinely fit; otherwise the trailing controls drop below the title row. Applies to every section header, not just Domain. - The Note and Track bordered buttons get .fixedSize() so their text can never letter-wrap — their natural width is what pushes the header onto its stacked layout. - The "Tracked" pill becomes an icon-only indicator (eye in a tinted circle) — with Pin and Note beside it the full pill was the first thing to compress, and the word survives for VoiceOver via its label. Enforced audit suite stays green (7 tests, 0 failures, floor runtime). The post-lookup header state itself is not reachable by the harness — it requires a live lookup — so on-device confirmation closes this out.
* feat(a11y): seeded audit fixtures; fix dense-row reflow they exposed (#21)Christian Cleberg13 days8-41/+305
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The dense rows and portfolio sections never rendered in the audit — the test simulator has no tracked domains or batch results — so five phases of row treatment shipped unmeasured. Driving the add-domain UI was tried earlier and rejected (keyboard contamination, persistent state), so this adds DOMAIN_DIG_SEED_FIXTURES: DEBUG-only launch argument, same pattern as DOMAIN_DIG_FORCE_PRO_PLUS, seeding four tracked domains and four batch results chosen to exercise every badge path, including a failed lookup and a stress-length domain name. Fixtures are strictly in-memory. persistTrackedDomains, refreshWidgetData (App Group file), refreshPersistedData, and refreshMonitoringState are all guarded while fixtures are active — the last one mattered: it runs right after seeding in the app task and was reloading the empty disk over the fixtures, which initially made the seeded watchlist audit pass by silently auditing the empty state. Four new audit tests cover the seeded Dashboard, Tracked Domains, and batch results at default and AccessibilityXXXL. What they found was real. At XXXL the watchlist row rendered the domain as "hea lt…" while the Registered badge wrapped one character per line into a screen-height capsule. Fixes, verified by before/after screenshots and the XXXL audits dropping to 7-8 findings per screen: - AppStatusBadgeView gets .fixedSize() — a capsule badge must never letter-wrap; taking natural width instead forces the row layout to its stacked alternative. - WatchlistRowView, BatchResultRowView, and PortfolioExpiryRow headers use ViewThatFits: domain-beside-badge while it genuinely fits, badge below the domain at accessibility sizes. Domain titles get fixedSize(horizontal: false, vertical: true) so they wrap rather than report a single-line ideal width to ViewThatFits and truncate. - The watchlist monitoring metadata strip (three texts abreast) stacks vertically when it no longer fits instead of wrapping mid-word. Known and deliberate: the seeded default-size audits still report a contrast/dynamicType wave attributed to "unknown element". Bisecting the row and badge accessibility modifiers showed most of it is an audit artifact on children-ignored content (the same rows measure 6-7:1 and render correctly); the artifact classes get characterised suppressions when enforcement lands, not blanket ones.
* feat(a11y): color independence, reduce motion, reduce transparency (#21 phase 5)Christian Cleberg13 days5-18/+87
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Audit unchanged at 11 dark / 14 light — expected, as none of these settings are exercised by performAccessibilityAudit, and simctl can toggle only Increase Contrast, not Differentiate Without Color, Reduce Motion, or Reduce Transparency. Correct by construction and build-clean; runtime behaviour is verified in the Phase 6 manual pass. Color independence: - Widget status is now an SF Symbol (checkmark.circle.fill / exclamationmark.triangle.fill / exclamationmark.octagon.fill), the same vocabulary as the in-app badges, replacing a silent colour-only dot on both the domain rows and the small-view count pills. Status now survives greyscale and reads consistently across surfaces. - Under accessibilityDifferentiateWithoutColor: the Dashboard summary-card dot becomes a per-filter symbol, the selected quick-filter chip gains a checkmark and a border (selection was fill-colour only, and also gains the .isSelected trait), and LabeledValueRow prefixes a warning/failure symbol. All gated on the setting so the default UI stays uncluttered. Reduce motion: all five withAnimation/.animation sites now pass nil under accessibilityReduceMotion — AppCopyButton's check cross-fade, CollapsibleSectionView's expand/collapse, TimelineDiffView's scroll, and WatchlistView's list reorder. Reduce transparency: the single .thinMaterial capsule falls back to an opaque AppSurfaceElevated fill under accessibilityReduceTransparency. The SweepActivityController item from the plan is dropped: it is pure ActivityKit lifecycle with no animation, confirmed back in the issue triage.
* feat(a11y): VoiceOver labels, dense-row rotor content, announcements (#21 ↵Christian Cleberg13 days10-9/+191
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | phase 4) The audit count is unchanged at 11 dark, and that is the expected result: performAccessibilityAudit validates descriptions, traits, contrast, hit regions, and clipping, but exercises none of VoiceOver's speech, the More Content rotor, custom-content ordering, or announcements — which is the entire substance of this phase. It is verified by construction and stays green with no regressions; the manual VoiceOver pass is Phase 6. Icon-only controls (~14) get accessibilityLabel, obeying label-in-name: where a control has visible text the label keeps it, so Voice Control still works. The pin and bookmark toggles gain accessibilityValue and .isSelected; the audit and workflow checkboxes gain .isSelected and a hint. Decorative icons split out of Labels are hidden. AppStatusBadgeView now reads as one word ("Critical"), not "icon, Critical", via children: .ignore + label. SectionTitleView and CollapsibleSectionView headers get the .isHeader trait for rotor navigation; the collapsible header also exposes expanded/collapsed as a value with a hint. The header deliberately does NOT use children: .combine — its trailing() closure can hold Track/Pin controls, and combining would swallow them. Dense rows use combine-for-summary, custom-content-for-detail. BatchResultRowView (8 elements) and WatchlistRowView (up to 9) become a single element — domain as label, status as value — with risk, IP, timestamp, source, certificate, and monitoring on the More Content rotor, risk and certificate at .high importance. Reading all of it inline would make a long sweep unnavigable. The custom-content chains live in ViewModifiers because inlining six of them plus the layout broke the type-checker. The shorter 3-4 element portfolio rows are left to NavigationLink's automatic combine, per WWDC21-10121. Technical strings get a speechStyle field on InfoRowViewData: .technical applies speechAlwaysIncludesPunctuation and accessibilityTextContentType(.sourceCode), set on DNS record values and cipher suites so load-bearing punctuation is not swallowed. Completion announcements: the sweep posts from the view model; the single lookup posts from an onChange in the view, since resultsLoaded is derived from many loading flags and has no single view-model moment. Widget: each domain row was a silent 8pt status dot plus a bare "12d" countdown. Rows now read as one phrase ("example.com, critical, certificate expires in 12 days"); the count pills are labelled. Not verifiable by the suite: the dense rows and the widget never render in the audit (no tracked domains or batch results in the test simulator), same limit as the deferred Phase 3 row reflow. Documented in Docs/ACCESSIBILITY.md.
* feat(a11y): Dynamic Type reflow and tap targets (#21 phase 3)Christian Cleberg2026-07-203-8/+54
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Takes the audit from 18 findings to 11 in dark mode. Everything that remains is system-rendered or placeholder noise, characterised below. The largest win was not where the plan expected. Every empty-state heading reported as clipped text, and the cause was `Label`: it constrains its own title, and `.fixedSize` applied to the Label does not reach the `Text` inside. Splitting into `HStack { Image; Text }` and putting the modifier on the Text cleared all four empty states at both default and accessibility sizes. That fix then caused a regression the audit caught immediately. `Label` folds its image into the title's accessibility element; an HStack does not, so the icon began announcing its raw SF Symbol name ("checklist.unchecked") to VoiceOver. Decorative icons split out of a Label now carry .accessibilityHidden(true). Tap targets: - AppCopyButton was a literal 30x30 on nearly every data row. Now @ScaledMetric from 44, floored at AppLayout.minimumTapTarget — @ScaledMetric scales down below the default text size as well as up, so the floor is load-bearing. - controlMinHeight was 42 in compact density, putting every collapsible section header and both Run buttons under the minimum. Reflow: - CardView's allowsHorizontalScroll defaulted to true, so nine call sites hid content behind a horizontal gesture instead of wrapping — a WCAG 1.4.10 failure and the mechanism behind clipped rows at large text sizes. The default is now false, and the remaining opt-in is suppressed at accessibility sizes. - Fixed .system(size:) point sizes replaced with text styles in the app and the widget. - The widget is clamped at accessibility1, the one place clamping is correct: a widget canvas is a fixed size and WidgetKit truncates overflow with no scroll affordance. Two hypotheses were tested and discarded rather than left in. Monospaced fonts looked like the clipping culprit — the app is 82% monospaced and hyphenates mid-word at accessibility sizes — but switching the empty state to proportional changed nothing, and prose typography is a design decision rather than an accessibility fix. Shortening search prompts and the domain placeholder also changed nothing: placeholder text is reported clipped regardless of length, so "Search" is flagged exactly as "Search portfolio" was. Not done: ViewThatFits reflow for BatchResultRowView and WatchlistRowView. Those rows never render in the audit because the test simulator has no tracked domains or batch results, so any change there would be unverifiable. Absence of findings is absence of data.
* fix(a11y): replace translucent accent washes with authored surfacesChristian Cleberg2026-07-203-3/+7
| | | | | | | | | | | | | | | | | | | | | The selected Dashboard summary card rendered lavender, not blue. Its background was a gradient of statusInfo at 28% and 12% opacity — a dark-mode trick where a translucent accent over near-black reads as a dim version of itself. Over a light background the same wash desaturates toward violet. Swapped for the authored StatusInfoSurface, which is a real colour with a real contrast measurement rather than an emergent one. Same treatment for the selected tag-filter chip (statusInfo at 30%) and an intelligence-section badge (statusWarning at 14%). The remaining opacity use, a 0.55 stroke in ContentView, is a border rather than a text background and is left alone. Verified across appearance and contrast settings on iOS 18.6: light 21 findings, light + Increase Contrast 18, dark 18, dark + Increase Contrast 18. Increase Contrast lowering the light count is the High Contrast colorset variants working as intended on the system section headers.
* fix(a11y): rebalance the light palette so hues surviveChristian Cleberg2026-07-209-79/+126
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported as "colors seem muted and hard to see on light mode", and correct. The light palette optimised contrast and produced mud: #7A5600 reads olive rather than amber, #146C2E bottle-dark rather than green. Contrast passed while the UI got harder to read, because hue identity is what distinguishes warning from critical at a glance. Two causes, both fixed. Every foreground was required to clear 4.5:1 against its own 16% badge tint — the harshest surface it ever sits on — which pushed each colour about 20% darker than the common case needed. Most of what is actually on screen is plain text on a card, with far more headroom. The fill is now decoupled from the foreground: AppStatusTone carries a foreground and a surface authored independently, with matching …Surface colorsets, so a foreground no longer has to survive a wash of itself. Every status foreground is now fully saturated. And warning was yellow. Yellow cannot stay yellow at a lightness low enough to pass 4.5:1 on white — it becomes olive. That is colorimetric, not a tuning problem. Warning is now orange: #AD5100 light, #FF9F0A dark. New light values: positive #008035, warning #AD5100, critical #CC0700. Worst-case ratios 4.54–6.76 across page, card, and surface in both schemes. Audit findings are unchanged — light 21, dark 18 — so the vividness costs nothing. Also picks up a literal .blue missed in phase 1: DomainDiffItem's low-severity change colour, which the phase 1 sweep did not cover because its pattern listed only cyan/yellow/green/red/orange/pink.
* feat(a11y): unlock light mode and add appearance preference (#21 phase 2)Christian Cleberg2026-07-2018-194/+226
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Removes the 16 scattered .preferredColorScheme(.dark) calls and the one .toolbarColorScheme, and applies appearance in exactly one place — the WindowGroup in DomainDigApp. Re-applying per view is what let the lock spread across eight files unnoticed until light mode was unreachable. Adds AppAppearance (System / Light / Dark) in @AppStorage, exposed under Settings > Display next to Density. Honouring the system setting and offering an override is one key, and it keeps the deliberate dark aesthetic reachable for anyone who wants it. Also replaces .secondary with AppTextSecondary across 191 sites. iOS's own secondaryLabel is 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 is precisely what exposed it, so it belongs here rather than in a later phase: without it, light mode would ship with body text under 4.5:1 app-wide. Dark mode reports 18 findings, unchanged from phase 1 — no regression from unlocking. Light mode reports 21. The three extra are iOS-rendered Section headers (TIER, PREFERENCES, SERVICES) using the system's grey; overriding system header styling across every section to gain ~0.3:1 on decorative labels is a poor trade and is left alone. Two long-standing Settings contrast findings are now explained. They are the last rows of a section sitting under the translucent tab bar, so the audit measures text against a blended background — confirmed by screenshot, present in dark mode since phase 0, and standard iOS scroll-under behaviour rather than a defect.
* feat(a11y): semantic colour system (#21 phase 1)Christian Cleberg2026-07-2016-235/+310
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Replaces every hard-coded colour with semantic asset colours that adapt to light, dark, and Increase Contrast. Dark mode stays locked, so this is a pure refactor: the audit reports the same findings before and after. The accent is now blue rather than cyan, per the tech/DNS theme. Why custom values rather than the system palette: every system colour fails WCAG AA 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 was never a matter of deleting .preferredColorScheme(.dark). Every new value clears 4.5:1 as text on its page, its card, and its own 16% badge tint — the way AppStatusBadgeView actually draws it. The accent needed splitting in two. As text on a dark background it must be light; as a fill behind a white label it must be dark. #4DA3FF reads 8.00:1 as text on black but 2.63:1 behind white text, so StatusInfo / AccentColor cover the foreground role and AccentFill covers .borderedProminent. AppOnAccent is the label colour for a solid fill and flips by scheme. Colours live in Shared/Colors.xcassets rather than the app catalog: the Shared folder is already a synchronized group in all three targets, so the widget and share extension pick the palette up with no project-file surgery. AccentColor stays in the app catalog as the global tint — and is now actually defined, having been an empty colorset that silently left system controls rendering in stock blue while custom chrome used cyan. Two deliberate visual changes: orange folds into StatusWarning and pink into StatusCritical. They encoded the same severity as the colours they now share, and both sites also carry a text label. Audit findings drop 15 to 14, and one of the originals turned out to be a phantom: the Inspect contrast failure was the Run button in its disabled state, which WCAG 1.4.3 exempts. testInspectScreen now types a domain first so the audit measures an enabled control. Findings also carry the offending element now, so the remaining clipped-text items name themselves ("No Portfolio Yet", "Search domains") instead of being anonymous.
* fix: extract nested ternary in batch quickStatusChristian Cleberg2026-07-201-1/+5
| | | | | | Closes the last swift:S3358 in the new-code period. The hasChanges branch nested a severity check inside the impactClassification ternary; split into if/else. No behavior change.
* v4.8.3: Clear SonarCloud new-code issuesChristian Cleberg2026-07-2024-220/+197
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Fixes the 4 reported bugs and ~97 code smells flagged in the new-code period. No behavior changes. Bugs (swift:S3923) — DomainInspectionService's confidenceFor* helpers each returned `error == nil ? .low : .low`, an inert conditional. Simplified to `return .low` and dropped the now-unused `error` parameter. Smells: - Merged 14 identical `.empty`/`.error` switch branches in DomainViewModel - Consolidated duplicate implementations (clearPresentedResults/reset, String.nonEmpty/nilIfEmpty, ExportFormat.id/fileExtension) - Extracted nested ternaries into TLSGrade.tone, EmailSecurityGrade.tone, and ChangeImpactClassification.color; removed ContentView.impactColor and the duplicate mapping in BatchResultsView - Documented empty closures and singleton inits - Marked unused protocol-conformance parameters `_` - Renamed CloudSyncTrigger.`import` to `imported` (raw value preserved) and SSLSessionDelegate's _serverTrust/_tlsMetadata - Merged nested ifs in the DER parser; flattened closure nesting in PortScanService and IntegrationService - Replaced two-case switches with if/else Left open: S107 (init parameter counts), S115 (constants mirroring DoH and ipapi JSON keys), S1075 (false positives on https:// literals), and two S117 hits on SwiftUI $binding shorthand. These want a Won't Fix resolution in SonarCloud, not a code change.
* v4.8.2: Bump version and mark shipped in roadmapv4.8.2Christian Cleberg2026-07-201-1/+1
| | | | | | | | Bump AppVersion/marketing version to 4.8.2 and build number to 42 across the app, widget, and share extension targets. Mark v4.8.2 (delivery visibility for disabled integrations, forced queue retries, unreachable-domain reporting, Swift 6 concurrency warnings, synced StoreKit configuration) shipped in RELEASE_ROADMAP.md and record the UAT follow-ups as resolved.
* fix: report unreachable domains instead of 'No meaningful changes'Christian Cleberg2026-07-202-3/+48
| | | | | | | | | | | | | | | | | Closes #10. resolvedSnapshotAfterFallback replaces a failed lookup's snapshot with the previous one, so alertDescriptor compared the old snapshot against itself, found matching hashes, and the run reported 'No meaningful changes' for a domain that was never actually reached. Nothing in the UI or the monitoring log distinguished that from a genuine no-change. MonitoringDomainResult now carries unreachableReason, set when the fallback fires. It is Optional so already-persisted monitoring logs still decode. The run summary reads 'Could not check — kept the previous result' with the underlying error, and monitoringEvents emits a warning- severity monitoringFailure so configured integrations hear about it rather than seeing silence.
* fix: make disabled targets and forced queue processing visibleChristian Cleberg2026-07-201-6/+52
| | | | | | | | | | | | | | | | | | | | | Closes #8, closes #9. enqueue(events:) filtered to enabled targets before writing any DeliveryRecord, so events routed to a disabled integration disappeared with nothing in the Delivery Log. Disabled targets now record a .skipped entry with a reason, matching how filter mismatches are already surfaced. sendTest bypassed the isEnabled check entirely, so a test event delivered against a target that silently dropped every real event — exactly the wrong signal when someone is verifying their setup. It now skips with the same reason. processQueueNow only restarted the processing task; it never moved nextAttemptAt, so an item in retry backoff stayed undue and the fresh task went straight back to sleep. Backoff reaches an hour, so the button appeared inert for the one case it exists to handle. It now pulls every queued item forward, and reports when the queue is empty instead of returning silently.
* fix: match IAP product IDs to App Store ConnectChristian Cleberg2026-07-201-4/+4
| | | | | | | | | | | | | | The four product ID constants did not match the auto-renewable subscriptions configured in App Store Connect, so Product.products(for:) returned an empty array and tier(for:) resolved every purchase to .free. Product IDs are permanent in App Store Connect, so the code is corrected to match the configured values rather than the reverse: domaindig.pro.monthly -> domaindig.pro.month domaindig.pro.yearly -> domaindig.pro.annually domaindig.dataplus.monthly -> domaindig.proplus.monthly domaindig.dataplus.yearly -> domaindig.proplus.annually
* v4.8.1: Bump version and mark shipped in roadmapChristian Cleberg2026-07-201-1/+1
| | | | | | | | Bump AppVersion/marketing version to 4.8.1 and build number to 41 across the app, widget, and share extension targets. Mark v4.8.1 (scheduled report tap target, completed Pro gate, markdown underline rendering, DNS record dedup, inspect tab keyboard behavior) shipped in RELEASE_ROADMAP.md, and note the three follow-ups filed during UAT.
* fix: Generate Now tap target, remove keyboard dismiss button and launch focusChristian Cleberg2026-07-202-58/+47
| | | | | | | | | | | | | | | | Scheduled Reports: the entire Overview section was wrapped in a single VStack inside one List row, so SwiftUI collapsed every control into one tap target and the menu-style Cadence Picker captured taps intended for the Generate Now button. Unwraps the VStack so each control is its own row, matching the pattern used in IntegrationsView and elsewhere. Also extends the .automatedMonitoring gate to the two Pickers and the Generate Now button. Previously only the Toggle was disabled, leaving a button that appeared active on Free but silently no-opped against the guard in ScheduledReportService. Inspect tab: removes the keyboard toolbar's Dismiss Keyboard button and the onAppear that focused the single-domain field at launch.
* fix: reject non-HTTPS webhook URLs at save timeChristian Cleberg2026-07-201-1/+18
| | | | | | | | | Validating only at send time meant an http:// URL saved fine and then failed silently on delivery. Validate in upsert so the integration editor surfaces it, and give the failure its own error case rather than reusing the generic invalid-URL message. The send-time guard stays as defense in depth for URLs saved before this.
* fix: harden webhook transport and gate debug loggingChristian Cleberg2026-07-202-1/+5
| | | | | | | | | | Require HTTPS for outbound integration webhooks. Webhook URLs are themselves secrets (Slack in particular), so an http:// endpoint leaked both the URL and the alert payload in cleartext. Disable DomainDebugLog in release builds. Every message used privacy: .public, which opted out of OSLog redaction and wrote looked-up domains to the unified log in shipped builds.
* v4.8.0: Bump version and mark shipped in roadmapChristian Cleberg2026-07-201-1/+1
| | | | | | | Bump AppVersion/marketing version to 4.8.0 and build number to 40 across the app, widget, and share extension targets. Mark v4.8.0 (markdown/PDF export, scheduled reports, stronger share affordances, verified local API consistency) shipped in RELEASE_ROADMAP.md.
* v4.8.0: Add scheduled report generationChristian Cleberg2026-07-206-0/+405
| | | | | | | | | | | | | | | | | | | | | - ScheduledReportService (@MainActor, headless/storage-backed like DomainMonitoringService): builds the latest report for every tracked domain from persisted history, exports it via DomainReportExporter in the configured format (markdown/PDF/JSON), writes it to a local Documents subdirectory, logs the run, and fires a "Scheduled Report Ready" local notification. - ScheduledReportScheduler mirrors DomainMonitoringScheduler's BGTaskScheduler approach with its own task identifier (net.cleberg.DomainDig.report.schedule, added to Info.plist) and a daily/weekly cadence. - ScheduledReportsView (Settings → Scheduled Reports): enable toggle, cadence and format pickers, "Generate Now", and a log of past reports each shareable via the existing ExportPresenter share sheet. - Gated behind the existing .automatedMonitoring capability (Pro), consistent with monitoring being the other background-automation feature. - Settings/logs persist via UserDefaults (DomainExportFormat is now Codable), not DomainDataPortabilityService backup/restore — this is local automation configuration, not user-authored content, same reasoning as v4.7.0's watchlist saved views.
* v4.8.0: Wire markdown/PDF export buttons into share menusChristian Cleberg2026-07-203-0/+52
| | | | | | | | | Adds "Export Markdown"/"Export PDF" (and batch/workflow equivalents) to the single-result, batch, watchlist, and workflow export menus, gated behind .advancedExports like the existing CSV/JSON options. Matches the existing menu structure and Pro-gate wording per file rather than introducing a new shared component, consistent with how CSV/JSON were already duplicated across these five menus before this change.
* v4.8.0: Add markdown and PDF export formatsChristian Cleberg2026-07-204-99/+46
| | | | | | | | | | | | | | | | | - DomainExportFormat gains .markdown and .pdf (CaseIterable, Identifiable, titled), alongside the existing text/csv/json. - Markdown reuses the existing text-export content verbatim via a line-based transform (section "Title\n----" underlines become "## Title", the leading title becomes an H1, other lines become bullets), so the two formats can never drift apart. - PDF renders that Markdown as a simple monospaced multi-page document via UIGraphicsPDFRenderer (mirrors AuditExporter's existing PDF approach; degrades to raw Markdown bytes on non-UIKit platforms). - Replaced the single/batch/tracked-domains/workflow share call sites' hand- written per-format switches with format-agnostic functions (exportSingleReportData, exportBatchReportData, exportTrackedDomainsData, exportWorkflowData) that delegate straight to DomainReportExporter, removing the now-orphaned per-format helper functions those switches used to call.
* v4.7.0: Bump version and mark shipped in roadmapv4.7.0Christian Cleberg2026-07-171-1/+1
| | | | | | | Bump AppVersion/marketing version to 4.7.0 and build number to 39 across the app, widget, and share extension targets. Mark v4.7.0 (comparison, reputation, tags/saved views) shipped in RELEASE_ROADMAP.md and escalate the overdue XCTest-target gap ahead of v5.0.0.
* v4.7.0: Add watchlist tags and saved filter viewsChristian Cleberg2026-07-174-3/+261
| | | | | | | | | | | - TrackedDomain.tags: [String] (backward-compatible custom decode), with updateTags(_:for:) and a normalized/deduplicated write path. - Tag editing in TrackedDomainDetailView (comma-separated field, chip display). - Tag filter chips in WatchlistView (TagFilterChipRowView), integrated into filteredTrackedDomains alongside the existing filter/search. - Saved views: name + snapshot the current tag/filter/sort as a WatchlistSavedView preset (UserDefaults-backed, not part of backup/restore), with a management sheet to apply or delete presets.
* v4.7.0: Add domain reputation/blocklist data sourceChristian Cleberg2026-07-177-4/+176
| | | | | | | | | | | | | | | | | | | - New DomainReputationResult model (status: clean/listed/unknown, listed sources, checked-at) and a `reputation(domain:)` method on ExternalDataService, mirroring the existing pluggable-URL enrichment pattern (ownership history, DNS history, extended subdomains, pricing). With no endpoint configured (the default; DomainDig ships no bundled third-party reputation dependency) it resolves to unavailable rather than "clean". - New .reputation FeatureCapability/DataCapability, gated Pro+ like domainPricing. - Threaded reputation/reputationError through LookupSnapshot and HistoryEntry (backward-compatible decode) so results persist with history entries. - Auto-fetched in performLookup alongside pricing; surfaced as a "Reputation" info row, folded into DomainInsightEngine's risk score/factors and top-level insights (a listed domain raises risk score and adds a factor/insight), and exported in text, CSV, and JSON report output. - Reputation-driven risk changes ride the existing change-severity pipeline, so a listed status flip is visible to monitoring the same way any other risk delta is, without bespoke monitoring wiring.
* v4.7.0: Add domain-vs-domain comparisonChristian Cleberg2026-07-173-0/+139
| | | | | | | | | | - DiffService.compare(domainA:domainB:) reuses the existing section-diff builders to compare two distinct domains' latest reports, returning a new DomainComparisonResult (parallel to the time-based DomainDiff). - DomainCompareView: pick two tracked domains and render the comparison with the existing DomainDiffView section renderer. - Entry point: "Compare Domains" in the Watchlist toolbar menu, shown once at least two domains are tracked.
* Implement v4.6.0: sweep Live Activity, share extension, iPad split view, ↵Christian Cleberg2026-07-177-50/+243
| | | | | | | | | | | | | | | | actionable notifications - Sweep Live Activity: SweepActivityAttributes (Shared/), lock-screen + Dynamic Island UI in the widget extension, driven by SweepActivityController wired into the batch pipeline (begin/update/end); NSSupportsLiveActivities in Info.plist. - Share extension (DomainDigShareExtension): accepts a web URL from the share sheet, extracts the host, and hands it to the app via the App Group inbox (DomainDigShareInbox); the app consumes it on activation and inspects it. - iPad layout: RootTabView uses NavigationSplitView in the regular size class and the tab bar in compact. - Actionable notifications: per-domain threadIdentifier grouping, a Re-inspect action, and tap routing into the domain detail via the intent router. - Bump version to 4.6.0 (build 38) across app, widget, and share targets.
* Complete v4.5.0: Run Sweep intent, detail deep link, and portfolio widgetChristian Cleberg2026-07-176-51/+113
| | | | | | | | | | | | | | | | | Finishes the v4.5.0 "Home Screen & Shortcuts reach" scope that the tag shipped partially: - Add RunSweepIntent (opens the app and runs refreshAllTrackedDomains via the in-process router) and expose it in DomainDigShortcuts. - Extend the domaindig:// scheme with `sweep` and `domain` (detail) actions; route .detail to present TrackedDomainDetailView and .sweep to refresh the watchlist. Move DomainDigDeepLink into Shared/ so the widget can build links. - Add a WidgetKit extension (DomainDigWidgetExtension) with small/medium/large Portfolio widgets showing health counts, per-domain status, and certificate countdowns; tapping a domain deep-links into its detail. - Share portfolio state via an App Group (group.net.cleberg.DomainDig): the app writes a DomainDigWidgetData snapshot on launch/foreground and on watchlist changes and reloads timelines; the widget reads the same store.