feat(intents): add App Intents + Siri integration
All checks were successful
CI / build-and-deploy (push) Successful in 24s
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:
@@ -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 */,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
122
Marks/Intents/BookmarkEntity.swift
Normal file
122
Marks/Intents/BookmarkEntity.swift
Normal 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:))
|
||||||
|
}
|
||||||
|
}
|
||||||
53
Marks/Intents/IntentSnippetViews.swift
Normal file
53
Marks/Intents/IntentSnippetViews.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
66
Marks/Intents/IntentSupport.swift
Normal file
66
Marks/Intents/IntentSupport.swift
Normal 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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
177
Marks/Intents/MarksAppIntents.swift
Normal file
177
Marks/Intents/MarksAppIntents.swift
Normal 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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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] {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user