From d3a27d06809e6744c092f9bed4bf0537843d0210 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 17 Jul 2026 00:27:45 -0500 Subject: Complete v4.5.0: Run Sweep intent, detail deep link, and portfolio widget 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. --- DomainDigWidget/DomainDigPortfolioWidget.swift | 189 +++++++++++++++++++++++++ DomainDigWidget/DomainDigWidget.entitlements | 10 ++ DomainDigWidget/DomainDigWidgetBundle.swift | 9 ++ DomainDigWidget/Info.plist | 29 ++++ 4 files changed, 237 insertions(+) create mode 100644 DomainDigWidget/DomainDigPortfolioWidget.swift create mode 100644 DomainDigWidget/DomainDigWidget.entitlements create mode 100644 DomainDigWidget/DomainDigWidgetBundle.swift create mode 100644 DomainDigWidget/Info.plist (limited to 'DomainDigWidget') 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) -> 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 @@ + + + + + com.apple.security.application-groups + + group.net.cleberg.DomainDig + + + 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 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + DomainDig + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + -- cgit v1.2.3