From fc82c2d90fa1b0cf0e6189522bceff975d314626 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 7 Jul 2026 14:38:47 +1200 Subject: [PATCH 1/2] Add App Shortcuts with navigation intents to the Jetpack app Four App Intents (new post, notifications, stats, reader) open their screens through RootViewCoordinator, mirroring the 3D Touch quick actions; each requires a signed-in account because without one the shared presenter builds a detached view hierarchy and the intent silently does nothing while reporting success. The stats intent takes an optional site parameter backed by the widget SiteEntity, and an explicit but unresolvable site reports an error rather than opening the wrong site. The AppShortcutsProvider and intents live in the Jetpack-only sources folder so the WordPress app does not expose them. Strings are keyed with the ios-appintents. prefix backed by a hand-maintained GlotPress source file, and Siri invocation phrases get an AppShortcuts.strings table in the app bundle, following the ios-widget. precedent. --- .../AppIntents/AppIntentOpenError.swift | 23 ++++++++ .../AppIntents/JetpackAppShortcuts.swift | 47 ++++++++++++++++ .../Jetpack/AppIntents/NewPostIntent.swift | 22 ++++++++ .../AppIntents/OpenNotificationsIntent.swift | 27 ++++++++++ .../Jetpack/AppIntents/OpenReaderIntent.swift | 24 +++++++++ .../Jetpack/AppIntents/OpenStatsIntent.swift | 32 +++++++++++ .../Resources/en.lproj/AppShortcuts.strings | 39 ++++++++++++++ .../Models/BlogAppIntentResolutionTests.swift | 42 +++++++++++++++ .../Classes/Models/Blog/Blog+AppIntents.swift | 20 +++++++ .../en.lproj/Localizable.strings | 53 +++++++++++++++++++ fastlane/lanes/localization.rb | 4 +- 11 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 Sources/Jetpack/AppIntents/AppIntentOpenError.swift create mode 100644 Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift create mode 100644 Sources/Jetpack/AppIntents/NewPostIntent.swift create mode 100644 Sources/Jetpack/AppIntents/OpenNotificationsIntent.swift create mode 100644 Sources/Jetpack/AppIntents/OpenReaderIntent.swift create mode 100644 Sources/Jetpack/AppIntents/OpenStatsIntent.swift create mode 100644 Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings create mode 100644 Tests/KeystoneTests/Tests/Models/BlogAppIntentResolutionTests.swift create mode 100644 WordPress/Classes/Models/Blog/Blog+AppIntents.swift create mode 100644 WordPress/JetpackAppIntents/en.lproj/Localizable.strings diff --git a/Sources/Jetpack/AppIntents/AppIntentOpenError.swift b/Sources/Jetpack/AppIntents/AppIntentOpenError.swift new file mode 100644 index 000000000000..5f29adf4896d --- /dev/null +++ b/Sources/Jetpack/AppIntents/AppIntentOpenError.swift @@ -0,0 +1,23 @@ +import AppIntents +import Foundation + +/// Errors surfaced to the system when an open intent cannot proceed. +enum AppIntentOpenError: Error, CustomLocalizedStringResourceConvertible { + case notLoggedIn + case siteNotFound + + var localizedStringResource: LocalizedStringResource { + switch self { + case .notLoggedIn: + return LocalizedStringResource( + "ios-appintents.openError.notLoggedIn", + defaultValue: "Sign in to the app first to use this action." + ) + case .siteNotFound: + return LocalizedStringResource( + "ios-appintents.openError.siteNotFound", + defaultValue: "The site could not be found." + ) + } + } +} diff --git a/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift b/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift new file mode 100644 index 000000000000..f95107888648 --- /dev/null +++ b/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift @@ -0,0 +1,47 @@ +import AppIntents + +/// Surfaces the navigation intents in the Shortcuts app and Siri without any user setup. +struct JetpackAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: NewPostIntent(), + phrases: [ + "Create a new post in \(.applicationName)", + "Write a new post in \(.applicationName)", + "New \(.applicationName) post" + ], + shortTitle: LocalizedStringResource("ios-appintents.newPost.shortTitle", defaultValue: "New Post"), + systemImageName: "square.and.pencil" + ) + AppShortcut( + intent: OpenNotificationsIntent(), + phrases: [ + "Open my \(.applicationName) notifications", + "Show my \(.applicationName) notifications" + ], + shortTitle: LocalizedStringResource( + "ios-appintents.openNotifications.shortTitle", + defaultValue: "Notifications" + ), + systemImageName: "bell" + ) + AppShortcut( + intent: OpenStatsIntent(), + phrases: [ + "Open my \(.applicationName) stats", + "Show my site stats in \(.applicationName)" + ], + shortTitle: LocalizedStringResource("ios-appintents.openStats.shortTitle", defaultValue: "Stats"), + systemImageName: "chart.bar" + ) + AppShortcut( + intent: OpenReaderIntent(), + phrases: [ + "Open the \(.applicationName) Reader", + "Open Reader in \(.applicationName)" + ], + shortTitle: LocalizedStringResource("ios-appintents.openReader.shortTitle", defaultValue: "Reader"), + systemImageName: "book" + ) + } +} diff --git a/Sources/Jetpack/AppIntents/NewPostIntent.swift b/Sources/Jetpack/AppIntents/NewPostIntent.swift new file mode 100644 index 000000000000..33e45416204c --- /dev/null +++ b/Sources/Jetpack/AppIntents/NewPostIntent.swift @@ -0,0 +1,22 @@ +import AppIntents + +/// Opens the post editor for the last used site. +struct NewPostIntent: AppIntent { + static let title = LocalizedStringResource("ios-appintents.newPost.title", defaultValue: "New Post") + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.newPost.description", + defaultValue: "Opens the editor to write a new post." + ) + ) + static let openAppWhenRun = true + + @MainActor + func perform() async throws -> some IntentResult { + guard AccountHelper.isLoggedIn else { + throw AppIntentOpenError.notLoggedIn + } + RootViewCoordinator.sharedPresenter.showPostEditor(animated: false) + return .result() + } +} diff --git a/Sources/Jetpack/AppIntents/OpenNotificationsIntent.swift b/Sources/Jetpack/AppIntents/OpenNotificationsIntent.swift new file mode 100644 index 000000000000..75503e971e66 --- /dev/null +++ b/Sources/Jetpack/AppIntents/OpenNotificationsIntent.swift @@ -0,0 +1,27 @@ +import AppIntents + +/// Opens the Notifications tab. +struct OpenNotificationsIntent: AppIntent { + static let title = LocalizedStringResource( + "ios-appintents.openNotifications.title", + defaultValue: "Open Notifications" + ) + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.openNotifications.description", + defaultValue: "Opens your notifications." + ) + ) + static let openAppWhenRun = true + + @MainActor + func perform() async throws -> some IntentResult { + guard AccountHelper.isLoggedIn else { + throw AppIntentOpenError.notLoggedIn + } + let presenter = RootViewCoordinator.sharedPresenter + presenter.rootViewController.dismiss(animated: false) + presenter.showNotificationsTab() + return .result() + } +} diff --git a/Sources/Jetpack/AppIntents/OpenReaderIntent.swift b/Sources/Jetpack/AppIntents/OpenReaderIntent.swift new file mode 100644 index 000000000000..5d37fd58d44d --- /dev/null +++ b/Sources/Jetpack/AppIntents/OpenReaderIntent.swift @@ -0,0 +1,24 @@ +import AppIntents + +/// Opens the Reader tab. +struct OpenReaderIntent: AppIntent { + static let title = LocalizedStringResource("ios-appintents.openReader.title", defaultValue: "Open Reader") + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.openReader.description", + defaultValue: "Opens the Reader to browse blogs you follow." + ) + ) + static let openAppWhenRun = true + + @MainActor + func perform() async throws -> some IntentResult { + guard AccountHelper.isLoggedIn else { + throw AppIntentOpenError.notLoggedIn + } + let presenter = RootViewCoordinator.sharedPresenter + presenter.rootViewController.dismiss(animated: false) + presenter.showReader(path: nil) + return .result() + } +} diff --git a/Sources/Jetpack/AppIntents/OpenStatsIntent.swift b/Sources/Jetpack/AppIntents/OpenStatsIntent.swift new file mode 100644 index 000000000000..63e780a86162 --- /dev/null +++ b/Sources/Jetpack/AppIntents/OpenStatsIntent.swift @@ -0,0 +1,32 @@ +import AppIntents +import JetpackStatsWidgetsCore +import WordPressData + +/// Opens Stats for a chosen site, or the last used site when none is picked. +struct OpenStatsIntent: AppIntent { + static let title = LocalizedStringResource("ios-appintents.openStats.title", defaultValue: "Open Stats") + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.openStats.description", + defaultValue: "Opens the stats for one of your sites." + ) + ) + static let openAppWhenRun = true + + @Parameter(title: LocalizedStringResource("ios-appintents.openStats.siteParameter", defaultValue: "Site")) + var site: SiteEntity? + + @MainActor + func perform() async throws -> some IntentResult { + guard AccountHelper.isLoggedIn else { + throw AppIntentOpenError.notLoggedIn + } + guard let blog = Blog.forAppIntent(siteIdentifier: site?.id, in: ContextManager.shared.mainContext) else { + throw AppIntentOpenError.siteNotFound + } + let presenter = RootViewCoordinator.sharedPresenter + presenter.rootViewController.dismiss(animated: false) + presenter.showStats(for: blog, source: .shortcut) + return .result() + } +} diff --git a/Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings b/Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings new file mode 100644 index 000000000000..34d8ecdd9937 --- /dev/null +++ b/Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings @@ -0,0 +1,39 @@ +/* Siri invocation phrases for the App Shortcuts declared in + Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift. + + Each key must match a phrase in code character for character, with ${applicationName} in + place of the \(.applicationName) interpolation; the system looks phrases up by the English + text, so a mismatched key silently disables that phrase's localization. Translations must + keep the ${applicationName} placeholder as-is. + + This file ships in the Jetpack app bundle. It is uploaded to GlotPress under the + "ios-appintents-phrases." prefix (see MANUALLY_MAINTAINED_STRINGS_FILES in + fastlane/lanes/localization.rb), and the per-locale sibling files are generated by the + release tooling when translations are downloaded. */ + +/* Siri phrase to open the post editor. ${applicationName} is the app's name. */ +"Create a new post in ${applicationName}" = "Create a new post in ${applicationName}"; + +/* Siri phrase to open the post editor. ${applicationName} is the app's name. */ +"Write a new post in ${applicationName}" = "Write a new post in ${applicationName}"; + +/* Siri phrase to open the post editor. ${applicationName} is the app's name. */ +"New ${applicationName} post" = "New ${applicationName} post"; + +/* Siri phrase to open the Notifications tab. ${applicationName} is the app's name. */ +"Open my ${applicationName} notifications" = "Open my ${applicationName} notifications"; + +/* Siri phrase to open the Notifications tab. ${applicationName} is the app's name. */ +"Show my ${applicationName} notifications" = "Show my ${applicationName} notifications"; + +/* Siri phrase to open Stats. ${applicationName} is the app's name. */ +"Open my ${applicationName} stats" = "Open my ${applicationName} stats"; + +/* Siri phrase to open Stats. ${applicationName} is the app's name. */ +"Show my site stats in ${applicationName}" = "Show my site stats in ${applicationName}"; + +/* Siri phrase to open the Reader tab. ${applicationName} is the app's name. */ +"Open the ${applicationName} Reader" = "Open the ${applicationName} Reader"; + +/* Siri phrase to open the Reader tab. ${applicationName} is the app's name. */ +"Open Reader in ${applicationName}" = "Open Reader in ${applicationName}"; diff --git a/Tests/KeystoneTests/Tests/Models/BlogAppIntentResolutionTests.swift b/Tests/KeystoneTests/Tests/Models/BlogAppIntentResolutionTests.swift new file mode 100644 index 000000000000..29499d2c9bb0 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Models/BlogAppIntentResolutionTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing + +@testable import WordPress +@testable import WordPressData + +@MainActor +@Suite("Blog app intent resolution") +struct BlogAppIntentResolutionTests { + @Test("a site identifier resolves the matching blog") + func explicitIdentifierResolvesMatchingBlog() { + let context = ContextManager.forTesting().mainContext + BlogBuilder(context).with(dotComID: 111).build() + let target = BlogBuilder(context).with(dotComID: 222).build() + + #expect(Blog.forAppIntent(siteIdentifier: "222", in: context) == target) + } + + @Test("an identifier with no matching blog resolves nothing instead of falling back") + func unknownIdentifierResolvesNil() { + let context = ContextManager.forTesting().mainContext + BlogBuilder(context).with(dotComID: 111).build() + + #expect(Blog.forAppIntent(siteIdentifier: "999", in: context) == nil) + } + + @Test("a non-numeric identifier resolves nothing") + func nonNumericIdentifierResolvesNil() { + let context = ContextManager.forTesting().mainContext + BlogBuilder(context).with(dotComID: 111).build() + + #expect(Blog.forAppIntent(siteIdentifier: "not-a-number", in: context) == nil) + } + + @Test("no identifier falls back to the last used or first blog") + func missingIdentifierFallsBack() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + + #expect(Blog.forAppIntent(siteIdentifier: nil, in: context) == blog) + } +} diff --git a/WordPress/Classes/Models/Blog/Blog+AppIntents.swift b/WordPress/Classes/Models/Blog/Blog+AppIntents.swift new file mode 100644 index 000000000000..b8afb1ebc5a9 --- /dev/null +++ b/WordPress/Classes/Models/Blog/Blog+AppIntents.swift @@ -0,0 +1,20 @@ +import Foundation +import WordPressData + +extension Blog { + /// Resolves the blog an App Intent should act on. + /// + /// An explicit site identifier must resolve to its own blog: falling back to + /// another site would silently act on the wrong one, so unresolvable + /// identifiers return `nil`. Only when no identifier is given does the + /// resolution fall back to the last used or first blog. + static func forAppIntent(siteIdentifier: String?, in context: NSManagedObjectContext) -> Blog? { + guard let siteIdentifier else { + return lastUsedOrFirst(in: context) + } + guard let siteID = Int(siteIdentifier) else { + return nil + } + return try? lookup(withID: siteID, in: context) + } +} diff --git a/WordPress/JetpackAppIntents/en.lproj/Localizable.strings b/WordPress/JetpackAppIntents/en.lproj/Localizable.strings new file mode 100644 index 000000000000..fc09d73c9c87 --- /dev/null +++ b/WordPress/JetpackAppIntents/en.lproj/Localizable.strings @@ -0,0 +1,53 @@ +/* Source strings for the Jetpack App Intents (Siri, Shortcuts, Spotlight), uploaded to GlotPress + under the "ios-appintents." prefix (see MANUALLY_MAINTAINED_STRINGS_FILES in + fastlane/lanes/localization.rb). + + This file is not shipped in any bundle. At runtime the prefixed keys are resolved from the + app's Localizable.strings, with the defaultValue in code as the fallback until the keys have + gone through a code freeze. Keep the keys in sync with the LocalizedStringResource keys in + Sources/Jetpack/AppIntents (minus the prefix). */ + +/* Title of the App Intent that opens the post editor. Shown in the Shortcuts app and Spotlight. */ +"newPost.title" = "New Post"; + +/* Description of the App Intent that opens the post editor. Shown in the Shortcuts app. */ +"newPost.description" = "Opens the editor to write a new post."; + +/* Short title of the New Post shortcut tile in Spotlight and the Shortcuts app. */ +"newPost.shortTitle" = "New Post"; + +/* Title of the App Intent that opens the Notifications tab. Shown in the Shortcuts app and Spotlight. */ +"openNotifications.title" = "Open Notifications"; + +/* Description of the App Intent that opens the Notifications tab. Shown in the Shortcuts app. */ +"openNotifications.description" = "Opens your notifications."; + +/* Short title of the Notifications shortcut tile in Spotlight and the Shortcuts app. */ +"openNotifications.shortTitle" = "Notifications"; + +/* Title of the App Intent that opens Stats for a site. Shown in the Shortcuts app and Spotlight. */ +"openStats.title" = "Open Stats"; + +/* Description of the App Intent that opens Stats for a site. Shown in the Shortcuts app. */ +"openStats.description" = "Opens the stats for one of your sites."; + +/* Label of the site parameter of the Open Stats App Intent. Shown in the Shortcuts app. */ +"openStats.siteParameter" = "Site"; + +/* Short title of the Stats shortcut tile in Spotlight and the Shortcuts app. */ +"openStats.shortTitle" = "Stats"; + +/* Title of the App Intent that opens the Reader tab. Shown in the Shortcuts app and Spotlight. */ +"openReader.title" = "Open Reader"; + +/* Description of the App Intent that opens the Reader tab. Shown in the Shortcuts app. */ +"openReader.description" = "Opens the Reader to browse blogs you follow."; + +/* Short title of the Reader shortcut tile in Spotlight and the Shortcuts app. */ +"openReader.shortTitle" = "Reader"; + +/* Error shown by Siri or the Shortcuts app when an App Intent runs while no account is signed in. */ +"openError.notLoggedIn" = "Sign in to the app first to use this action."; + +/* Error shown by Siri or the Shortcuts app when the site an App Intent should act on no longer exists. */ +"openError.siteNotFound" = "The site could not be found."; diff --git a/fastlane/lanes/localization.rb b/fastlane/lanes/localization.rb index a6897b7dc1ac..99c3697e0e74 100644 --- a/fastlane/lanes/localization.rb +++ b/fastlane/lanes/localization.rb @@ -97,7 +97,9 @@ File.join('WordPress', 'Resources', 'en.lproj', 'InfoPlist.strings') => 'infoplist.', # For now WordPress and Jetpack share the same InfoPlist.strings File.join('WordPress', 'WordPressDraftActionExtension', 'en.lproj', 'InfoPlist.strings') => 'ios-sharesheet.', # CFBundleDisplayName for the "Save as Draft" share action File.join('WordPress', 'JetpackDraftActionExtension', 'en.lproj', 'InfoPlist.strings') => 'ios-jetpack-sharesheet.', # CFBundleDisplayName for the "Save to Jetpack" share action - File.join('WordPress', 'JetpackStatsWidgets', 'en.lproj', 'Localizable.strings') => 'ios-widget.' # Strings for the App Intents UI used when configuring the iOS Widget; resolved at runtime via the prefixed keys in the app's Localizable.strings + File.join('WordPress', 'JetpackStatsWidgets', 'en.lproj', 'Localizable.strings') => 'ios-widget.', # Strings for the App Intents UI used when configuring the iOS Widget; resolved at runtime via the prefixed keys in the app's Localizable.strings + File.join('WordPress', 'JetpackAppIntents', 'en.lproj', 'Localizable.strings') => 'ios-appintents.', # Titles, descriptions, and dialogs of the Jetpack App Intents; resolved at runtime via the prefixed keys in the app's Localizable.strings + File.join('Sources', 'Jetpack', 'Resources', 'en.lproj', 'AppShortcuts.strings') => 'ios-appintents-phrases.' # Siri invocation phrases for the Jetpack App Shortcuts; the per-locale sibling files ship in the Jetpack app bundle }.freeze # Remote Swift Packages whose localizable strings we want to extract (they're checked out under Derived Data From 3677cbee82e7e541b5bf69bf4f4bdedc778a4e8b Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 7 Jul 2026 15:59:27 +1200 Subject: [PATCH 2/2] Add a release note for the navigation App Shortcuts --- RELEASE-NOTES.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 423368e006f3..2c0712730427 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -3,6 +3,7 @@ * [*] [internal] In-app updates: guard the flexible update notice against double-posting when two update checks race [#25666] * [*] [build tooling] Add a String Catalog localization pipeline (plurals + build-free catalog generation) with a CI coverage gate [#25688] * [*] [internal] Stats widgets: migrate the site picker from SiriKit to App Intents [#25753] +* [*] Add App Shortcuts for creating a post and opening Notifications, Stats, and the Reader from Siri, Spotlight, and the Shortcuts app [#25754] 27.0