From a3059f25fcb61ecc184bee544e389834b8e22c4e Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Wed, 17 Jun 2026 00:16:46 -0500 Subject: [PATCH] feat(intents): onscreen entity annotations + App Intents tests - bookmarkOnscreen(_:) publishes the viewed bookmark as the on-screen entity (NSUserActivity.appEntityIdentifier) so Siri can resolve "this" / "summarize this one"; applied to the BrowserView sheets in BookmarksView and SearchView - Add MarksTests (XCTest) covering entity mapping, EntityIdentifier, IntentRouter, and the real perform() of SearchMarksIntent and OpenBookmarkIntent; wired into the Marks scheme test phase Co-Authored-By: Claude Opus 4.8 --- Marks.xcodeproj/project.pbxproj | 103 +++++++++++++++++ .../xcshareddata/xcschemes/Marks.xcscheme | 11 ++ Marks/Intents/BookmarkOnscreen.swift | 19 ++++ Marks/Views/BookmarksView.swift | 1 + Marks/Views/SearchView.swift | 1 + MarksTests/AppIntentsTests.swift | 107 ++++++++++++++++++ project.yml | 14 +++ 7 files changed, 256 insertions(+) create mode 100644 Marks/Intents/BookmarkOnscreen.swift create mode 100644 MarksTests/AppIntentsTests.swift diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index 680ddf4..e08027b 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; }; 706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; }; 76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; }; + 778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */; }; 8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; }; 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; }; 852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */; }; @@ -47,6 +48,7 @@ EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; }; EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ACABF0CA940312B4195456 /* IntentSupport.swift */; }; FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D650D0784B847BFEDCB3141 /* BookmarkEntity.swift */; }; + FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7623601C25E481DF58371F2A /* AppIntentsTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -64,6 +66,13 @@ remoteGlobalIDString = CF31B2655F8F30CF6DF3C26F; remoteInfo = ShareExtension; }; + F63E42D8413C0F6ED8BB78CF /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 76C272776D8EBCCD3FB6715A; + remoteInfo = Marks; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -101,6 +110,7 @@ 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAppIntents.swift; sourceTree = ""; }; 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = ""; }; 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = ""; }; + 7623601C25E481DF58371F2A /* AppIntentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentsTests.swift; sourceTree = ""; }; 86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = ""; }; 8D650D0784B847BFEDCB3141 /* BookmarkEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkEntity.swift; sourceTree = ""; }; @@ -115,9 +125,11 @@ B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkRow.swift; sourceTree = ""; }; B13B9F2D890C7953531AC0D2 /* TagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsView.swift; sourceTree = ""; }; BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; + C8B10AC011AF8F242E525440 /* MarksTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = MarksTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = ""; }; CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = ""; }; CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = ""; }; + D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkOnscreen.swift; sourceTree = ""; }; D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = ""; }; D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = ""; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = ""; }; @@ -176,10 +188,19 @@ path = Models; sourceTree = ""; }; + 3F5B1508DEFC894F33CBD16C /* MarksTests */ = { + isa = PBXGroup; + children = ( + 7623601C25E481DF58371F2A /* AppIntentsTests.swift */, + ); + path = MarksTests; + sourceTree = ""; + }; 58E8E316BE3F10C5149AADC3 /* Intents */ = { isa = PBXGroup; children = ( 8D650D0784B847BFEDCB3141 /* BookmarkEntity.swift */, + D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */, 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */, D6ACABF0CA940312B4195456 /* IntentSupport.swift */, 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */, @@ -247,6 +268,7 @@ isa = PBXGroup; children = ( 3952A081972B444674BEB51B /* Marks */, + 3F5B1508DEFC894F33CBD16C /* MarksTests */, 85E717A515682EBF67DD199A /* MarksWidget */, 7A85D09B4E109E1D9CD8F5BB /* ShareExtension */, F5B3C85424137FA0CE2467B1 /* Frameworks */, @@ -258,6 +280,7 @@ isa = PBXGroup; children = ( AB6C53AB14A38FCD4CC7628D /* Marks.app */, + C8B10AC011AF8F242E525440 /* MarksTests.xctest */, 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */, 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */, ); @@ -334,6 +357,24 @@ productReference = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + F96181AD21F4A6755214E19B /* MarksTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CE9D24DDB32E8B7EFDDD9D59 /* Build configuration list for PBXNativeTarget "MarksTests" */; + buildPhases = ( + 1035354FBD99C8ED4ACA93DC /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + A665E75E77C12B6A3519ABF9 /* PBXTargetDependency */, + ); + name = MarksTests; + packageProductDependencies = ( + ); + productName = MarksTests; + productReference = C8B10AC011AF8F242E525440 /* MarksTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -355,6 +396,10 @@ DevelopmentTeam = AE5DZKJHGN; ProvisioningStyle = Automatic; }; + F96181AD21F4A6755214E19B = { + DevelopmentTeam = AE5DZKJHGN; + ProvisioningStyle = Automatic; + }; }; }; buildConfigurationList = 4BACCD33EEE63890F06D7057 /* Build configuration list for PBXProject "Marks" */; @@ -372,6 +417,7 @@ projectRoot = ""; targets = ( 76C272776D8EBCCD3FB6715A /* Marks */, + F96181AD21F4A6755214E19B /* MarksTests */, 61046BF39C48393DD96687A0 /* MarksWidget */, CF31B2655F8F30CF6DF3C26F /* ShareExtension */, ); @@ -390,6 +436,14 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 1035354FBD99C8ED4ACA93DC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 2210C849B430CC6609106C62 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -419,6 +473,7 @@ 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */, FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */, CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */, + 778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */, 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */, A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */, 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */, @@ -452,6 +507,11 @@ target = 61046BF39C48393DD96687A0 /* MarksWidget */; targetProxy = 22E9426D1CE4E3C473C5CD5D /* PBXContainerItemProxy */; }; + A665E75E77C12B6A3519ABF9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 76C272776D8EBCCD3FB6715A /* Marks */; + targetProxy = F63E42D8413C0F6ED8BB78CF /* PBXContainerItemProxy */; + }; A87C510E52F149E7E3CA0283 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = CF31B2655F8F30CF6DF3C26F /* ShareExtension */; @@ -571,6 +631,23 @@ }; name = Release; }; + 68D4EEAD1A7AA570E88EF498 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.MarksTests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Marks.app/Marks"; + }; + name = Debug; + }; 6CDF13C33FDD1141CFE677AD /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -689,6 +766,23 @@ }; name = Release; }; + E92B937142E9625F95B51699 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.MarksTests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Marks.app/Marks"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -728,6 +822,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + CE9D24DDB32E8B7EFDDD9D59 /* Build configuration list for PBXNativeTarget "MarksTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 68D4EEAD1A7AA570E88EF498 /* Debug */, + E92B937142E9625F95B51699 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ }; rootObject = 0CC2596AE7F05EAF051B2FCD /* Project object */; diff --git a/Marks.xcodeproj/xcshareddata/xcschemes/Marks.xcscheme b/Marks.xcodeproj/xcshareddata/xcschemes/Marks.xcscheme index 38e25a7..4d59205 100644 --- a/Marks.xcodeproj/xcshareddata/xcschemes/Marks.xcscheme +++ b/Marks.xcodeproj/xcshareddata/xcschemes/Marks.xcscheme @@ -67,6 +67,17 @@ + + + + diff --git a/Marks/Intents/BookmarkOnscreen.swift b/Marks/Intents/BookmarkOnscreen.swift new file mode 100644 index 0000000..034b371 --- /dev/null +++ b/Marks/Intents/BookmarkOnscreen.swift @@ -0,0 +1,19 @@ +import SwiftUI +import AppIntents + +extension View { + /// Marks `bookmark` as the on-screen entity via an `NSUserActivity`, so + /// Siri / Apple Intelligence can resolve references like "summarize this" + /// or "open this one" while the bookmark is being viewed. + /// + /// iOS 26.0 ships no SwiftUI `.appEntityIdentifier` modifier, so this uses + /// the supported path: the standard `.userActivity` modifier plus + /// `NSUserActivity.appEntityIdentifier` (from `AppEntityAnnotatable`). + func bookmarkOnscreen(_ bookmark: Bookmark) -> some View { + userActivity("com.magicive.marks.viewBookmark", element: bookmark.id) { id, activity in + activity.title = bookmark.displayTitle + activity.appEntityIdentifier = EntityIdentifier(for: BookmarkEntity.self, identifier: id) + activity.isEligibleForSearch = true + } + } +} diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index a951afa..635ae7b 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -178,6 +178,7 @@ struct BookmarksView: View { BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer) { await viewModel.archive(bookmark) } + .bookmarkOnscreen(bookmark) } } .onChange(of: browsingBookmark) { _, new in diff --git a/Marks/Views/SearchView.swift b/Marks/Views/SearchView.swift index 8133ccc..b38f1d1 100644 --- a/Marks/Views/SearchView.swift +++ b/Marks/Views/SearchView.swift @@ -52,6 +52,7 @@ struct SearchView: View { ) { await viewModel.archive(bookmark) } + .bookmarkOnscreen(bookmark) } } } diff --git a/MarksTests/AppIntentsTests.swift b/MarksTests/AppIntentsTests.swift new file mode 100644 index 0000000..3a0ec6a --- /dev/null +++ b/MarksTests/AppIntentsTests.swift @@ -0,0 +1,107 @@ +import XCTest +import AppIntents +@testable import Marks + +/// Tests for the App Intents layer. +/// +/// Note: WWDC26's dedicated `AppIntentsTesting` framework (out-of-process +/// `IntentDefinitions(bundleIdentifier:)` / `.run()`) is not present in this +/// Xcode, so these are standard XCTest cases. They work because Marks has no +/// AppIntents extension — intents run in the app process, so `perform()` can be +/// called directly and asserted against `IntentRouter`. +final class AppIntentsTests: XCTestCase { + + private func sampleBookmark( + id: Int = 42, + url: String = "https://example.com/article", + title: String = "Concurrency in Swift", + tags: [String] = ["swift", "concurrency"], + unread: Bool = true, + aiSummary: String? = "A short summary." + ) -> Bookmark { + Bookmark( + id: id, url: url, title: title, description: "desc", + tagNames: tags, dateAdded: Date(), dateModified: Date(), + isArchived: false, unread: unread, shared: false, + websiteTitle: nil, websiteDescription: nil, + faviconUrl: nil, previewImageUrl: nil, + aiSummary: aiSummary, aiTags: nil + ) + } + + // MARK: - Entity mapping + + func testEntityMapsFromBookmark() { + let entity = BookmarkEntity(from: sampleBookmark()) + XCTAssertEqual(entity.id, 42) + XCTAssertEqual(entity.title, "Concurrency in Swift") + XCTAssertEqual(entity.url, URL(string: "https://example.com/article")) + XCTAssertEqual(entity.host, "example.com") + XCTAssertEqual(entity.tags, ["swift", "concurrency"]) + XCTAssertTrue(entity.unread) + XCTAssertEqual(entity.summary, "A short summary.") + } + + func testEntityUsesDisplayTitleFallback() { + // empty title → falls back to url (Bookmark.displayTitle) + let entity = BookmarkEntity(from: sampleBookmark(title: "")) + XCTAssertFalse(entity.title.isEmpty) + } + + func testMakeBookmarkRoundTripsCoreFields() { + let entity = BookmarkEntity(from: sampleBookmark()) + let back = entity.makeBookmark() + XCTAssertEqual(back.id, 42) + XCTAssertEqual(back.url, "https://example.com/article") + XCTAssertEqual(back.tagNames, ["swift", "concurrency"]) + } + + func testEntityIdentifierEncodesServerId() { + let entity = BookmarkEntity(from: sampleBookmark(id: 7)) + let eid = EntityIdentifier(for: entity) + XCTAssertEqual(eid.identifier, "7") + XCTAssertTrue(eid.entityType == BookmarkEntity.self) + } + + // MARK: - Router + + @MainActor + func testRouterSearch() { + IntentRouter.shared.searchRequest = nil + IntentRouter.shared.search("hello") + XCTAssertEqual(IntentRouter.shared.searchRequest, "hello") + } + + @MainActor + func testRouterOpenBookmark() { + IntentRouter.shared.openBookmarkURL = nil + IntentRouter.shared.openBookmark(url: "https://example.com") + XCTAssertEqual(IntentRouter.shared.openBookmarkURL, "https://example.com") + } + + // MARK: - Intent perform() wiring (no network) + + @MainActor + func testSearchIntentRoutesCriteriaTerm() async throws { + IntentRouter.shared.searchRequest = nil + let intent = SearchMarksIntent() + intent.criteria = StringSearchCriteria(term: "graphql") + _ = try await intent.perform() + XCTAssertEqual(IntentRouter.shared.searchRequest, "graphql") + } + + @MainActor + func testOpenIntentRoutesURL() async throws { + IntentRouter.shared.openBookmarkURL = nil + let intent = OpenBookmarkIntent() + intent.target = BookmarkEntity(from: sampleBookmark()) + _ = try await intent.perform() + XCTAssertEqual(IntentRouter.shared.openBookmarkURL, "https://example.com/article") + } + + // MARK: - App Shortcuts + + func testAppShortcutsAreRegistered() { + XCTAssertGreaterThanOrEqual(MarksShortcuts.appShortcuts.count, 4) + } +} diff --git a/project.yml b/project.yml index d41725a..64d1222 100644 --- a/project.yml +++ b/project.yml @@ -26,6 +26,8 @@ schemes: config: Debug test: config: Debug + targets: + - MarksTests profile: config: Release analyze: @@ -89,6 +91,18 @@ targets: SKIP_INSTALL: YES APPLICATION_EXTENSION_API_ONLY: YES + MarksTests: + type: bundle.unit-test + platform: iOS + sources: + - path: MarksTests + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.MarksTests + GENERATE_INFOPLIST_FILE: YES + dependencies: + - target: Marks + MarksWidget: type: app-extension platform: iOS