diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index 71a6654..680ddf4 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -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 = ""; }; 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = ""; }; 629C41E0BC28EB6359D50CFD /* BrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserView.swift; sourceTree = ""; }; + 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAppIntents.swift; sourceTree = ""; }; 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = ""; }; 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = ""; }; 86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = ""; }; + 8D650D0784B847BFEDCB3141 /* BookmarkEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkEntity.swift; sourceTree = ""; }; 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 = ""; }; 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomBookmarkWidget.swift; sourceTree = ""; }; + 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSnippetViews.swift; sourceTree = ""; }; A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = ""; }; AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = ""; }; 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 = ""; }; CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = ""; }; D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = ""; }; + D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = ""; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = ""; }; DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = ""; }; E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = ""; }; F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = ""; }; + 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 = ""; }; /* 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 = ""; }; + 58E8E316BE3F10C5149AADC3 /* Intents */ = { + isa = PBXGroup; + children = ( + 8D650D0784B847BFEDCB3141 /* BookmarkEntity.swift */, + 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */, + D6ACABF0CA940312B4195456 /* IntentSupport.swift */, + 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */, + ); + path = Intents; + sourceTree = ""; + }; 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 */, diff --git a/Marks.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme b/Marks.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme index f18cdd2..0e50a44 100644 --- a/Marks.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme +++ b/Marks.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme @@ -1,10 +1,11 @@ + version = "1.7"> + buildImplicitDependencies = "YES" + runPostActionsOnFailure = "NO"> + shouldUseLaunchSchemeArgsEnv = "YES" + onlyGenerateCoverageForSpecifiedTargets = "NO"> - - - - - + + + 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:)) + } +} diff --git a/Marks/Intents/IntentSnippetViews.swift b/Marks/Intents/IntentSnippetViews.swift new file mode 100644 index 0000000..120e0fe --- /dev/null +++ b/Marks/Intents/IntentSnippetViews.swift @@ -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() + } +} diff --git a/Marks/Intents/IntentSupport.swift b/Marks/Intents/IntentSupport.swift new file mode 100644 index 0000000..878120a --- /dev/null +++ b/Marks/Intents/IntentSupport.swift @@ -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))) + } +} diff --git a/Marks/Intents/MarksAppIntents.swift b/Marks/Intents/MarksAppIntents.swift new file mode 100644 index 0000000..588485b --- /dev/null +++ b/Marks/Intents/MarksAppIntents.swift @@ -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 & 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 & 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" + ) + } +} diff --git a/Marks/MarksApp.swift b/Marks/MarksApp.swift index 77a4681..e10a01c 100644 --- a/Marks/MarksApp.swift +++ b/Marks/MarksApp.swift @@ -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), diff --git a/Marks/Views/SearchView.swift b/Marks/Views/SearchView.swift index ff5f263..8133ccc 100644 --- a/Marks/Views/SearchView.swift +++ b/Marks/Views/SearchView.swift @@ -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] { diff --git a/project.yml b/project.yml index d37a08d..d41725a 100644 --- a/project.yml +++ b/project.yml @@ -74,6 +74,7 @@ targets: - target: ShareExtension - target: MarksWidget - sdk: WidgetKit.framework + - sdk: AppIntents.framework ShareExtension: type: app-extension