Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions Sources/Jetpack/AppIntents/AppIntentOpenError.swift
Original file line number Diff line number Diff line change
@@ -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."
)
}
}
}
60 changes: 60 additions & 0 deletions Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift
Original file line number Diff line number Diff line change
@@ -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"
)
}
}
27 changes: 27 additions & 0 deletions Sources/Jetpack/AppIntents/NewPostIntent.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
29 changes: 29 additions & 0 deletions Sources/Jetpack/AppIntents/OpenNotificationsIntent.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
29 changes: 29 additions & 0 deletions Sources/Jetpack/AppIntents/OpenReaderIntent.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
43 changes: 43 additions & 0 deletions Sources/Jetpack/AppIntents/OpenStatsIntent.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
39 changes: 39 additions & 0 deletions Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings
Original file line number Diff line number Diff line change
@@ -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}";
Original file line number Diff line number Diff line change
@@ -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)
}
}
20 changes: 20 additions & 0 deletions WordPress/Classes/Models/Blog/Blog+AppIntents.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
26 changes: 26 additions & 0 deletions docs/localization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion fastlane/lanes/localization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions fastlane/lanes/localization_catalog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down