feat(intents): add App Intents + Siri integration
All checks were successful
CI / build-and-deploy (push) Successful in 24s

Expose Marks to Siri, Spotlight, and Shortcuts via App Intents:

- BookmarkEntity (AppEntity + IndexedEntity, keyed on the Linkding
  server id) with a server-backed EntityStringQuery for live lookup
- Intents: Add, Open (OpenIntent), Search, ShowUnread, and an AI
  Summarize intent backed by ClaudeService; AppShortcutsProvider with
  spoken phrases; Siri snippet views and dialog
- SearchMarksIntent conforms to the system.search App Schema
  (@AppIntent(schema:), ShowInAppSearchResultsIntent) so it is
  executable by conversational Siri / Apple Intelligence
- IntentRouter bridges intents (run in the app process) to the SwiftUI
  scene for open/search navigation; widgets refreshed after adds
- Link AppIntents.framework so the metadata processor extracts shortcut
  phrases and validates the assistant schema

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Krishna Kumar
2026-06-16 16:10:17 -05:00
parent 8a27b692b8
commit 389d615c8b
9 changed files with 494 additions and 17 deletions

View File

@@ -21,6 +21,7 @@
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; }; 76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; };
8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; }; 8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; };
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; }; 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; };
852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */; };
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; }; 8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; };
8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; }; 8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; };
905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */; }; 905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */; };
@@ -39,9 +40,13 @@
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; }; C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; };
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */; }; CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */; };
DA914D93102B9F872DDC2032 /* MarksWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */; }; DA914D93102B9F872DDC2032 /* MarksWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */; };
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */; };
E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; }; E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */; };
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; }; E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; }; 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 */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -93,13 +98,16 @@
5C29CB878BC334639E6194E2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 5C29CB878BC334639E6194E2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = "<group>"; }; 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = "<group>"; };
629C41E0BC28EB6359D50CFD /* BrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserView.swift; sourceTree = "<group>"; }; 629C41E0BC28EB6359D50CFD /* BrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserView.swift; sourceTree = "<group>"; };
64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAppIntents.swift; sourceTree = "<group>"; };
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = "<group>"; }; 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = "<group>"; };
759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = "<group>"; }; 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = "<group>"; };
86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = "<group>"; }; 8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = "<group>"; };
8D650D0784B847BFEDCB3141 /* BookmarkEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkEntity.swift; sourceTree = "<group>"; };
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
969B364D1E9E6D2E1C610D21 /* MarksWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MarksWidget.entitlements; sourceTree = "<group>"; }; 969B364D1E9E6D2E1C610D21 /* MarksWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MarksWidget.entitlements; sourceTree = "<group>"; };
96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomBookmarkWidget.swift; sourceTree = "<group>"; }; 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomBookmarkWidget.swift; sourceTree = "<group>"; };
9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSnippetViews.swift; sourceTree = "<group>"; };
A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = "<group>"; }; A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = "<group>"; };
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; }; AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; };
AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; }; AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -111,11 +119,13 @@
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; }; CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; };
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = "<group>"; }; CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = "<group>"; };
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; }; D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; };
D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = "<group>"; };
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = "<group>"; }; DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = "<group>"; };
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = "<group>"; }; F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = "<group>"; };
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = "<group>"; }; F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = "<group>"; };
F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppIntents.framework; path = System/Library/Frameworks/AppIntents.framework; sourceTree = SDKROOT; };
FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; }; FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -125,6 +135,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */, A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */,
852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -147,6 +158,7 @@
86B2DE40098D2B39FFE1AC36 /* Info.plist */, 86B2DE40098D2B39FFE1AC36 /* Info.plist */,
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */, 0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */,
D92575C7C710347F226EC74A /* MarksApp.swift */, D92575C7C710347F226EC74A /* MarksApp.swift */,
58E8E316BE3F10C5149AADC3 /* Intents */,
3EF3094F53F0378D1BF18508 /* Models */, 3EF3094F53F0378D1BF18508 /* Models */,
7ABBFDF43A1EB773E0A49CFE /* Services */, 7ABBFDF43A1EB773E0A49CFE /* Services */,
AF9CABF75E77168EF1D60F29 /* Views */, AF9CABF75E77168EF1D60F29 /* Views */,
@@ -164,6 +176,17 @@
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
58E8E316BE3F10C5149AADC3 /* Intents */ = {
isa = PBXGroup;
children = (
8D650D0784B847BFEDCB3141 /* BookmarkEntity.swift */,
9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */,
D6ACABF0CA940312B4195456 /* IntentSupport.swift */,
64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */,
);
path = Intents;
sourceTree = "<group>";
};
7A85D09B4E109E1D9CD8F5BB /* ShareExtension */ = { 7A85D09B4E109E1D9CD8F5BB /* ShareExtension */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -244,6 +267,7 @@
F5B3C85424137FA0CE2467B1 /* Frameworks */ = { F5B3C85424137FA0CE2467B1 /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */,
093402014727A338745A06DA /* SwiftUI.framework */, 093402014727A338745A06DA /* SwiftUI.framework */,
0552C13335034219DECF4F62 /* WidgetKit.framework */, 0552C13335034219DECF4F62 /* WidgetKit.framework */,
); );
@@ -393,6 +417,7 @@
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */, AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */,
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */, B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */,
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */, 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */,
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */, CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */,
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */, 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */, A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
@@ -401,8 +426,11 @@
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */, BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */, 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */, C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */,
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */,
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */,
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */, 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */, 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */,
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */,
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */, 8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */,
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */, 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */,
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */, 0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */,

View File

@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1630" LastUpgradeVersion = "1630"
version = "1.3"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
@@ -40,7 +41,8 @@
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"> shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion> <MacroExpansion>
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
@@ -65,15 +67,6 @@
allowLocationSimulation = "YES"> allowLocationSimulation = "YES">
<BuildableProductRunnable <BuildableProductRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CF31B2655F8F30CF6DF3C26F"
BuildableName = "ShareExtension.appex"
BlueprintName = "ShareExtension"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<MacroExpansion>
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A" BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
@@ -81,7 +74,9 @@
BlueprintName = "Marks" BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj"> ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference> </BuildableReference>
</MacroExpansion> </BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"

View File

@@ -0,0 +1,122 @@
import Foundation
import AppIntents
import CoreSpotlight
/// A Linkding bookmark exposed to Siri, Spotlight, Shortcuts, and the
/// Apple-Intelligence "Use Model" action.
///
/// Conforms to `IndexedEntity` so its title / description / tags become
/// Spotlight-searchable, which is what lets Siri answer "find my bookmark
/// about Swift concurrency". The identifier is the Linkding server id (`Int`),
/// which is stable across devices.
struct BookmarkEntity: AppEntity, IndexedEntity {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Bookmark")
let id: Int
@Property(title: "Title")
var title: String
@Property(title: "URL")
var url: URL
@Property(title: "Website")
var host: String
@Property(title: "Description")
var details: String
@Property(title: "Tags")
var tags: [String]
@Property(title: "Unread")
var unread: Bool
/// AI-generated summary (when one has been produced). Exposed so the
/// Shortcuts "Use Model" action and Siri can reason over it.
@Property(title: "Summary")
var summary: String?
static let defaultQuery = BookmarkQuery()
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: "\(title)",
subtitle: "\(host)",
image: .init(systemName: "bookmark.fill")
)
}
/// Rich Spotlight attributes so semantic search has real content to match.
var attributeSet: CSSearchableItemAttributeSet {
let attrs = CSSearchableItemAttributeSet(contentType: .url)
attrs.title = title
attrs.contentDescription = details.isEmpty ? summary : details
attrs.keywords = tags
attrs.url = url
return attrs
}
}
extension BookmarkEntity {
init(from b: Bookmark) {
self.id = b.id
self.title = b.displayTitle
self.url = URL(string: b.url) ?? URL(string: "https://example.invalid")!
self.host = b.domain
self.details = b.contentExcerpt ?? ""
self.tags = b.tagNames
self.unread = b.unread
self.summary = b.aiSummary
}
/// Reconstruct a minimal `Bookmark` from the entity enough for the
/// AI enrichment call, which only reads url/title/tags.
func makeBookmark() -> Bookmark {
Bookmark(
id: id,
url: url.absoluteString,
title: title,
description: details,
tagNames: tags,
dateAdded: Date(),
dateModified: Date(),
isArchived: false,
unread: unread,
shared: false,
websiteTitle: nil,
websiteDescription: nil,
faviconUrl: nil,
previewImageUrl: nil,
aiSummary: summary,
aiTags: nil
)
}
}
/// Looks bookmarks up by id and resolves free-text queries by hitting the
/// Linkding server live the recommended path for data that lives on a
/// server and changes too often to pre-index.
struct BookmarkQuery: EntityStringQuery {
func entities(for identifiers: [Int]) async throws -> [BookmarkEntity] {
let api = try MarksIntent.api()
// Linkding has no batch-by-id endpoint; pull a page and filter.
let response = try await api.fetchBookmarks(limit: 200)
let wanted = Set(identifiers)
return response.results
.filter { wanted.contains($0.id) }
.map(BookmarkEntity.init(from:))
}
func entities(matching string: String) async throws -> [BookmarkEntity] {
let api = try MarksIntent.api()
let response = try await api.fetchBookmarks(search: string, limit: 25)
return response.results.map(BookmarkEntity.init(from:))
}
func suggestedEntities() async throws -> [BookmarkEntity] {
let api = try MarksIntent.api()
let response = try await api.fetchBookmarks(limit: 10)
return response.results.map(BookmarkEntity.init(from:))
}
}

View File

@@ -0,0 +1,53 @@
import SwiftUI
/// Compact card Siri shows after saving / opening a bookmark.
struct BookmarkSnippetView: View {
let entity: BookmarkEntity
var body: some View {
HStack(spacing: 12) {
Image(systemName: "bookmark.fill")
.font(.title2)
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 3) {
Text(entity.title)
.font(.headline)
.lineLimit(2)
Text(entity.host)
.font(.subheadline)
.foregroundStyle(.secondary)
if !entity.tags.isEmpty {
Text(entity.tags.map { "#\($0)" }.joined(separator: " "))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 0)
}
.padding()
}
}
/// Card Siri shows for an AI summary result.
struct SummarySnippetView: View {
let title: String
let host: String
let summary: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "sparkles").foregroundStyle(.tint)
Text(title).font(.headline).lineLimit(2)
}
Text(host)
.font(.caption)
.foregroundStyle(.secondary)
Text(summary)
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
.padding()
}
}

View File

@@ -0,0 +1,66 @@
import Foundation
import AppIntents
/// Which top-level tab the app is showing. Used so an intent can switch tabs.
enum AppTab: Hashable {
case bookmarks, tags, search
}
/// Bridges App Intents (which run in the main app process, since there is no
/// separate AppIntents extension target) to the live SwiftUI scene.
///
/// Intents mutate this shared, observable singleton; `MainContainer` and
/// `SearchView` observe it and react (present a browser, switch to the search
/// tab, etc.). This is the standard "intent in-app navigation" pattern when
/// perform() runs in-process.
@MainActor
@Observable
final class IntentRouter {
static let shared = IntentRouter()
private init() {}
/// A bookmark URL an intent asked to open in the in-app browser.
var openBookmarkURL: String?
/// A query an intent asked the app to search for.
var searchRequest: String?
func openBookmark(url: String) { openBookmarkURL = url }
func search(_ query: String) { searchRequest = query }
}
/// Errors surfaced to Siri / Shortcuts when an intent can't run.
enum MarksIntentError: Error, CustomLocalizedStringResourceConvertible {
case notConfigured
case invalidURL
var localizedStringResource: LocalizedStringResource {
switch self {
case .notConfigured: "Open Marks and connect to your Linkding server first."
case .invalidURL: "That doesn't look like a valid link."
}
}
}
/// Shared plumbing for the intents: build an API client from saved config and
/// keep the widgets in sync after mutations.
enum MarksIntent {
/// Builds a `LinkdingAPI` from the saved server config, or throws a
/// user-facing error if the app has never been connected.
static func api() throws -> LinkdingAPI {
guard let config = ServerConfig.load() else { throw MarksIntentError.notConfigured }
return LinkdingAPI(config: config)
}
/// Prepend a freshly-saved bookmark into the widget cache so the Recent
/// widget reflects it immediately (mirrors `BookmarksViewModel.load`).
static func refreshWidgets(adding b: Bookmark) {
var items = WidgetDataStore.loadBookmarks()
items.removeAll { $0.url == b.url }
items.insert(
WidgetBookmark(url: b.url, title: b.displayTitle, domain: b.domain, faviconUrl: b.faviconUrl),
at: 0
)
WidgetDataStore.saveBookmarks(Array(items.prefix(20)))
}
}

View File

@@ -0,0 +1,177 @@
import Foundation
import AppIntents
// MARK: - Open
/// Opens a bookmark in the in-app browser. Conforms to `OpenIntent` the
/// system-blessed "open" type Siri understands. (iOS 26 has no separate
/// `system.open` App Schema; `OpenIntent` with an `AppEntity` target *is* the
/// supported mechanism, so no schema macro is needed here.)
struct OpenBookmarkIntent: OpenIntent {
static let title: LocalizedStringResource = "Open Bookmark"
static let openAppWhenRun = true
@Parameter(title: "Bookmark")
var target: BookmarkEntity
@MainActor
func perform() async throws -> some IntentResult {
IntentRouter.shared.openBookmark(url: target.url.absoluteString)
return .result()
}
}
// MARK: - Search
/// Searches Marks and shows the results in the app's search tab.
///
/// Conforms to the formal `system.search` App Schema (`ShowInAppSearchResultsIntent`),
/// which is what makes it executable by conversational Siri / Apple Intelligence:
/// Siri hands over the spoken term as `criteria` and the app re-runs it in its
/// own search UI. `searchScopes` defaults to `[.general]` for string criteria.
@AppIntent(schema: .system.search)
struct SearchMarksIntent {
static let title: LocalizedStringResource = "Search Marks"
static let description = IntentDescription("Search your Marks bookmarks.")
@Parameter(title: "Search", requestValueDialog: "What do you want to search for?")
var criteria: StringSearchCriteria
@MainActor
func perform() async throws -> some IntentResult {
IntentRouter.shared.search(criteria.term)
return .result()
}
}
// MARK: - Add
/// Saves a link to Marks without opening the app. Mirrors the Share Extension's
/// headless save, but goes through the typed `LinkdingAPI`.
struct AddBookmarkIntent: AppIntent {
static let title: LocalizedStringResource = "Add Bookmark to Marks"
static let description = IntentDescription("Save a link to your Marks bookmarks.")
static let openAppWhenRun = false
@Parameter(title: "URL")
var url: URL
@Parameter(title: "Title")
var name: String?
@Parameter(title: "Tags")
var tags: [String]?
static var parameterSummary: some ParameterSummary {
Summary("Add \(\.$url) to Marks") {
\.$name
\.$tags
}
}
func perform() async throws -> some IntentResult & ReturnsValue<BookmarkEntity> & ProvidesDialog & ShowsSnippetView {
let api = try MarksIntent.api()
let create = BookmarkCreate(
url: url.absoluteString,
title: name ?? "",
tagNames: tags ?? [],
isArchived: false,
unread: true,
shared: false
)
let bookmark = try await api.createBookmark(create)
let entity = BookmarkEntity(from: bookmark)
MarksIntent.refreshWidgets(adding: bookmark)
return .result(
value: entity,
dialog: IntentDialog("Saved \(entity.title) to Marks."),
view: BookmarkSnippetView(entity: entity)
)
}
}
// MARK: - Show unread
/// Returns the user's unread bookmarks "what's unread in Marks?"
struct ShowUnreadIntent: AppIntent {
static let title: LocalizedStringResource = "Show Unread Bookmarks"
static let description = IntentDescription("List the bookmarks you haven't read yet.")
func perform() async throws -> some IntentResult & ReturnsValue<[BookmarkEntity]> & ProvidesDialog {
let api = try MarksIntent.api()
let response = try await api.fetchBookmarks(limit: 10, unread: true)
let entities = response.results.map(BookmarkEntity.init(from:))
let dialog: IntentDialog = entities.isEmpty
? "You have no unread bookmarks."
: "You have \(entities.count) unread bookmark\(entities.count == 1 ? "" : "s")."
return .result(value: entities, dialog: dialog)
}
}
// MARK: - Summarize (AI)
/// Produces an AI summary of a bookmark via the bundled ClaudeService.
struct SummarizeBookmarkIntent: AppIntent {
static let title: LocalizedStringResource = "Summarize Bookmark"
static let description = IntentDescription("Get an AI summary of a saved bookmark.")
@Parameter(title: "Bookmark")
var target: BookmarkEntity
static var parameterSummary: some ParameterSummary {
Summary("Summarize \(\.$target)")
}
func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog & ShowsSnippetView {
let claude = ClaudeService()
let (summary, _) = try await claude.enrich(bookmark: target.makeBookmark())
return .result(
value: summary,
dialog: IntentDialog("\(summary)"),
view: SummarySnippetView(title: target.title, host: target.host, summary: summary)
)
}
}
// MARK: - App Shortcuts
struct MarksShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: AddBookmarkIntent(),
phrases: [
"Add a bookmark to \(.applicationName)",
"Save this link to \(.applicationName)"
],
shortTitle: "Add Bookmark",
systemImageName: "bookmark"
)
AppShortcut(
intent: SearchMarksIntent(),
phrases: [
"Search \(.applicationName)",
"Search my bookmarks in \(.applicationName)"
],
shortTitle: "Search",
systemImageName: "magnifyingglass"
)
AppShortcut(
intent: ShowUnreadIntent(),
phrases: [
"Show unread in \(.applicationName)",
"What's unread in \(.applicationName)"
],
shortTitle: "Unread",
systemImageName: "envelope.badge"
)
AppShortcut(
intent: SummarizeBookmarkIntent(),
phrases: [
"Summarize a bookmark in \(.applicationName)",
"Summarize this with \(.applicationName)"
],
shortTitle: "Summarize",
systemImageName: "sparkles"
)
}
}

View File

@@ -36,6 +36,8 @@ struct MainContainer: View {
@State private var viewModel: BookmarksViewModel @State private var viewModel: BookmarksViewModel
@State private var deepLinkBrowser: IdentifiableURL? @State private var deepLinkBrowser: IdentifiableURL?
@State private var showDeepLinkPlayer = false @State private var showDeepLinkPlayer = false
@State private var selectedTab: AppTab = .bookmarks
@State private var router = IntentRouter.shared
init(config: ServerConfig, onDisconnect: @escaping () -> Void) { init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
self.config = config self.config = config
@@ -45,20 +47,23 @@ struct MainContainer: View {
} }
var body: some View { var body: some View {
TabView { TabView(selection: $selectedTab) {
Tab("Bookmarks", systemImage: "bookmark") { Tab("Bookmarks", systemImage: "bookmark", value: AppTab.bookmarks) {
BookmarksView(viewModel: viewModel, onDisconnect: onDisconnect) BookmarksView(viewModel: viewModel, onDisconnect: onDisconnect)
} }
Tab("Tags", systemImage: "tag") { Tab("Tags", systemImage: "tag", value: AppTab.tags) {
TagsView(viewModel: viewModel) TagsView(viewModel: viewModel)
} }
Tab(role: .search) { Tab(value: AppTab.search, role: .search) {
SearchView(viewModel: viewModel) SearchView(viewModel: viewModel)
} }
} }
.onOpenURL { url in .onOpenURL { url in
handleDeepLink(url) handleDeepLink(url)
} }
.task { applyPendingIntent() }
.onChange(of: router.openBookmarkURL) { _, _ in applyPendingIntent() }
.onChange(of: router.searchRequest) { _, _ in applyPendingIntent() }
.sheet(item: $deepLinkBrowser) { item in .sheet(item: $deepLinkBrowser) { item in
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer) BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer)
} }
@@ -73,6 +78,27 @@ struct MainContainer: View {
} }
} }
/// Reacts to requests an App Intent placed on `IntentRouter` (opening a
/// bookmark, or searching). Also runs once at launch so cold-starts via an
/// intent are honored.
private func applyPendingIntent() {
if let urlString = router.openBookmarkURL {
router.openBookmarkURL = nil
if let url = URL(string: urlString) {
deepLinkBrowser = IdentifiableURL(url: url)
}
}
if let query = router.searchRequest {
selectedTab = .search
Task {
viewModel.searchQuery = query
await viewModel.search()
}
// SearchView consumes the text from the router on appear; leave it
// set until then, then SearchView clears it.
}
}
private func handleDeepLink(_ url: URL) { private func handleDeepLink(_ url: URL) {
guard url.scheme == "marks", guard url.scheme == "marks",
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false), let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),

View File

@@ -57,6 +57,15 @@ struct SearchView: View {
} }
.searchable(text: $searchText, prompt: "Titles, URLs, tags…") .searchable(text: $searchText, prompt: "Titles, URLs, tags…")
.onChange(of: searchText) { _, _ in scheduleSearch() } .onChange(of: searchText) { _, _ in scheduleSearch() }
.onAppear { consumeIntentSearch() }
.onChange(of: IntentRouter.shared.searchRequest) { _, _ in consumeIntentSearch() }
}
/// Pulls a query handed over by `SearchMarksIntent` into the search field.
private func consumeIntentSearch() {
guard let query = IntentRouter.shared.searchRequest else { return }
searchText = query
IntentRouter.shared.searchRequest = nil
} }
private var results: [Bookmark] { private var results: [Bookmark] {

View File

@@ -74,6 +74,7 @@ targets:
- target: ShareExtension - target: ShareExtension
- target: MarksWidget - target: MarksWidget
- sdk: WidgetKit.framework - sdk: WidgetKit.framework
- sdk: AppIntents.framework
ShareExtension: ShareExtension:
type: app-extension type: app-extension