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:
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"
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user