Commit b4b94d54cb

b4b94d54cbed9334a28cdcd70d3a8a0fd06e04ef

parent: 065e1e77ff

Unsigned

cmc <hello@cleberg.net> · 2026-07-17 13:36 UTC

Implement v4.6.0: sweep Live Activity, share extension, iPad split view, 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.

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +164 −18
@@ -13,8 +13,9 @@
13 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; }; 13 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; };
14 8BCA3CC02F9C8D57004B742C /* LocalAPIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */; }; 14 8BCA3CC02F9C8D57004B742C /* LocalAPIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */; };
15 8BCA3CC12F9C8D57004B742C /* LocalAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */; }; 15 8BCA3CC12F9C8D57004B742C /* LocalAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */; };
16 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; };
17 8BDA00010000000000000002 /* DomainDigWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 16 8BDA00010000000000000002 /* DomainDigWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
17 8BDB00010000000000000002 /* DomainDigShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDB00010000000000000001 /* DomainDigShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
18 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; };
18/* End PBXBuildFile section */ 19/* End PBXBuildFile section */
19 20
20/* Begin PBXContainerItemProxy section */ 21/* Begin PBXContainerItemProxy section */
@@ -25,6 +26,13 @@
25 remoteGlobalIDString = 8BDA00010000000000000006; 26 remoteGlobalIDString = 8BDA00010000000000000006;
26 remoteInfo = DomainDigWidgetExtension; 27 remoteInfo = DomainDigWidgetExtension;
27 }; 28 };
29 8BDB00010000000000000003 /* PBXContainerItemProxy */ = {
30 isa = PBXContainerItemProxy;
31 containerPortal = 8B7800612F6090E300933221 /* Project object */;
32 proxyType = 1;
33 remoteGlobalIDString = 8BDB00010000000000000006;
34 remoteInfo = DomainDigShareExtension;
35 };
28/* End PBXContainerItemProxy section */ 36/* End PBXContainerItemProxy section */
29 37
30/* Begin PBXCopyFilesBuildPhase section */ 38/* Begin PBXCopyFilesBuildPhase section */
@@ -35,6 +43,7 @@
35 dstSubfolderSpec = 13; 43 dstSubfolderSpec = 13;
36 files = ( 44 files = (
37 8BDA00010000000000000002 /* DomainDigWidgetExtension.appex in Embed Foundation Extensions */, 45 8BDA00010000000000000002 /* DomainDigWidgetExtension.appex in Embed Foundation Extensions */,
46 8BDB00010000000000000002 /* DomainDigShareExtension.appex in Embed Foundation Extensions */,
38 ); 47 );
39 name = "Embed Foundation Extensions"; 48 name = "Embed Foundation Extensions";
40 runOnlyForDeploymentPostprocessing = 0; 49 runOnlyForDeploymentPostprocessing = 0;
@@ -49,8 +58,9 @@
49 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; }; 58 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; };
50 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIModels.swift; sourceTree = "<group>"; }; 59 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIModels.swift; sourceTree = "<group>"; };
51 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIService.swift; sourceTree = "<group>"; }; 60 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIService.swift; sourceTree = "<group>"; };
52 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; };
53 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 61 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
62 8BDB00010000000000000001 /* DomainDigShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
63 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; };
54/* End PBXFileReference section */ 64/* End PBXFileReference section */
55 65
56/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 66/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -70,6 +80,13 @@
70 ); 80 );
71 target = 8BDA00010000000000000006 /* DomainDigWidgetExtension */; 81 target = 8BDA00010000000000000006 /* DomainDigWidgetExtension */;
72 }; 82 };
83 8BDB0001000000000000000F /* Exceptions for "DomainDigShareExtension" folder in "DomainDigShareExtension" target */ = {
84 isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
85 membershipExceptions = (
86 Info.plist,
87 );
88 target = 8BDB00010000000000000006 /* DomainDigShareExtension */;
89 };
73/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 90/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
74 91
75/* Begin PBXFileSystemSynchronizedRootGroup section */ 92/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -94,6 +111,14 @@
94 path = DomainDigWidget; 111 path = DomainDigWidget;
95 sourceTree = "<group>"; 112 sourceTree = "<group>";
96 }; 113 };
114 8BDB0001000000000000000E /* DomainDigShareExtension */ = {
115 isa = PBXFileSystemSynchronizedRootGroup;
116 exceptions = (
117 8BDB0001000000000000000F /* Exceptions for "DomainDigShareExtension" folder in "DomainDigShareExtension" target */,
118 );
119 path = DomainDigShareExtension;
120 sourceTree = "<group>";
121 };
97/* End PBXFileSystemSynchronizedRootGroup section */ 122/* End PBXFileSystemSynchronizedRootGroup section */
98 123
99/* Begin PBXFrameworksBuildPhase section */ 124/* Begin PBXFrameworksBuildPhase section */
@@ -111,6 +136,13 @@
111 ); 136 );
112 runOnlyForDeploymentPostprocessing = 0; 137 runOnlyForDeploymentPostprocessing = 0;
113 }; 138 };
139 8BDB00010000000000000008 /* Frameworks */ = {
140 isa = PBXFrameworksBuildPhase;
141 buildActionMask = 2147483647;
142 files = (
143 );
144 runOnlyForDeploymentPostprocessing = 0;
145 };
114/* End PBXFrameworksBuildPhase section */ 146/* End PBXFrameworksBuildPhase section */
115 147
116/* Begin PBXGroup section */ 148/* Begin PBXGroup section */
@@ -120,6 +152,7 @@
120 8B78006B2F6090E300933221 /* DomainDig */, 152 8B78006B2F6090E300933221 /* DomainDig */,
121 8BDA0001000000000000000D /* Shared */, 153 8BDA0001000000000000000D /* Shared */,
122 8BDA0001000000000000000E /* DomainDigWidget */, 154 8BDA0001000000000000000E /* DomainDigWidget */,
155 8BDB0001000000000000000E /* DomainDigShareExtension */,
123 8B78006A2F6090E300933221 /* Products */, 156 8B78006A2F6090E300933221 /* Products */,
124 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */, 157 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */,
125 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */, 158 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */,
@@ -136,6 +169,7 @@
136 children = ( 169 children = (
137 8B7800692F6090E300933221 /* DomainDig.app */, 170 8B7800692F6090E300933221 /* DomainDig.app */,
138 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */, 171 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */,
172 8BDB00010000000000000001 /* DomainDigShareExtension.appex */,
139 ); 173 );
140 name = Products; 174 name = Products;
141 sourceTree = "<group>"; 175 sourceTree = "<group>";
@@ -156,6 +190,7 @@
156 ); 190 );
157 dependencies = ( 191 dependencies = (
158 8BDA00010000000000000004 /* PBXTargetDependency */, 192 8BDA00010000000000000004 /* PBXTargetDependency */,
193 8BDB00010000000000000004 /* PBXTargetDependency */,
159 ); 194 );
160 fileSystemSynchronizedGroups = ( 195 fileSystemSynchronizedGroups = (
161 8B78006B2F6090E300933221 /* DomainDig */, 196 8B78006B2F6090E300933221 /* DomainDig */,
@@ -191,15 +226,30 @@
191 productReference = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */; 226 productReference = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */;
192 productType = "com.apple.product-type.app-extension"; 227 productType = "com.apple.product-type.app-extension";
193 }; 228 };
194/* End PBXNativeTarget section */ 229 8BDB00010000000000000006 /* DomainDigShareExtension */ = {
195 230 isa = PBXNativeTarget;
196/* Begin PBXTargetDependency section */ 231 buildConfigurationList = 8BDB0001000000000000000A /* Build configuration list for PBXNativeTarget "DomainDigShareExtension" */;
197 8BDA00010000000000000004 /* PBXTargetDependency */ = { 232 buildPhases = (
198 isa = PBXTargetDependency; 233 8BDB00010000000000000007 /* Sources */,
199 target = 8BDA00010000000000000006 /* DomainDigWidgetExtension */; 234 8BDB00010000000000000008 /* Frameworks */,
200 targetProxy = 8BDA00010000000000000003 /* PBXContainerItemProxy */; 235 8BDB00010000000000000009 /* Resources */,
236 );
237 buildRules = (
238 );
239 dependencies = (
240 );
241 fileSystemSynchronizedGroups = (
242 8BDA0001000000000000000D /* Shared */,
243 8BDB0001000000000000000E /* DomainDigShareExtension */,
244 );
245 name = DomainDigShareExtension;
246 packageProductDependencies = (
247 );
248 productName = DomainDigShareExtension;
249 productReference = 8BDB00010000000000000001 /* DomainDigShareExtension.appex */;
250 productType = "com.apple.product-type.app-extension";
201 }; 251 };
202/* End PBXTargetDependency section */ 252/* End PBXNativeTarget section */
203 253
204/* Begin PBXProject section */ 254/* Begin PBXProject section */
205 8B7800612F6090E300933221 /* Project object */ = { 255 8B7800612F6090E300933221 /* Project object */ = {
@@ -220,6 +270,9 @@
220 8BDA00010000000000000006 = { 270 8BDA00010000000000000006 = {
221 CreatedOnToolsVersion = 26.3; 271 CreatedOnToolsVersion = 26.3;
222 }; 272 };
273 8BDB00010000000000000006 = {
274 CreatedOnToolsVersion = 26.3;
275 };
223 }; 276 };
224 }; 277 };
225 buildConfigurationList = 8B7800642F6090E300933221 /* Build configuration list for PBXProject "DomainDig" */; 278 buildConfigurationList = 8B7800642F6090E300933221 /* Build configuration list for PBXProject "DomainDig" */;
@@ -238,6 +291,7 @@
238 targets = ( 291 targets = (
239 8B7800682F6090E300933221 /* DomainDig */, 292 8B7800682F6090E300933221 /* DomainDig */,
240 8BDA00010000000000000006 /* DomainDigWidgetExtension */, 293 8BDA00010000000000000006 /* DomainDigWidgetExtension */,
294 8BDB00010000000000000006 /* DomainDigShareExtension */,
241 ); 295 );
242 }; 296 };
243/* End PBXProject section */ 297/* End PBXProject section */
@@ -257,6 +311,13 @@
257 ); 311 );
258 runOnlyForDeploymentPostprocessing = 0; 312 runOnlyForDeploymentPostprocessing = 0;
259 }; 313 };
314 8BDB00010000000000000009 /* Resources */ = {
315 isa = PBXResourcesBuildPhase;
316 buildActionMask = 2147483647;
317 files = (
318 );
319 runOnlyForDeploymentPostprocessing = 0;
320 };
260/* End PBXResourcesBuildPhase section */ 321/* End PBXResourcesBuildPhase section */
261 322
262/* Begin PBXSourcesBuildPhase section */ 323/* Begin PBXSourcesBuildPhase section */
@@ -281,8 +342,28 @@
281 ); 342 );
282 runOnlyForDeploymentPostprocessing = 0; 343 runOnlyForDeploymentPostprocessing = 0;
283 }; 344 };
345 8BDB00010000000000000007 /* Sources */ = {
346 isa = PBXSourcesBuildPhase;
347 buildActionMask = 2147483647;
348 files = (
349 );
350 runOnlyForDeploymentPostprocessing = 0;
351 };
284/* End PBXSourcesBuildPhase section */ 352/* End PBXSourcesBuildPhase section */
285 353
354/* Begin PBXTargetDependency section */
355 8BDA00010000000000000004 /* PBXTargetDependency */ = {
356 isa = PBXTargetDependency;
357 target = 8BDA00010000000000000006 /* DomainDigWidgetExtension */;
358 targetProxy = 8BDA00010000000000000003 /* PBXContainerItemProxy */;
359 };
360 8BDB00010000000000000004 /* PBXTargetDependency */ = {
361 isa = PBXTargetDependency;
362 target = 8BDB00010000000000000006 /* DomainDigShareExtension */;
363 targetProxy = 8BDB00010000000000000003 /* PBXContainerItemProxy */;
364 };
365/* End PBXTargetDependency section */
366
286/* Begin XCBuildConfiguration section */ 367/* Begin XCBuildConfiguration section */
287 8B7800722F6090E400933221 /* Debug */ = { 368 8B7800722F6090E400933221 /* Debug */ = {
288 isa = XCBuildConfiguration; 369 isa = XCBuildConfiguration;
@@ -414,7 +495,7 @@
414 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 495 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
415 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; 496 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
416 CODE_SIGN_STYLE = Automatic; 497 CODE_SIGN_STYLE = Automatic;
417 CURRENT_PROJECT_VERSION = 37; 498 CURRENT_PROJECT_VERSION = 38;
418 DEVELOPMENT_TEAM = ZCNAX3VL9D; 499 DEVELOPMENT_TEAM = ZCNAX3VL9D;
419 ENABLE_PREVIEWS = YES; 500 ENABLE_PREVIEWS = YES;
420 GENERATE_INFOPLIST_FILE = YES; 501 GENERATE_INFOPLIST_FILE = YES;
@@ -431,7 +512,7 @@
431 "$(inherited)", 512 "$(inherited)",
432 "@executable_path/Frameworks", 513 "@executable_path/Frameworks",
433 ); 514 );
434 MARKETING_VERSION = 4.5.0; 515 MARKETING_VERSION = 4.6.0;
435 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 516 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
436 PRODUCT_NAME = "$(TARGET_NAME)"; 517 PRODUCT_NAME = "$(TARGET_NAME)";
437 STRING_CATALOG_GENERATE_SYMBOLS = YES; 518 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -451,7 +532,7 @@
451 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 532 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
452 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; 533 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
453 CODE_SIGN_STYLE = Automatic; 534 CODE_SIGN_STYLE = Automatic;
454 CURRENT_PROJECT_VERSION = 37; 535 CURRENT_PROJECT_VERSION = 38;
455 DEVELOPMENT_TEAM = ZCNAX3VL9D; 536 DEVELOPMENT_TEAM = ZCNAX3VL9D;
456 ENABLE_PREVIEWS = YES; 537 ENABLE_PREVIEWS = YES;
457 GENERATE_INFOPLIST_FILE = YES; 538 GENERATE_INFOPLIST_FILE = YES;
@@ -468,7 +549,7 @@
468 "$(inherited)", 549 "$(inherited)",
469 "@executable_path/Frameworks", 550 "@executable_path/Frameworks",
470 ); 551 );
471 MARKETING_VERSION = 4.5.0; 552 MARKETING_VERSION = 4.6.0;
472 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 553 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
473 PRODUCT_NAME = "$(TARGET_NAME)"; 554 PRODUCT_NAME = "$(TARGET_NAME)";
474 STRING_CATALOG_GENERATE_SYMBOLS = YES; 555 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -487,7 +568,7 @@
487 APPLICATION_EXTENSION_API_ONLY = YES; 568 APPLICATION_EXTENSION_API_ONLY = YES;
488 CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements; 569 CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements;
489 CODE_SIGN_STYLE = Automatic; 570 CODE_SIGN_STYLE = Automatic;
490 CURRENT_PROJECT_VERSION = 37; 571 CURRENT_PROJECT_VERSION = 38;
491 DEVELOPMENT_TEAM = ZCNAX3VL9D; 572 DEVELOPMENT_TEAM = ZCNAX3VL9D;
492 GENERATE_INFOPLIST_FILE = NO; 573 GENERATE_INFOPLIST_FILE = NO;
493 INFOPLIST_FILE = DomainDigWidget/Info.plist; 574 INFOPLIST_FILE = DomainDigWidget/Info.plist;
@@ -497,7 +578,7 @@
497 "@executable_path/Frameworks", 578 "@executable_path/Frameworks",
498 "@executable_path/../../Frameworks", 579 "@executable_path/../../Frameworks",
499 ); 580 );
500 MARKETING_VERSION = 4.5.0; 581 MARKETING_VERSION = 4.6.0;
501 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget; 582 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget;
502 PRODUCT_NAME = "$(TARGET_NAME)"; 583 PRODUCT_NAME = "$(TARGET_NAME)";
503 SKIP_INSTALL = YES; 584 SKIP_INSTALL = YES;
@@ -515,7 +596,7 @@
515 APPLICATION_EXTENSION_API_ONLY = YES; 596 APPLICATION_EXTENSION_API_ONLY = YES;
516 CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements; 597 CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements;
517 CODE_SIGN_STYLE = Automatic; 598 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 37; 599 CURRENT_PROJECT_VERSION = 38;
519 DEVELOPMENT_TEAM = ZCNAX3VL9D; 600 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520 GENERATE_INFOPLIST_FILE = NO; 601 GENERATE_INFOPLIST_FILE = NO;
521 INFOPLIST_FILE = DomainDigWidget/Info.plist; 602 INFOPLIST_FILE = DomainDigWidget/Info.plist;
@@ -525,7 +606,7 @@
525 "@executable_path/Frameworks", 606 "@executable_path/Frameworks",
526 "@executable_path/../../Frameworks", 607 "@executable_path/../../Frameworks",
527 ); 608 );
528 MARKETING_VERSION = 4.5.0; 609 MARKETING_VERSION = 4.6.0;
529 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget; 610 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget;
530 PRODUCT_NAME = "$(TARGET_NAME)"; 611 PRODUCT_NAME = "$(TARGET_NAME)";
531 SKIP_INSTALL = YES; 612 SKIP_INSTALL = YES;
@@ -537,6 +618,62 @@
537 }; 618 };
538 name = Release; 619 name = Release;
539 }; 620 };
621 8BDB0001000000000000000B /* Debug */ = {
622 isa = XCBuildConfiguration;
623 buildSettings = {
624 APPLICATION_EXTENSION_API_ONLY = YES;
625 CODE_SIGN_ENTITLEMENTS = DomainDigShareExtension/DomainDigShareExtension.entitlements;
626 CODE_SIGN_STYLE = Automatic;
627 CURRENT_PROJECT_VERSION = 38;
628 DEVELOPMENT_TEAM = ZCNAX3VL9D;
629 GENERATE_INFOPLIST_FILE = NO;
630 INFOPLIST_FILE = DomainDigShareExtension/Info.plist;
631 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
632 LD_RUNPATH_SEARCH_PATHS = (
633 "$(inherited)",
634 "@executable_path/Frameworks",
635 "@executable_path/../../Frameworks",
636 );
637 MARKETING_VERSION = 4.6.0;
638 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigShareExtension;
639 PRODUCT_NAME = "$(TARGET_NAME)";
640 SKIP_INSTALL = YES;
641 SWIFT_APPROACHABLE_CONCURRENCY = YES;
642 SWIFT_EMIT_LOC_STRINGS = YES;
643 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
644 SWIFT_VERSION = 5.0;
645 TARGETED_DEVICE_FAMILY = "1,2";
646 };
647 name = Debug;
648 };
649 8BDB0001000000000000000C /* Release */ = {
650 isa = XCBuildConfiguration;
651 buildSettings = {
652 APPLICATION_EXTENSION_API_ONLY = YES;
653 CODE_SIGN_ENTITLEMENTS = DomainDigShareExtension/DomainDigShareExtension.entitlements;
654 CODE_SIGN_STYLE = Automatic;
655 CURRENT_PROJECT_VERSION = 38;
656 DEVELOPMENT_TEAM = ZCNAX3VL9D;
657 GENERATE_INFOPLIST_FILE = NO;
658 INFOPLIST_FILE = DomainDigShareExtension/Info.plist;
659 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
660 LD_RUNPATH_SEARCH_PATHS = (
661 "$(inherited)",
662 "@executable_path/Frameworks",
663 "@executable_path/../../Frameworks",
664 );
665 MARKETING_VERSION = 4.6.0;
666 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigShareExtension;
667 PRODUCT_NAME = "$(TARGET_NAME)";
668 SKIP_INSTALL = YES;
669 SWIFT_APPROACHABLE_CONCURRENCY = YES;
670 SWIFT_EMIT_LOC_STRINGS = YES;
671 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
672 SWIFT_VERSION = 5.0;
673 TARGETED_DEVICE_FAMILY = "1,2";
674 };
675 name = Release;
676 };
540/* End XCBuildConfiguration section */ 677/* End XCBuildConfiguration section */
541 678
542/* Begin XCConfigurationList section */ 679/* Begin XCConfigurationList section */
@@ -567,6 +704,15 @@
567 defaultConfigurationIsVisible = 0; 704 defaultConfigurationIsVisible = 0;
568 defaultConfigurationName = Release; 705 defaultConfigurationName = Release;
569 }; 706 };
707 8BDB0001000000000000000A /* Build configuration list for PBXNativeTarget "DomainDigShareExtension" */ = {
708 isa = XCConfigurationList;
709 buildConfigurations = (
710 8BDB0001000000000000000B /* Debug */,
711 8BDB0001000000000000000C /* Release */,
712 );
713 defaultConfigurationIsVisible = 0;
714 defaultConfigurationName = Release;
715 };
570/* End XCConfigurationList section */ 716/* End XCConfigurationList section */
571 }; 717 };
572 rootObject = 8B7800612F6090E300933221 /* Project object */; 718 rootObject = 8B7800612F6090E300933221 /* Project object */;
DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist +6 −1
@@ -9,11 +9,16 @@
9 <key>orderHint</key> 9 <key>orderHint</key>
10 <integer>0</integer> 10 <integer>0</integer>
11 </dict> 11 </dict>
12 <key>DomainDigWidgetExtension.xcscheme_^#shared#^_</key> 12 <key>DomainDigShareExtension.xcscheme_^#shared#^_</key>
13 <dict> 13 <dict>
14 <key>orderHint</key> 14 <key>orderHint</key>
15 <integer>1</integer> 15 <integer>1</integer>
16 </dict> 16 </dict>
17 <key>DomainDigWidgetExtension.xcscheme_^#shared#^_</key>
18 <dict>
19 <key>orderHint</key>
20 <integer>2</integer>
21 </dict>
17 </dict> 22 </dict>
18 <key>SuppressBuildableAutocreation</key> 23 <key>SuppressBuildableAutocreation</key>
19 <dict> 24 <dict>
DomainDig/AppVersion.swift +1 −1
@@ -2,6 +2,6 @@ import Foundation
2 2
3enum AppVersion { 3enum AppVersion {
4 nonisolated static var current: String { 4 nonisolated static var current: String {
5 "4.5.0" 5 "4.6.0"
6 } 6 }
7} 7}
DomainDig/DomainDigApp.swift +9
@@ -40,6 +40,7 @@ struct DomainDigApp: App {
40 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() 40 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
41 IntegrationService.shared.processQueueNow() 41 IntegrationService.shared.processQueueNow()
42 viewModel.refreshWidgetData() 42 viewModel.refreshWidgetData()
43 consumeShareInbox()
43 } 44 }
44 .onReceive(NotificationCenter.default.publisher(for: .cloudSyncDidApplyChanges)) { _ in 45 .onReceive(NotificationCenter.default.publisher(for: .cloudSyncDidApplyChanges)) { _ in
45 viewModel.refreshPersistedData() 46 viewModel.refreshPersistedData()
@@ -59,6 +60,14 @@ struct DomainDigApp: App {
59 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() 60 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
60 IntegrationService.shared.processQueueNow() 61 IntegrationService.shared.processQueueNow()
61 viewModel.refreshWidgetData() 62 viewModel.refreshWidgetData()
63 consumeShareInbox()
62 } 64 }
63 } 65 }
66
67 /// Picks up a domain shared via the share extension and routes it into an
68 /// inspection through the intent router (consumed by `RootTabView`).
69 private func consumeShareInbox() {
70 guard let domain = DomainDigShareInbox.consume() else { return }
71 DomainDigIntentRouter.shared.pendingAction = .inspect(domain)
72 }
64} 73}
DomainDig/DomainViewModel.swift +16
@@ -2834,6 +2834,11 @@ final class DomainViewModel {
2834 customPortScanTask?.cancel() 2834 customPortScanTask?.cancel()
2835 batchTask?.cancel() 2835 batchTask?.cancel()
2836 2836
2837 SweepActivityController.shared.begin(
2838 title: source == .watchlistRefresh ? "Watchlist Sweep" : "Batch Lookup",
2839 total: domains.count
2840 )
2841
2837 batchTask = Task { [weak self] in 2842 batchTask = Task { [weak self] in
2838 guard let self else { return } 2843 guard let self else { return }
2839 self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded() 2844 self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
@@ -2928,6 +2933,11 @@ final class DomainViewModel {
2928 errorMessage: "Lookup cancelled" 2933 errorMessage: "Lookup cancelled"
2929 ) 2934 )
2930 batchCompletedCount += 1 2935 batchCompletedCount += 1
2936 SweepActivityController.shared.update(
2937 completed: batchCompletedCount,
2938 total: batchTotalCount,
2939 currentDomain: batchCurrentDomain
2940 )
2931 return 2941 return
2932 } 2942 }
2933 2943
@@ -2956,6 +2966,11 @@ final class DomainViewModel {
2956 errorMessage: payload.snapshot.statusMessage 2966 errorMessage: payload.snapshot.statusMessage
2957 ) 2967 )
2958 batchCompletedCount += 1 2968 batchCompletedCount += 1
2969 SweepActivityController.shared.update(
2970 completed: batchCompletedCount,
2971 total: batchTotalCount,
2972 currentDomain: batchCurrentDomain
2973 )
2959 } 2974 }
2960 2975
2961 private func finishBatchLookup(source: BatchLookupSource) { 2976 private func finishBatchLookup(source: BatchLookupSource) {
@@ -2989,6 +3004,7 @@ final class DomainViewModel {
2989 generatedAt: Date() 3004 generatedAt: Date()
2990 ) 3005 )
2991 latestBatchSweepSummary = summary 3006 latestBatchSweepSummary = summary
3007 SweepActivityController.shared.end(changed: changedCount, warnings: warningCount)
2992 3008
2993 if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName { 3009 if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName {
2994 let workflowReports: [DomainReport] = summary.results.compactMap { result in 3010 let workflowReports: [DomainReport] = summary.results.compactMap { result in
DomainDig/Info.plist +2
@@ -23,6 +23,8 @@
23 </array> 23 </array>
24 <key>CKSharingSupported</key> 24 <key>CKSharingSupported</key>
25 <true/> 25 <true/>
26 <key>NSSupportsLiveActivities</key>
27 <true/>
26 <key>UIBackgroundModes</key> 28 <key>UIBackgroundModes</key>
27 <array> 29 <array>
28 <string>fetch</string> 30 <string>fetch</string>
DomainDig/LocalNotificationService.swift +59 −6
@@ -7,8 +7,27 @@ final class LocalNotificationService {
7 7
8 private init() {} 8 private init() {}
9 9
10 static let domainUserInfoKey = "domain"
11 static let domainCategoryIdentifier = "domain-event"
12 static let reinspectActionIdentifier = "reinspect"
13
10 func configureForegroundPresentation() { 14 func configureForegroundPresentation() {
11 UNUserNotificationCenter.current().delegate = NotificationCenterDelegate.shared 15 let center = UNUserNotificationCenter.current()
16 center.delegate = NotificationCenterDelegate.shared
17
18 let reinspect = UNNotificationAction(
19 identifier: Self.reinspectActionIdentifier,
20 title: "Re-inspect",
21 options: []
22 )
23 center.setNotificationCategories([
24 UNNotificationCategory(
25 identifier: Self.domainCategoryIdentifier,
26 actions: [reinspect],
27 intentIdentifiers: [],
28 options: []
29 )
30 ])
12 } 31 }
13 32
14 func requestAuthorizationIfNeeded() async -> Bool { 33 func requestAuthorizationIfNeeded() async -> Bool {
@@ -44,7 +63,8 @@ final class LocalNotificationService {
44 identifier: "domain-change-\(domain)", 63 identifier: "domain-change-\(domain)",
45 title: domain, 64 title: domain,
46 body: message, 65 body: message,
47 interruptionLevel: severity == .high ? .timeSensitive : .active 66 interruptionLevel: severity == .high ? .timeSensitive : .active,
67 domain: domain
48 ) 68 )
49 } 69 }
50 70
@@ -53,7 +73,8 @@ final class LocalNotificationService {
53 identifier: "cert-warning-\(domain)", 73 identifier: "cert-warning-\(domain)",
54 title: domain, 74 title: domain,
55 body: "Certificate expires in \(daysRemaining) days", 75 body: "Certificate expires in \(daysRemaining) days",
56 interruptionLevel: .timeSensitive 76 interruptionLevel: .timeSensitive,
77 domain: domain
57 ) 78 )
58 } 79 }
59 80
@@ -74,7 +95,8 @@ final class LocalNotificationService {
74 identifier: "monitoring-\(domain)-\(UUID().uuidString)", 95 identifier: "monitoring-\(domain)-\(UUID().uuidString)",
75 title: domain, 96 title: domain,
76 body: message, 97 body: message,
77 interruptionLevel: interruptionLevel 98 interruptionLevel: interruptionLevel,
99 domain: domain
78 ) 100 )
79 } 101 }
80 102
@@ -103,7 +125,8 @@ final class LocalNotificationService {
103 identifier: "monitoring-summary-\(domain)-\(UUID().uuidString)", 125 identifier: "monitoring-summary-\(domain)-\(UUID().uuidString)",
104 title: domain, 126 title: domain,
105 body: body, 127 body: body,
106 interruptionLevel: interruptionLevel 128 interruptionLevel: interruptionLevel,
129 domain: domain
107 ) 130 )
108 } 131 }
109 132
@@ -127,13 +150,20 @@ final class LocalNotificationService {
127 identifier: String, 150 identifier: String,
128 title: String, 151 title: String,
129 body: String, 152 body: String,
130 interruptionLevel: UNNotificationInterruptionLevel 153 interruptionLevel: UNNotificationInterruptionLevel,
154 domain: String? = nil
131 ) async { 155 ) async {
132 let content = UNMutableNotificationContent() 156 let content = UNMutableNotificationContent()
133 content.title = title 157 content.title = title
134 content.body = body 158 content.body = body
135 content.sound = .default 159 content.sound = .default
136 content.interruptionLevel = interruptionLevel 160 content.interruptionLevel = interruptionLevel
161 if let domain {
162 // Group alerts per domain and let taps/actions route back into it.
163 content.threadIdentifier = domain
164 content.userInfo = [Self.domainUserInfoKey: domain]
165 content.categoryIdentifier = Self.domainCategoryIdentifier
166 }
137 167
138 let request = UNNotificationRequest( 168 let request = UNNotificationRequest(
139 identifier: identifier, 169 identifier: identifier,
@@ -154,4 +184,27 @@ private final class NotificationCenterDelegate: NSObject, UNUserNotificationCent
154 ) async -> UNNotificationPresentationOptions { 184 ) async -> UNNotificationPresentationOptions {
155 [.banner, .list, .sound] 185 [.banner, .list, .sound]
156 } 186 }
187
188 func userNotificationCenter(
189 _ center: UNUserNotificationCenter,
190 didReceive response: UNNotificationResponse
191 ) async {
192 let userInfo = response.notification.request.content.userInfo
193 guard let domain = userInfo[LocalNotificationService.domainUserInfoKey] as? String,
194 !domain.isEmpty
195 else { return }
196
197 let action: DomainDigDeepLink.Action
198 switch response.actionIdentifier {
199 case LocalNotificationService.reinspectActionIdentifier:
200 action = .inspect(domain)
201 default:
202 // Default tap: open the tracked domain's detail.
203 action = .detail(domain)
204 }
205
206 await MainActor.run {
207 DomainDigIntentRouter.shared.pendingAction = action
208 }
209 }
157} 210}
DomainDig/RootTabView.swift +93 −43
@@ -1,15 +1,36 @@
1import SwiftUI 1import SwiftUI
2 2
3private enum RootTab: Hashable { 3private enum RootTab: Hashable, CaseIterable {
4 case dashboard 4 case dashboard
5 case audit 5 case audit
6 case history 6 case history
7 case inspect 7 case inspect
8 case settings 8 case settings
9
10 var title: String {
11 switch self {
12 case .dashboard: return "Dashboard"
13 case .audit: return "Audit"
14 case .history: return "History"
15 case .inspect: return "Inspect"
16 case .settings: return "Settings"
17 }
18 }
19
20 var systemImage: String {
21 switch self {
22 case .dashboard: return "square.grid.2x2"
23 case .audit: return "checklist"
24 case .history: return "clock.arrow.trianglehead.counterclockwise.rotate.90"
25 case .inspect: return "magnifyingglass"
26 case .settings: return "gearshape"
27 }
28 }
9} 29}
10 30
11struct RootTabView: View { 31struct RootTabView: View {
12 @Bindable var viewModel: DomainViewModel 32 @Bindable var viewModel: DomainViewModel
33 @Environment(\.horizontalSizeClass) private var horizontalSizeClass
13 @State private var purchaseService = PurchaseService.shared 34 @State private var purchaseService = PurchaseService.shared
14 @State private var intentRouter = DomainDigIntentRouter.shared 35 @State private var intentRouter = DomainDigIntentRouter.shared
15 @State private var detailDomain: TrackedDomain? 36 @State private var detailDomain: TrackedDomain?
@@ -18,44 +39,12 @@ struct RootTabView: View {
18 var body: some View { 39 var body: some View {
19 let _ = purchaseService.currentTier 40 let _ = purchaseService.currentTier
20 41
21 TabView(selection: $selectedTab) { 42 Group {
22 NavigationStack { 43 if horizontalSizeClass == .regular {
23 DashboardView(viewModel: viewModel) 44 splitLayout
24 } 45 } else {
25 .tabItem { 46 tabLayout
26 Label("Dashboard", systemImage: "square.grid.2x2")
27 }
28 .tag(RootTab.dashboard)
29
30 NavigationStack {
31 AuditListView(viewModel: viewModel)
32 }
33 .tabItem {
34 Label("Audit", systemImage: "checklist")
35 }
36 .tag(RootTab.audit)
37
38 NavigationStack {
39 HistoryView(viewModel: viewModel)
40 }
41 .tabItem {
42 Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
43 }
44 .tag(RootTab.history)
45
46 ContentView(viewModel: viewModel)
47 .tabItem {
48 Label("Inspect", systemImage: "magnifyingglass")
49 }
50 .tag(RootTab.inspect)
51
52 NavigationStack {
53 SettingsView(viewModel: viewModel)
54 }
55 .tabItem {
56 Label("Settings", systemImage: "gearshape")
57 } 47 }
58 .tag(RootTab.settings)
59 } 48 }
60 .sheet(isPresented: Binding( 49 .sheet(isPresented: Binding(
61 get: { viewModel.isPaywallPresented }, 50 get: { viewModel.isPaywallPresented },
@@ -79,16 +68,16 @@ struct RootTabView: View {
79 } 68 }
80 ) 69 )
81 } 70 }
82 .onChange(of: purchaseService.currentTier) { _, newValue in
83 if newValue != .free, selectedTab == .inspect, viewModel.trackedDomains.isEmpty == false {
84 selectedTab = .dashboard
85 }
86 }
87 .sheet(item: $detailDomain) { trackedDomain in 71 .sheet(item: $detailDomain) { trackedDomain in
88 NavigationStack { 72 NavigationStack {
89 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain) 73 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
90 } 74 }
91 } 75 }
76 .onChange(of: purchaseService.currentTier) { _, newValue in
77 if newValue != .free, selectedTab == .inspect, viewModel.trackedDomains.isEmpty == false {
78 selectedTab = .dashboard
79 }
80 }
92 .onOpenURL { url in 81 .onOpenURL { url in
93 guard let action = DomainDigDeepLink.action(from: url) else { return } 82 guard let action = DomainDigDeepLink.action(from: url) else { return }
94 perform(action) 83 perform(action)
@@ -101,6 +90,67 @@ struct RootTabView: View {
101 } 90 }
102 } 91 }
103 92
93 // MARK: Layouts
94
95 /// Compact (iPhone, iPad slide-over): the classic tab bar.
96 private var tabLayout: some View {
97 TabView(selection: $selectedTab) {
98 ForEach(RootTab.allCases, id: \.self) { tab in
99 section(for: tab)
100 .tabItem {
101 Label(tab.title, systemImage: tab.systemImage)
102 }
103 .tag(tab)
104 }
105 }
106 }
107
108 /// Regular width (iPad, large iPhone landscape): two-column split view.
109 private var splitLayout: some View {
110 NavigationSplitView {
111 List(RootTab.allCases, id: \.self, selection: sidebarSelection) { tab in
112 Label(tab.title, systemImage: tab.systemImage)
113 .tag(tab)
114 }
115 .navigationTitle("DomainDig")
116 } detail: {
117 section(for: selectedTab)
118 }
119 }
120
121 private var sidebarSelection: Binding<RootTab?> {
122 Binding(
123 get: { selectedTab },
124 set: { selectedTab = $0 ?? selectedTab }
125 )
126 }
127
128 @ViewBuilder
129 private func section(for tab: RootTab) -> some View {
130 switch tab {
131 case .dashboard:
132 NavigationStack {
133 DashboardView(viewModel: viewModel)
134 }
135 case .audit:
136 NavigationStack {
137 AuditListView(viewModel: viewModel)
138 }
139 case .history:
140 NavigationStack {
141 HistoryView(viewModel: viewModel)
142 }
143 case .inspect:
144 ContentView(viewModel: viewModel)
145 case .settings:
146 NavigationStack {
147 SettingsView(viewModel: viewModel)
148 }
149 }
150 }
151
152 // MARK: Intent / deep-link routing
153
104 private func consume(_ action: DomainDigDeepLink.Action?) { 154 private func consume(_ action: DomainDigDeepLink.Action?) {
105 guard let action else { return } 155 guard let action else { return }
106 intentRouter.pendingAction = nil 156 intentRouter.pendingAction = nil
DomainDig/SweepActivityController.swift added +63
@@ -0,0 +1,63 @@
1import ActivityKit
2import Foundation
3
4/// Starts, updates, and ends the sweep Live Activity around a batch run.
5@MainActor
6final class SweepActivityController {
7 static let shared = SweepActivityController()
8
9 private var activity: Activity<SweepActivityAttributes>?
10
11 private init() {}
12
13 func begin(title: String, total: Int) {
14 guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
15 // A previous activity that never ended (e.g. app killed mid-sweep)
16 // would otherwise linger; replace it.
17 end(changed: 0, warnings: 0, immediately: true)
18
19 let state = SweepActivityAttributes.ContentState(
20 completed: 0,
21 total: total,
22 currentDomain: nil,
23 changed: 0,
24 warnings: 0
25 )
26 activity = try? Activity.request(
27 attributes: SweepActivityAttributes(title: title, startedAt: Date()),
28 content: ActivityContent(state: state, staleDate: nil)
29 )
30 }
31
32 func update(completed: Int, total: Int, currentDomain: String?) {
33 guard let activity else { return }
34 let state = SweepActivityAttributes.ContentState(
35 completed: completed,
36 total: total,
37 currentDomain: currentDomain,
38 changed: 0,
39 warnings: 0
40 )
41 Task {
42 await activity.update(ActivityContent(state: state, staleDate: nil))
43 }
44 }
45
46 func end(changed: Int, warnings: Int, immediately: Bool = false) {
47 guard let activity else { return }
48 self.activity = nil
49 let state = SweepActivityAttributes.ContentState(
50 completed: activity.content.state.total,
51 total: activity.content.state.total,
52 currentDomain: nil,
53 changed: changed,
54 warnings: warnings
55 )
56 Task {
57 await activity.end(
58 ActivityContent(state: state, staleDate: nil),
59 dismissalPolicy: immediately ? .immediate : .after(Date().addingTimeInterval(60))
60 )
61 }
62 }
63}
DomainDigShareExtension/DomainDigShareExtension.entitlements added +10
@@ -0,0 +1,10 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>com.apple.security.application-groups</key>
6 <array>
7 <string>group.net.cleberg.DomainDig</string>
8 </array>
9</dict>
10</plist>
DomainDigShareExtension/Info.plist added +39
@@ -0,0 +1,39 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>CFBundleDevelopmentRegion</key>
6 <string>$(DEVELOPMENT_LANGUAGE)</string>
7 <key>CFBundleDisplayName</key>
8 <string>Dig Domain</string>
9 <key>CFBundleExecutable</key>
10 <string>$(EXECUTABLE_NAME)</string>
11 <key>CFBundleIdentifier</key>
12 <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13 <key>CFBundleInfoDictionaryVersion</key>
14 <string>6.0</string>
15 <key>CFBundleName</key>
16 <string>$(PRODUCT_NAME)</string>
17 <key>CFBundlePackageType</key>
18 <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
19 <key>CFBundleShortVersionString</key>
20 <string>$(MARKETING_VERSION)</string>
21 <key>CFBundleVersion</key>
22 <string>$(CURRENT_PROJECT_VERSION)</string>
23 <key>NSExtension</key>
24 <dict>
25 <key>NSExtensionAttributes</key>
26 <dict>
27 <key>NSExtensionActivationRule</key>
28 <dict>
29 <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
30 <integer>1</integer>
31 </dict>
32 </dict>
33 <key>NSExtensionPointIdentifier</key>
34 <string>com.apple.share-services</string>
35 <key>NSExtensionPrincipalClass</key>
36 <string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
37 </dict>
38</dict>
39</plist>
DomainDigShareExtension/ShareViewController.swift added +65
@@ -0,0 +1,65 @@
1import UIKit
2import UniformTypeIdentifiers
3
4/// Receives a web URL from the share sheet, extracts its host, and hands it to
5/// the app through the App Group inbox. Shows a brief confirmation and closes;
6/// the app picks up the domain the next time it becomes active.
7final class ShareViewController: UIViewController {
8 private let label = UILabel()
9
10 override func viewDidLoad() {
11 super.viewDidLoad()
12 view.backgroundColor = .systemBackground
13
14 label.text = "Saving…"
15 label.font = .preferredFont(forTextStyle: .headline)
16 label.textAlignment = .center
17 label.numberOfLines = 0
18 label.translatesAutoresizingMaskIntoConstraints = false
19 view.addSubview(label)
20 NSLayoutConstraint.activate([
21 label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
22 label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
23 label.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 24),
24 label.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24)
25 ])
26
27 extractSharedDomain { [weak self] domain in
28 DispatchQueue.main.async {
29 self?.finish(domain: domain)
30 }
31 }
32 }
33
34 private func extractSharedDomain(completion: @escaping (String?) -> Void) {
35 let providers = (extensionContext?.inputItems ?? [])
36 .compactMap { $0 as? NSExtensionItem }
37 .flatMap { $0.attachments ?? [] }
38
39 guard let provider = providers.first(where: {
40 $0.hasItemConformingToTypeIdentifier(UTType.url.identifier)
41 }) else {
42 completion(nil)
43 return
44 }
45
46 provider.loadItem(forTypeIdentifier: UTType.url.identifier) { item, _ in
47 let url = item as? URL ?? (item as? Data).flatMap { URL(dataRepresentation: $0, relativeTo: nil) }
48 let host = url?.host?.trimmingCharacters(in: .whitespacesAndNewlines)
49 completion(host?.isEmpty == false ? host : nil)
50 }
51 }
52
53 private func finish(domain: String?) {
54 if let domain {
55 DomainDigShareInbox.write(domain: domain)
56 label.text = "Saved \(domain) — open DomainDig to inspect it."
57 } else {
58 label.text = "No web address found."
59 }
60
61 DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) { [weak self] in
62 self?.extensionContext?.completeRequest(returningItems: nil)
63 }
64 }
65}
DomainDigWidget/DomainDigWidgetBundle.swift +1
@@ -5,5 +5,6 @@ import WidgetKit
5struct DomainDigWidgetBundle: WidgetBundle { 5struct DomainDigWidgetBundle: WidgetBundle {
6 var body: some Widget { 6 var body: some Widget {
7 DomainDigPortfolioWidget() 7 DomainDigPortfolioWidget()
8 SweepLiveActivity()
8 } 9 }
9} 10}
DomainDigWidget/SweepLiveActivity.swift added +96
@@ -0,0 +1,96 @@
1import ActivityKit
2import SwiftUI
3import WidgetKit
4
5struct SweepLiveActivity: Widget {
6 var body: some WidgetConfiguration {
7 ActivityConfiguration(for: SweepActivityAttributes.self) { context in
8 // Lock Screen / banner presentation.
9 SweepActivityBannerView(context: context)
10 .padding()
11 .activityBackgroundTint(Color(.systemBackground).opacity(0.85))
12 } dynamicIsland: { context in
13 DynamicIsland {
14 DynamicIslandExpandedRegion(.leading) {
15 Label(context.attributes.title, systemImage: "arrow.trianglehead.2.clockwise")
16 .font(.caption)
17 .foregroundStyle(.secondary)
18 }
19 DynamicIslandExpandedRegion(.trailing) {
20 Text("\(context.state.completed)/\(context.state.total)")
21 .font(.caption)
22 .monospacedDigit()
23 }
24 DynamicIslandExpandedRegion(.bottom) {
25 VStack(alignment: .leading, spacing: 4) {
26 ProgressView(value: context.state.fractionComplete)
27 .tint(.cyan)
28 if let current = context.state.currentDomain {
29 Text(current)
30 .font(.caption2)
31 .foregroundStyle(.secondary)
32 .lineLimit(1)
33 }
34 }
35 }
36 } compactLeading: {
37 Image(systemName: "arrow.trianglehead.2.clockwise")
38 .foregroundStyle(.cyan)
39 } compactTrailing: {
40 Text("\(context.state.completed)/\(context.state.total)")
41 .font(.caption2)
42 .monospacedDigit()
43 } minimal: {
44 ProgressView(value: context.state.fractionComplete)
45 .progressViewStyle(.circular)
46 .tint(.cyan)
47 }
48 }
49 }
50}
51
52private struct SweepActivityBannerView: View {
53 let context: ActivityViewContext<SweepActivityAttributes>
54
55 var body: some View {
56 VStack(alignment: .leading, spacing: 8) {
57 HStack {
58 Label(context.attributes.title, systemImage: "arrow.trianglehead.2.clockwise")
59 .font(.caption)
60 .fontWeight(.semibold)
61 .foregroundStyle(.secondary)
62 Spacer()
63 Text("\(context.state.completed) of \(context.state.total)")
64 .font(.caption)
65 .monospacedDigit()
66 }
67
68 ProgressView(value: context.state.fractionComplete)
69 .tint(.cyan)
70
71 HStack {
72 if let current = context.state.currentDomain {
73 Text(current)
74 .font(.caption2)
75 .foregroundStyle(.secondary)
76 .lineLimit(1)
77 } else {
78 Text(context.state.completed >= context.state.total ? "Finished" : "Working…")
79 .font(.caption2)
80 .foregroundStyle(.secondary)
81 }
82 Spacer()
83 if context.state.changed > 0 {
84 Text("\(context.state.changed) changed")
85 .font(.caption2)
86 .foregroundStyle(.orange)
87 }
88 if context.state.warnings > 0 {
89 Text("\(context.state.warnings) warnings")
90 .font(.caption2)
91 .foregroundStyle(.red)
92 }
93 }
94 }
95 }
96}
Shared/DomainDigShareInbox.swift added +28
@@ -0,0 +1,28 @@
1import Foundation
2
3/// App Group handoff from the share extension to the app.
4///
5/// Share extensions cannot reliably open their host app, so the extension
6/// drops the shared domain here and the app consumes it the next time it
7/// becomes active, routing it into an inspection.
8enum DomainDigShareInbox {
9 private static let key = "pendingInspectDomain"
10
11 private static var defaults: UserDefaults? {
12 UserDefaults(suiteName: DomainDigWidgetStore.appGroupID)
13 }
14
15 static func write(domain: String) {
16 defaults?.set(domain, forKey: key)
17 }
18
19 /// Returns and clears the pending domain, if any.
20 static func consume() -> String? {
21 guard let defaults,
22 let domain = defaults.string(forKey: key),
23 !domain.isEmpty
24 else { return nil }
25 defaults.removeObject(forKey: key)
26 return domain
27 }
28}
Shared/SweepActivityAttributes.swift added +25
@@ -0,0 +1,25 @@
1import ActivityKit
2import Foundation
3
4/// Live Activity contract for an in-flight watchlist sweep or batch lookup.
5///
6/// Lives in `Shared/` because the app starts/updates the activity and the
7/// widget extension renders it.
8struct SweepActivityAttributes: ActivityAttributes {
9 struct ContentState: Codable, Hashable {
10 var completed: Int
11 var total: Int
12 var currentDomain: String?
13 var changed: Int
14 var warnings: Int
15
16 var fractionComplete: Double {
17 guard total > 0 else { return 0 }
18 return Double(completed) / Double(total)
19 }
20 }
21
22 /// User-facing title for the run, e.g. "Watchlist Sweep" or "Batch Lookup".
23 var title: String
24 var startedAt: Date
25}