From aea3e238f15522000d8d9f4d5264738ac1815b3e 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 | 27 +++++++++ .../AppIntents/JetpackAppShortcuts.swift | 60 +++++++++++++++++++ .../Jetpack/AppIntents/NewPostIntent.swift | 27 +++++++++ .../AppIntents/OpenNotificationsIntent.swift | 29 +++++++++ .../Jetpack/AppIntents/OpenReaderIntent.swift | 29 +++++++++ .../Jetpack/AppIntents/OpenStatsIntent.swift | 43 +++++++++++++ .../Resources/en.lproj/AppShortcuts.strings | 39 ++++++++++++ .../Models/BlogAppIntentResolutionTests.swift | 42 +++++++++++++ .../Classes/Models/Blog/Blog+AppIntents.swift | 20 +++++++ docs/localization.md | 26 ++++++++ fastlane/lanes/localization.rb | 3 +- fastlane/lanes/localization_catalog.rb | 6 +- 12 files changed, 348 insertions(+), 3 deletions(-) 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 diff --git a/Sources/Jetpack/AppIntents/AppIntentOpenError.swift b/Sources/Jetpack/AppIntents/AppIntentOpenError.swift new file mode 100644 index 000000000000..543918d62632 --- /dev/null +++ b/Sources/Jetpack/AppIntents/AppIntentOpenError.swift @@ -0,0 +1,27 @@ +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.", + comment: + "Error shown by Siri or the Shortcuts app when an App Intent runs while no account is signed in." + ) + case .siteNotFound: + return LocalizedStringResource( + "ios-appintents.openError.siteNotFound", + defaultValue: "The site could not be found.", + comment: + "Error shown by Siri or the Shortcuts app when the site an App Intent should act on no longer exists." + ) + } + } +} diff --git a/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift b/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift new file mode 100644 index 000000000000..340d58dcce51 --- /dev/null +++ b/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift @@ -0,0 +1,60 @@ +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", + comment: "Short title of the New Post shortcut tile in Spotlight and the Shortcuts app." + ), + systemImageName: "square.and.pencil" + ) + AppShortcut( + intent: OpenNotificationsIntent(), + phrases: [ + "Open my \(.applicationName) notifications", + "Show my \(.applicationName) notifications" + ], + shortTitle: LocalizedStringResource( + "ios-appintents.openNotifications.shortTitle", + defaultValue: "Notifications", + comment: "Short title of the Notifications shortcut tile in Spotlight and the Shortcuts app." + ), + systemImageName: "bell" + ) + AppShortcut( + intent: OpenStatsIntent(), + phrases: [ + "Open my \(.applicationName) stats", + "Show my site stats in \(.applicationName)" + ], + shortTitle: LocalizedStringResource( + "ios-appintents.openStats.shortTitle", + defaultValue: "Stats", + comment: "Short title of the Stats shortcut tile in Spotlight and the Shortcuts app." + ), + systemImageName: "chart.bar" + ) + AppShortcut( + intent: OpenReaderIntent(), + phrases: [ + "Open the \(.applicationName) Reader", + "Open Reader in \(.applicationName)" + ], + shortTitle: LocalizedStringResource( + "ios-appintents.openReader.shortTitle", + defaultValue: "Reader", + comment: "Short title of the Reader shortcut tile in Spotlight and the Shortcuts app." + ), + systemImageName: "book" + ) + } +} diff --git a/Sources/Jetpack/AppIntents/NewPostIntent.swift b/Sources/Jetpack/AppIntents/NewPostIntent.swift new file mode 100644 index 000000000000..046cd7e78175 --- /dev/null +++ b/Sources/Jetpack/AppIntents/NewPostIntent.swift @@ -0,0 +1,27 @@ +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", + comment: "Title of the App Intent that opens the post editor. Shown in the Shortcuts app and Spotlight." + ) + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.newPost.description", + defaultValue: "Opens the editor to write a new post.", + comment: "Description of the App Intent that opens the post editor. Shown in the Shortcuts app." + ) + ) + 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..21cb706abf05 --- /dev/null +++ b/Sources/Jetpack/AppIntents/OpenNotificationsIntent.swift @@ -0,0 +1,29 @@ +import AppIntents + +/// Opens the Notifications tab. +struct OpenNotificationsIntent: AppIntent { + static let title = LocalizedStringResource( + "ios-appintents.openNotifications.title", + defaultValue: "Open Notifications", + comment: "Title of the App Intent that opens the Notifications tab. Shown in the Shortcuts app and Spotlight." + ) + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.openNotifications.description", + defaultValue: "Opens your notifications.", + comment: "Description of the App Intent that opens the Notifications tab. Shown in the Shortcuts app." + ) + ) + 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..9a5d1a5d853e --- /dev/null +++ b/Sources/Jetpack/AppIntents/OpenReaderIntent.swift @@ -0,0 +1,29 @@ +import AppIntents + +/// Opens the Reader tab. +struct OpenReaderIntent: AppIntent { + static let title = LocalizedStringResource( + "ios-appintents.openReader.title", + defaultValue: "Open Reader", + comment: "Title of the App Intent that opens the Reader tab. Shown in the Shortcuts app and Spotlight." + ) + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.openReader.description", + defaultValue: "Opens the Reader to browse blogs you follow.", + comment: "Description of the App Intent that opens the Reader tab. Shown in the Shortcuts app." + ) + ) + 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..34278ccd6335 --- /dev/null +++ b/Sources/Jetpack/AppIntents/OpenStatsIntent.swift @@ -0,0 +1,43 @@ +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", + comment: "Title of the App Intent that opens Stats for a site. Shown in the Shortcuts app and Spotlight." + ) + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.openStats.description", + defaultValue: "Opens the stats for one of your sites.", + comment: "Description of the App Intent that opens Stats for a site. Shown in the Shortcuts app." + ) + ) + static let openAppWhenRun = true + + @Parameter( + title: LocalizedStringResource( + "ios-appintents.openStats.siteParameter", + defaultValue: "Site", + comment: "Label of the site parameter of the Open Stats App Intent. Shown in the Shortcuts app." + ) + ) + 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/docs/localization.md b/docs/localization.md index c34705a36c99..18bb7de8dff3 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -129,3 +129,29 @@ private enum Strings { ## Testing Test with long words and special characters to ensure UI layouts work across languages. + +## App Intents strings + +App Intents display strings (`LocalizedStringResource` titles, descriptions, parameter names, +dialog and error text) cannot be extracted by `genstrings`. Write them with an explicit prefixed +key, an English default, and a translator comment — all in code, which is the single source: + + LocalizedStringResource( + "ios-appintents.newPost.title", + defaultValue: "New Post", + comment: "Title of the App Shortcut that opens the editor" + ) + +Interpolating values in a `defaultValue` is supported for preformatted Strings only — the freeze +upload renders each `\(…)` as a positional `%@` specifier; format numbers and dates into Strings +before interpolating them. + +At code freeze, `generate_app_intents_strings_for_glotpress` extracts these call sites with +`xcstringstool` (dirs listed in `APP_INTENTS_STRINGS_ROOTS`) and merges them into the GlotPress +upload; `bundle exec fastlane generate_app_intents_strings_for_glotpress` runs the extraction +standalone as a smoke check (it prints the count and errors on unprefixed keys). Translations +come back in the app's `Localizable.strings`, which the system resolves against on iOS 18 and +later (the iOS 17 widget-configuration UI only reads the widget extension bundle and therefore +shows the English defaults). Siri invocation phrases are different: they live in +`AppShortcuts.strings` (keyed by the English phrase), a roundtrip file whose per-locale siblings +ship in the app bundle. diff --git a/fastlane/lanes/localization.rb b/fastlane/lanes/localization.rb index 12a30becac68..d5e2f2d37fc0 100644 --- a/fastlane/lanes/localization.rb +++ b/fastlane/lanes/localization.rb @@ -96,7 +96,8 @@ MANUALLY_MAINTAINED_STRINGS_FILES = { 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', 'JetpackDraftActionExtension', 'en.lproj', 'InfoPlist.strings') => 'ios-jetpack-sharesheet.', # CFBundleDisplayName for the "Save to Jetpack" share action + 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 diff --git a/fastlane/lanes/localization_catalog.rb b/fastlane/lanes/localization_catalog.rb index 0a1647ebf69b..171469b23e03 100644 --- a/fastlane/lanes/localization_catalog.rb +++ b/fastlane/lanes/localization_catalog.rb @@ -44,11 +44,13 @@ # freeze extracts them from these dirs with xcstringstool and merges them into the GlotPress upload # (see generate_app_intents_strings_for_glotpress). Keys in code are self-qualified with these prefixes. APP_INTENTS_STRINGS_ROOTS = [ - File.join(PROJECT_ROOT_FOLDER, 'Modules', 'Sources', 'JetpackStatsWidgetsCore', 'AppIntents') + File.join(PROJECT_ROOT_FOLDER, 'Modules', 'Sources', 'JetpackStatsWidgetsCore', 'AppIntents'), + File.join(PROJECT_ROOT_FOLDER, 'Sources', 'Jetpack', 'AppIntents') ].freeze APP_INTENTS_KEY_PREFIXES = [ - 'ios-widget.' + 'ios-widget.', + 'ios-appintents.' ].freeze platform :ios do From 34cef4e3ade90d05403092cfdd1079ae1f96556c 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