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 */; };
|
||||
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 */; };
|
||||
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; };
|
||||
8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; };
|
||||
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 */; };
|
||||
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6B10FBB227F426A2B597C8 /* BookmarkListRow.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 */; };
|
||||
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */; };
|
||||
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -93,13 +98,16 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
@@ -111,11 +119,13 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -125,6 +135,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */,
|
||||
852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -147,6 +158,7 @@
|
||||
86B2DE40098D2B39FFE1AC36 /* Info.plist */,
|
||||
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */,
|
||||
D92575C7C710347F226EC74A /* MarksApp.swift */,
|
||||
58E8E316BE3F10C5149AADC3 /* Intents */,
|
||||
3EF3094F53F0378D1BF18508 /* Models */,
|
||||
7ABBFDF43A1EB773E0A49CFE /* Services */,
|
||||
AF9CABF75E77168EF1D60F29 /* Views */,
|
||||
@@ -164,6 +176,17 @@
|
||||
path = Models;
|
||||
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 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -244,6 +267,7 @@
|
||||
F5B3C85424137FA0CE2467B1 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */,
|
||||
093402014727A338745A06DA /* SwiftUI.framework */,
|
||||
0552C13335034219DECF4F62 /* WidgetKit.framework */,
|
||||
);
|
||||
@@ -393,6 +417,7 @@
|
||||
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */,
|
||||
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */,
|
||||
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
|
||||
FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */,
|
||||
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */,
|
||||
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
|
||||
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
|
||||
@@ -401,8 +426,11 @@
|
||||
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
|
||||
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
|
||||
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */,
|
||||
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */,
|
||||
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */,
|
||||
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
|
||||
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */,
|
||||
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */,
|
||||
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */,
|
||||
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */,
|
||||
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1630"
|
||||
version = "1.3">
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
@@ -40,7 +41,8 @@
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
@@ -65,15 +67,6 @@
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CF31B2655F8F30CF6DF3C26F"
|
||||
BuildableName = "ShareExtension.appex"
|
||||
BlueprintName = "ShareExtension"
|
||||
ReferencedContainer = "container:Marks.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
|
||||
@@ -81,7 +74,9 @@
|
||||
BlueprintName = "Marks"
|
||||
ReferencedContainer = "container:Marks.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
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 deepLinkBrowser: IdentifiableURL?
|
||||
@State private var showDeepLinkPlayer = false
|
||||
@State private var selectedTab: AppTab = .bookmarks
|
||||
@State private var router = IntentRouter.shared
|
||||
|
||||
init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
|
||||
self.config = config
|
||||
@@ -45,20 +47,23 @@ struct MainContainer: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
Tab("Bookmarks", systemImage: "bookmark") {
|
||||
TabView(selection: $selectedTab) {
|
||||
Tab("Bookmarks", systemImage: "bookmark", value: AppTab.bookmarks) {
|
||||
BookmarksView(viewModel: viewModel, onDisconnect: onDisconnect)
|
||||
}
|
||||
Tab("Tags", systemImage: "tag") {
|
||||
Tab("Tags", systemImage: "tag", value: AppTab.tags) {
|
||||
TagsView(viewModel: viewModel)
|
||||
}
|
||||
Tab(role: .search) {
|
||||
Tab(value: AppTab.search, role: .search) {
|
||||
SearchView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
.onOpenURL { url in
|
||||
handleDeepLink(url)
|
||||
}
|
||||
.task { applyPendingIntent() }
|
||||
.onChange(of: router.openBookmarkURL) { _, _ in applyPendingIntent() }
|
||||
.onChange(of: router.searchRequest) { _, _ in applyPendingIntent() }
|
||||
.sheet(item: $deepLinkBrowser) { item in
|
||||
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) {
|
||||
guard url.scheme == "marks",
|
||||
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
|
||||
@@ -57,6 +57,15 @@ struct SearchView: View {
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Titles, URLs, tags…")
|
||||
.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] {
|
||||
|
||||
@@ -74,6 +74,7 @@ targets:
|
||||
- target: ShareExtension
|
||||
- target: MarksWidget
|
||||
- sdk: WidgetKit.framework
|
||||
- sdk: AppIntents.framework
|
||||
|
||||
ShareExtension:
|
||||
type: app-extension
|
||||
|
||||
Reference in New Issue
Block a user