diff options
| -rw-r--r-- | DomainDig.xcodeproj/project.pbxproj | 173 | ||||
| -rw-r--r-- | DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist | 5 | ||||
| -rw-r--r-- | DomainDig/DomainDig.entitlements | 4 | ||||
| -rw-r--r-- | DomainDig/DomainDigApp.swift | 2 | ||||
| -rw-r--r-- | DomainDig/DomainDigIntents.swift | 78 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel+Widget.swift | 60 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 1 | ||||
| -rw-r--r-- | DomainDig/RootTabView.swift | 19 | ||||
| -rw-r--r-- | DomainDigWidget/DomainDigPortfolioWidget.swift | 189 | ||||
| -rw-r--r-- | DomainDigWidget/DomainDigWidget.entitlements | 10 | ||||
| -rw-r--r-- | DomainDigWidget/DomainDigWidgetBundle.swift | 9 | ||||
| -rw-r--r-- | DomainDigWidget/Info.plist | 29 | ||||
| -rw-r--r-- | Shared/DomainDigDeepLink.swift | 71 | ||||
| -rw-r--r-- | Shared/DomainDigWidgetData.swift | 82 |
14 files changed, 681 insertions, 51 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj index fe76a4e..dbd0873 100644 --- a/DomainDig.xcodeproj/project.pbxproj +++ b/DomainDig.xcodeproj/project.pbxproj @@ -14,8 +14,33 @@ 8BCA3CC02F9C8D57004B742C /* LocalAPIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */; }; 8BCA3CC12F9C8D57004B742C /* LocalAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */; }; 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; }; + 8BDA00010000000000000002 /* DomainDigWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 8BDA00010000000000000003 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8B7800612F6090E300933221 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8BDA00010000000000000006; + remoteInfo = DomainDigWidgetExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 8BDA00010000000000000005 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 8BDA00010000000000000002 /* DomainDigWidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; }; 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; }; @@ -25,6 +50,7 @@ 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIModels.swift; sourceTree = "<group>"; }; 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIService.swift; sourceTree = "<group>"; }; 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; }; + 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -37,6 +63,13 @@ ); target = 8B7800682F6090E300933221 /* DomainDig */; }; + 8BDA0001000000000000000F /* Exceptions for "DomainDigWidget" folder in "DomainDigWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 8BDA00010000000000000006 /* DomainDigWidgetExtension */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -48,6 +81,19 @@ path = DomainDig; sourceTree = "<group>"; }; + 8BDA0001000000000000000D /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = "<group>"; + }; + 8BDA0001000000000000000E /* DomainDigWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 8BDA0001000000000000000F /* Exceptions for "DomainDigWidget" folder in "DomainDigWidgetExtension" target */, + ); + path = DomainDigWidget; + sourceTree = "<group>"; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -58,6 +104,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8BDA00010000000000000008 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -65,6 +118,8 @@ isa = PBXGroup; children = ( 8B78006B2F6090E300933221 /* DomainDig */, + 8BDA0001000000000000000D /* Shared */, + 8BDA0001000000000000000E /* DomainDigWidget */, 8B78006A2F6090E300933221 /* Products */, 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */, 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */, @@ -80,6 +135,7 @@ isa = PBXGroup; children = ( 8B7800692F6090E300933221 /* DomainDig.app */, + 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */, ); name = Products; sourceTree = "<group>"; @@ -94,13 +150,16 @@ 8B7800652F6090E300933221 /* Sources */, 8B7800662F6090E300933221 /* Frameworks */, 8B7800672F6090E300933221 /* Resources */, + 8BDA00010000000000000005 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 8BDA00010000000000000004 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 8B78006B2F6090E300933221 /* DomainDig */, + 8BDA0001000000000000000D /* Shared */, ); name = DomainDig; packageProductDependencies = ( @@ -109,8 +168,39 @@ productReference = 8B7800692F6090E300933221 /* DomainDig.app */; productType = "com.apple.product-type.application"; }; + 8BDA00010000000000000006 /* DomainDigWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8BDA0001000000000000000A /* Build configuration list for PBXNativeTarget "DomainDigWidgetExtension" */; + buildPhases = ( + 8BDA00010000000000000007 /* Sources */, + 8BDA00010000000000000008 /* Frameworks */, + 8BDA00010000000000000009 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 8BDA0001000000000000000D /* Shared */, + 8BDA0001000000000000000E /* DomainDigWidget */, + ); + name = DomainDigWidgetExtension; + packageProductDependencies = ( + ); + productName = DomainDigWidgetExtension; + productReference = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ +/* Begin PBXTargetDependency section */ + 8BDA00010000000000000004 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8BDA00010000000000000006 /* DomainDigWidgetExtension */; + targetProxy = 8BDA00010000000000000003 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXProject section */ 8B7800612F6090E300933221 /* Project object */ = { isa = PBXProject; @@ -127,6 +217,9 @@ }; }; }; + 8BDA00010000000000000006 = { + CreatedOnToolsVersion = 26.3; + }; }; }; buildConfigurationList = 8B7800642F6090E300933221 /* Build configuration list for PBXProject "DomainDig" */; @@ -144,6 +237,7 @@ projectRoot = ""; targets = ( 8B7800682F6090E300933221 /* DomainDig */, + 8BDA00010000000000000006 /* DomainDigWidgetExtension */, ); }; /* End PBXProject section */ @@ -156,6 +250,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8BDA00010000000000000009 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -173,6 +274,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8BDA00010000000000000007 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ @@ -373,6 +481,62 @@ }; name = Release; }; + 8BDA0001000000000000000B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 37; + DEVELOPMENT_TEAM = ZCNAX3VL9D; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = DomainDigWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 4.5.0; + PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 8BDA0001000000000000000C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 37; + DEVELOPMENT_TEAM = ZCNAX3VL9D; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = DomainDigWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 4.5.0; + PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -394,6 +558,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 8BDA0001000000000000000A /* Build configuration list for PBXNativeTarget "DomainDigWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8BDA0001000000000000000B /* Debug */, + 8BDA0001000000000000000C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 8B7800612F6090E300933221 /* Project object */; diff --git a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist index 8fd771d..2e2db5b 100644 --- a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,6 +9,11 @@ <key>orderHint</key> <integer>0</integer> </dict> + <key>DomainDigWidgetExtension.xcscheme_^#shared#^_</key> + <dict> + <key>orderHint</key> + <integer>1</integer> + </dict> </dict> <key>SuppressBuildableAutocreation</key> <dict> diff --git a/DomainDig/DomainDig.entitlements b/DomainDig/DomainDig.entitlements index d792729..223e66a 100644 --- a/DomainDig/DomainDig.entitlements +++ b/DomainDig/DomainDig.entitlements @@ -10,5 +10,9 @@ <array> <string>CloudKit</string> </array> + <key>com.apple.security.application-groups</key> + <array> + <string>group.net.cleberg.DomainDig</string> + </array> </dict> </plist> diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift index ff40caf..8716806 100644 --- a/DomainDig/DomainDigApp.swift +++ b/DomainDig/DomainDigApp.swift @@ -39,6 +39,7 @@ struct DomainDigApp: App { cloudSyncService.scheduleSyncIfNeeded(trigger: .launch) viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() IntegrationService.shared.processQueueNow() + viewModel.refreshWidgetData() } .onReceive(NotificationCenter.default.publisher(for: .cloudSyncDidApplyChanges)) { _ in viewModel.refreshPersistedData() @@ -57,6 +58,7 @@ struct DomainDigApp: App { cloudSyncService.scheduleSyncIfNeeded(trigger: .launch) viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() IntegrationService.shared.processQueueNow() + viewModel.refreshWidgetData() } } } diff --git a/DomainDig/DomainDigIntents.swift b/DomainDig/DomainDigIntents.swift index 44a1dc4..c0c73cc 100644 --- a/DomainDig/DomainDigIntents.swift +++ b/DomainDig/DomainDigIntents.swift @@ -124,6 +124,24 @@ struct AddToWatchlistIntent: AppIntent { } } +/// App Intent that opens DomainDig and re-inspects every tracked domain. It runs +/// through the existing view-model batch path (`refreshAllTrackedDomains`), which +/// enforces the batch feature gate and surfaces the paywall when needed. +struct RunSweepIntent: AppIntent { + static var title: LocalizedStringResource = "Run Watchlist Sweep" + static var description = IntentDescription( + "Open DomainDig and re-inspect every domain on your watchlist." + ) + + static var openAppWhenRun = true + + @MainActor + func perform() async throws -> some IntentResult { + DomainDigIntentRouter.shared.pendingAction = .sweep + return .result() + } +} + /// In-process hand-off from an `openAppWhenRun` intent to the running SwiftUI /// layer. `RootTabView` observes `pendingAction` and performs it. @MainActor @@ -134,57 +152,6 @@ final class DomainDigIntentRouter { private init() {} } -/// Shared builder/parser for the `domaindig://` URL scheme, used by both the -/// intents (to open the app) and the app (to route incoming links). -enum DomainDigDeepLink { - static let scheme = "domaindig" - - enum Action: Equatable { - case inspect(String) - case watch(String) - - var host: String { - switch self { - case .inspect: return "inspect" - case .watch: return "watch" - } - } - - var domain: String { - switch self { - case let .inspect(domain), let .watch(domain): return domain - } - } - } - - static func url(for action: Action) -> URL { - var components = URLComponents() - components.scheme = scheme - components.host = action.host - components.queryItems = [URLQueryItem(name: "domain", value: action.domain)] - // The scheme and host are fixed and the domain is percent-encoded by - // URLComponents, so this is always a valid URL. - return components.url! - } - - static func action(from url: URL) -> Action? { - guard url.scheme == scheme else { return nil } - - let domain = URLComponents(url: url, resolvingAgainstBaseURL: false)? - .queryItems? - .first { $0.name == "domain" }? - .value? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !domain.isEmpty else { return nil } - - switch url.host() { - case "inspect": return .inspect(domain) - case "watch": return .watch(domain) - default: return nil - } - } -} - /// Exposes DomainDig intents to Spotlight and Siri with invocation phrases. struct DomainDigShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { @@ -206,5 +173,14 @@ struct DomainDigShortcuts: AppShortcutsProvider { shortTitle: "Add to Watchlist", systemImageName: "plus.circle" ) + AppShortcut( + intent: RunSweepIntent(), + phrases: [ + "Run a sweep with \(.applicationName)", + "Sweep my \(.applicationName) watchlist" + ], + shortTitle: "Run Sweep", + systemImageName: "arrow.trianglehead.2.clockwise" + ) } } diff --git a/DomainDig/DomainViewModel+Widget.swift b/DomainDig/DomainViewModel+Widget.swift new file mode 100644 index 0000000..59b28e0 --- /dev/null +++ b/DomainDig/DomainViewModel+Widget.swift @@ -0,0 +1,60 @@ +import Foundation +import WidgetKit + +extension DomainViewModel { + /// Publishes the current portfolio state to the App Group container so the + /// widget can render it, then asks WidgetKit to refresh its timelines. + func refreshWidgetData() { + let data = portfolioDashboardData + let snapshot = data.snapshot + + let ordered = data.domainStates.sorted { lhs, rhs in + if lhs.trackedDomain.isPinned != rhs.trackedDomain.isPinned { + return lhs.trackedDomain.isPinned + } + return lhs.health.widgetSeverityRank > rhs.health.widgetSeverityRank + } + + let domains = ordered.prefix(6).map { state in + DomainDigWidgetDomain( + domain: state.trackedDomain.domain, + isPinned: state.trackedDomain.isPinned, + status: state.health.widgetStatus, + certDaysRemaining: state.certificateDaysRemaining, + lastChange: state.lastChangeDate + ) + } + + DomainDigWidgetStore.write( + DomainDigWidgetData( + generatedAt: Date(), + totalDomains: snapshot.totalDomains, + healthyCount: snapshot.healthyCount, + warningCount: snapshot.warningCount, + criticalCount: snapshot.criticalCount, + expiringSoonCount: snapshot.expiringSoonCount, + unreachableCount: snapshot.unreachableCount, + domains: Array(domains) + ) + ) + WidgetCenter.shared.reloadAllTimelines() + } +} + +private extension DomainHealth { + var widgetStatus: DomainDigWidgetStatus { + switch self { + case .healthy: return .healthy + case .warning: return .warning + case .critical: return .critical + } + } + + var widgetSeverityRank: Int { + switch self { + case .healthy: return 0 + case .warning: return 1 + case .critical: return 2 + } + } +} diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index f0c4411..3f37cb9 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -2582,6 +2582,7 @@ final class DomainViewModel { DomainDataPortabilityService.saveTrackedDomains(trackedDomains) CloudSyncService.shared.scheduleSyncIfNeeded() refreshDataLifecycleSummary() + refreshWidgetData() } private func beginBulkPersistenceDeferral() { diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift index 72038a1..9823d37 100644 --- a/DomainDig/RootTabView.swift +++ b/DomainDig/RootTabView.swift @@ -12,6 +12,7 @@ struct RootTabView: View { @Bindable var viewModel: DomainViewModel @State private var purchaseService = PurchaseService.shared @State private var intentRouter = DomainDigIntentRouter.shared + @State private var detailDomain: TrackedDomain? @State private var selectedTab: RootTab = FeatureAccessService.currentTier == .free ? .inspect : .dashboard var body: some View { @@ -83,6 +84,11 @@ struct RootTabView: View { selectedTab = .dashboard } } + .sheet(item: $detailDomain) { trackedDomain in + NavigationStack { + TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain) + } + } .onOpenURL { url in guard let action = DomainDigDeepLink.action(from: url) else { return } perform(action) @@ -113,6 +119,19 @@ struct RootTabView: View { if viewModel.trackDomain(domain: domain, availabilityStatus: nil) { selectedTab = .dashboard } + case let .detail(domain): + // Open the tracked domain's detail (e.g. from a widget tap). If it is + // no longer tracked, fall back to inspecting it. + if let tracked = viewModel.trackedDomains.first( + where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } + ) { + detailDomain = tracked + } else { + perform(.inspect(domain)) + } + case .sweep: + selectedTab = .dashboard + viewModel.refreshAllTrackedDomains() } } } diff --git a/DomainDigWidget/DomainDigPortfolioWidget.swift b/DomainDigWidget/DomainDigPortfolioWidget.swift new file mode 100644 index 0000000..f26964c --- /dev/null +++ b/DomainDigWidget/DomainDigPortfolioWidget.swift @@ -0,0 +1,189 @@ +import SwiftUI +import WidgetKit + +struct DomainDigEntry: TimelineEntry { + let date: Date + let data: DomainDigWidgetData +} + +struct DomainDigProvider: TimelineProvider { + func placeholder(in context: Context) -> DomainDigEntry { + DomainDigEntry(date: Date(), data: .placeholder) + } + + func getSnapshot(in context: Context, completion: @escaping (DomainDigEntry) -> Void) { + let data = context.isPreview ? .placeholder : (DomainDigWidgetStore.read() ?? .placeholder) + completion(DomainDigEntry(date: Date(), data: data)) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline<DomainDigEntry>) -> Void) { + let data = DomainDigWidgetStore.read() ?? .empty + let entry = DomainDigEntry(date: Date(), data: data) + // The app reloads timelines on foreground and on watchlist changes; this + // periodic refresh is a backstop so cert countdowns stay roughly current. + let next = Calendar.current.date(byAdding: .hour, value: 6, to: Date()) + ?? Date().addingTimeInterval(6 * 3600) + completion(Timeline(entries: [entry], policy: .after(next))) + } +} + +struct DomainDigPortfolioWidget: Widget { + let kind = "DomainDigPortfolioWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: DomainDigProvider()) { entry in + DomainDigWidgetView(data: entry.data) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Domain Portfolio") + .description("Health and certificate status for your tracked domains.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} + +struct DomainDigWidgetView: View { + @Environment(\.widgetFamily) private var family + let data: DomainDigWidgetData + + var body: some View { + if data.totalDomains == 0 { + emptyState + } else { + switch family { + case .systemSmall: + smallView + default: + mediumOrLargeView + } + } + } + + private var emptyState: some View { + VStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.title2) + .foregroundStyle(.secondary) + Text("No tracked domains") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + + // MARK: Small + + private var smallView: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 4) { + Image(systemName: "shield.lefthalf.filled") + Text("DomainDig") + .fontWeight(.semibold) + Spacer() + } + .font(.caption2) + .foregroundStyle(.secondary) + + Text("\(data.totalDomains)") + .font(.system(size: 34, weight: .bold, design: .rounded)) + Text("tracked") + .font(.caption2) + .foregroundStyle(.secondary) + + Spacer(minLength: 0) + + HStack(spacing: 10) { + countPill(data.healthyCount, .green) + countPill(data.warningCount, .orange) + countPill(data.criticalCount, .red) + } + } + } + + private func countPill(_ value: Int, _ color: Color) -> some View { + HStack(spacing: 3) { + Circle().fill(color).frame(width: 7, height: 7) + Text("\(value)").font(.caption).fontWeight(.medium) + } + } + + // MARK: Medium / Large + + private var mediumOrLargeView: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Label("Domain Portfolio", systemImage: "shield.lefthalf.filled") + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + Spacer() + Text("\(data.totalDomains) tracked") + .font(.caption2) + .foregroundStyle(.secondary) + } + + HStack(spacing: 12) { + summaryStat(data.healthyCount, "Healthy", .green) + summaryStat(data.warningCount, "Warning", .orange) + summaryStat(data.criticalCount, "Critical", .red) + summaryStat(data.expiringSoonCount, "Expiring", .yellow) + } + + Divider() + + VStack(spacing: 6) { + ForEach(data.domains.prefix(family == .systemLarge ? 6 : 3)) { domain in + Link(destination: DomainDigDeepLink.url(for: .detail(domain.domain))) { + domainRow(domain) + } + } + } + Spacer(minLength: 0) + } + } + + private func summaryStat(_ value: Int, _ label: String, _ color: Color) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text("\(value)") + .font(.headline) + .foregroundStyle(color) + Text(label) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func domainRow(_ domain: DomainDigWidgetDomain) -> some View { + HStack(spacing: 6) { + Circle() + .fill(color(for: domain.status)) + .frame(width: 8, height: 8) + if domain.isPinned { + Image(systemName: "pin.fill") + .font(.system(size: 8)) + .foregroundStyle(.secondary) + } + Text(domain.domain) + .font(.caption) + .lineLimit(1) + Spacer(minLength: 4) + Text(certLabel(for: domain)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + private func certLabel(for domain: DomainDigWidgetDomain) -> String { + guard let days = domain.certDaysRemaining else { return "—" } + if days < 0 { return "expired" } + return "\(days)d" + } + + private func color(for status: DomainDigWidgetStatus) -> Color { + switch status { + case .healthy: return .green + case .warning: return .orange + case .critical: return .red + } + } +} diff --git a/DomainDigWidget/DomainDigWidget.entitlements b/DomainDigWidget/DomainDigWidget.entitlements new file mode 100644 index 0000000..eaa20ba --- /dev/null +++ b/DomainDigWidget/DomainDigWidget.entitlements @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>com.apple.security.application-groups</key> + <array> + <string>group.net.cleberg.DomainDig</string> + </array> +</dict> +</plist> diff --git a/DomainDigWidget/DomainDigWidgetBundle.swift b/DomainDigWidget/DomainDigWidgetBundle.swift new file mode 100644 index 0000000..483995c --- /dev/null +++ b/DomainDigWidget/DomainDigWidgetBundle.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct DomainDigWidgetBundle: WidgetBundle { + var body: some Widget { + DomainDigPortfolioWidget() + } +} diff --git a/DomainDigWidget/Info.plist b/DomainDigWidget/Info.plist new file mode 100644 index 0000000..d1b64db --- /dev/null +++ b/DomainDigWidget/Info.plist @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleDevelopmentRegion</key> + <string>$(DEVELOPMENT_LANGUAGE)</string> + <key>CFBundleDisplayName</key> + <string>DomainDig</string> + <key>CFBundleExecutable</key> + <string>$(EXECUTABLE_NAME)</string> + <key>CFBundleIdentifier</key> + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleName</key> + <string>$(PRODUCT_NAME)</string> + <key>CFBundlePackageType</key> + <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> + <key>CFBundleShortVersionString</key> + <string>$(MARKETING_VERSION)</string> + <key>CFBundleVersion</key> + <string>$(CURRENT_PROJECT_VERSION)</string> + <key>NSExtension</key> + <dict> + <key>NSExtensionPointIdentifier</key> + <string>com.apple.widgetkit-extension</string> + </dict> +</dict> +</plist> diff --git a/Shared/DomainDigDeepLink.swift b/Shared/DomainDigDeepLink.swift new file mode 100644 index 0000000..ba1d64b --- /dev/null +++ b/Shared/DomainDigDeepLink.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Shared builder/parser for the `domaindig://` URL scheme, used by the intents +/// and app (to open/route) and by the widget (to deep-link into a domain). +/// +/// Lives in `Shared/` so it compiles into both the app and the widget target. +/// It relies only on Foundation and no actor isolation, so it is safe in the +/// widget extension (`APPLICATION_EXTENSION_API_ONLY`). +enum DomainDigDeepLink { + static let scheme = "domaindig" + + enum Action: Equatable { + case inspect(String) + case watch(String) + case detail(String) + case sweep + + var host: String { + switch self { + case .inspect: return "inspect" + case .watch: return "watch" + case .detail: return "domain" + case .sweep: return "sweep" + } + } + + /// The domain the action targets, if any. `.sweep` has no domain. + var domain: String? { + switch self { + case let .inspect(domain), let .watch(domain), let .detail(domain): + return domain + case .sweep: + return nil + } + } + } + + static func url(for action: Action) -> URL { + var components = URLComponents() + components.scheme = scheme + components.host = action.host + if let domain = action.domain { + components.queryItems = [URLQueryItem(name: "domain", value: domain)] + } + // The scheme and host are fixed and any domain is percent-encoded by + // URLComponents, so this is always a valid URL. + return components.url! + } + + static func action(from url: URL) -> Action? { + guard url.scheme == scheme else { return nil } + + if url.host() == "sweep" { + return .sweep + } + + let domain = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first { $0.name == "domain" }? + .value? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !domain.isEmpty else { return nil } + + switch url.host() { + case "inspect": return .inspect(domain) + case "watch": return .watch(domain) + case "domain": return .detail(domain) + default: return nil + } + } +} diff --git a/Shared/DomainDigWidgetData.swift b/Shared/DomainDigWidgetData.swift new file mode 100644 index 0000000..5ceae48 --- /dev/null +++ b/Shared/DomainDigWidgetData.swift @@ -0,0 +1,82 @@ +import Foundation + +/// App Group + snapshot shared between the app (writer) and the widget (reader). +/// +/// Deliberately self-contained: it references no app-target types (TrackedDomain, +/// PortfolioSnapshot, DomainHealth, …) so it compiles unchanged into the widget +/// extension. The app maps its richer types into these DTOs. +enum DomainDigWidgetStore { + static let appGroupID = "group.net.cleberg.DomainDig" + private static let key = "widgetData" + + private static var defaults: UserDefaults? { + UserDefaults(suiteName: appGroupID) + } + + static func write(_ data: DomainDigWidgetData) { + guard let defaults, let encoded = try? JSONEncoder().encode(data) else { return } + defaults.set(encoded, forKey: key) + } + + static func read() -> DomainDigWidgetData? { + guard let defaults, + let encoded = defaults.data(forKey: key), + let data = try? JSONDecoder().decode(DomainDigWidgetData.self, from: encoded) + else { return nil } + return data + } +} + +struct DomainDigWidgetData: Codable, Sendable { + var generatedAt: Date + var totalDomains: Int + var healthyCount: Int + var warningCount: Int + var criticalCount: Int + var expiringSoonCount: Int + var unreachableCount: Int + var domains: [DomainDigWidgetDomain] + + static let placeholder = DomainDigWidgetData( + generatedAt: .distantPast, + totalDomains: 3, + healthyCount: 2, + warningCount: 1, + criticalCount: 0, + expiringSoonCount: 1, + unreachableCount: 0, + domains: [ + DomainDigWidgetDomain(domain: "example.com", isPinned: true, status: .healthy, certDaysRemaining: 240, lastChange: nil), + DomainDigWidgetDomain(domain: "cleberg.net", isPinned: false, status: .warning, certDaysRemaining: 21, lastChange: nil), + DomainDigWidgetDomain(domain: "example.org", isPinned: false, status: .healthy, certDaysRemaining: 88, lastChange: nil) + ] + ) + + /// Empty state used before the app has written any data. + static let empty = DomainDigWidgetData( + generatedAt: .distantPast, + totalDomains: 0, + healthyCount: 0, + warningCount: 0, + criticalCount: 0, + expiringSoonCount: 0, + unreachableCount: 0, + domains: [] + ) +} + +struct DomainDigWidgetDomain: Codable, Sendable, Identifiable { + var domain: String + var isPinned: Bool + var status: DomainDigWidgetStatus + var certDaysRemaining: Int? + var lastChange: Date? + + var id: String { domain } +} + +enum DomainDigWidgetStatus: String, Codable, Sendable { + case healthy + case warning + case critical +} |
