Skip to content

Commit 07e4ea2

Browse files
authored
App Intents: migrate the stats widget site picker off SiriKit (#25753)
* Format stats widget sources with swift-format * Add App Intents site entity and configuration intent for stats widgets Introduces SiteEntity, SiteEntityQuery, and an App Intents SelectSiteIntent in JetpackStatsWidgetsCore, backed by the same widget plist cache and shared defaults the SiriKit intents extension reads today. The entity identifier matches the legacy Site identifier so existing widget configurations survive the upcoming migration. No AppIntentsPackage registration is included because linkd rejects package references from statically linked modules, and static linking already merges the intent metadata into the consuming targets. CFBundleName in the test bundle plist keeps xcodebuild test working once the project contains App Intents. * Migrate stats widgets to AppIntentConfiguration and remove the SiriKit intents extension Switches all nine Jetpack stats widgets to AppIntentConfiguration with async AppIntentTimelineProviders using the SelectSiteIntent from JetpackStatsWidgetsCore. The site option list now comes from an in-process entity query, so the JetpackIntents extension, Sites.intentdefinition, and their localizations are removed along with the target's build tooling references, and the GlotPress upload source for the ios-widget. keys moves to an unshipped en.lproj under WordPress/JetpackStatsWidgets. The system migrates existing widget configurations by matching the legacy intent class name and site identifier. fetchStats now reports a failure instead of silently dropping the completion when a refresh is already in progress. * Ship the widget App Intents strings in the widget extension bundle The system resolves the widget configuration UI's strings against the app bundle on iOS 26 but against the widget extension bundle on iOS 17, and a package resource bundle is never consulted (verified on simulators). The extension therefore ships static copies of the GlotPress-maintained ios-widget. keys for the older resolution path. * Add a release note for the widget site picker migration
1 parent 4234672 commit 07e4ea2

100 files changed

Lines changed: 820 additions & 1559 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Modules/Package.swift

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,11 @@ let package = Package(
120120
],
121121
resources: [.process("Resources")]
122122
),
123-
.target(name: "JetpackStatsWidgetsCore", swiftSettings: [.swiftLanguageMode(.v5)]),
123+
.target(
124+
name: "JetpackStatsWidgetsCore",
125+
dependencies: ["BuildSettingsKit"],
126+
swiftSettings: [.swiftLanguageMode(.v5)]
127+
),
124128
.target(
125129
name: "JetpackSocial",
126130
dependencies: [
@@ -405,7 +409,6 @@ enum XcodeSupport {
405409
name: "XcodeTarget_NotificationServiceExtension",
406410
targets: ["XcodeTarget_NotificationServiceExtension"]
407411
),
408-
.library(name: "XcodeTarget_Intents", targets: ["XcodeTarget_Intents"]),
409412
.library(name: "XcodeTarget_StatsWidget", targets: ["XcodeTarget_StatsWidget"])
410413
]
411414
}
@@ -571,24 +574,6 @@ enum XcodeSupport {
571574
.product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"),
572575
.product(name: "WordPressAPI", package: "wordpress-rs")
573576
]
574-
),
575-
.xcodeTarget(
576-
"XcodeTarget_Intents",
577-
dependencies: [
578-
"BuildSettingsKit",
579-
"JetpackStatsWidgetsCore",
580-
// Even though the extensions are all in Swift, we need to include the Objective-C
581-
// version of CocoaLumberjack to avoid linking issues with other dependencies that
582-
// use it.
583-
//
584-
// Example:
585-
//
586-
// Undefined symbols for architecture arm64:
587-
// "_OBJC_CLASS_$_DDLog", referenced from:
588-
// in AppExtensionsService.o
589-
.product(name: "CocoaLumberjack", package: "CocoaLumberjack"),
590-
.product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack")
591-
]
592577
)
593578
]
594579
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import AppIntents
2+
3+
/// The widget configuration intent that lets the user pick which site a stats widget shows.
4+
///
5+
/// Replaces the SiriKit intent of the same name that was generated from `Sites.intentdefinition`.
6+
/// `intentClassName` and the `site` parameter name must not change: the system uses them to
7+
/// migrate widget configurations created with the SiriKit intent, and a mismatch silently resets
8+
/// users' widgets to the default site.
9+
///
10+
/// The localization keys are the app-bundle names of the identifiers Xcode generated for the
11+
/// legacy `.intentdefinition` ("gpCwrM", "ILcGmf"): GlotPress uploads them under the
12+
/// `ios-widget.` prefix, and the downloaded translations land in the app's
13+
/// `Localizable.strings` under those prefixed keys.
14+
///
15+
/// The keys must resolve in two bundles, because the OS picks a different one depending on
16+
/// version (verified on simulators): iOS 26 resolves the widget configuration UI's strings
17+
/// against the app bundle (whose GlotPress-managed `Localizable.strings` carries the prefixed
18+
/// keys in every locale), while iOS 17 resolves against the widget extension bundle, which
19+
/// ships static copies of the same prefixed keys in
20+
/// `Sources/JetpackStatsWidgets/Resources/<locale>.lproj/Localizable.strings`.
21+
/// A nested SPM package resource bundle is never consulted, so the strings cannot live in
22+
/// this package.
23+
public struct SelectSiteIntent: WidgetConfigurationIntent, CustomIntentMigratedAppIntent {
24+
public static let intentClassName = "SelectSiteIntent"
25+
26+
public static let title = LocalizedStringResource("ios-widget.gpCwrM", defaultValue: "Select Site")
27+
28+
// The legacy intent was ineligible for Siri suggestions; keep this
29+
// configuration-only intent out of Shortcuts and Spotlight the same way.
30+
public static let isDiscoverable = false
31+
32+
@Parameter(title: LocalizedStringResource("ios-widget.ILcGmf", defaultValue: "Site"))
33+
public var site: SiteEntity?
34+
35+
public init() {}
36+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import AppIntents
2+
import Foundation
3+
4+
/// A WordPress.com (or Jetpack-connected) site, as exposed to the system via App Intents.
5+
///
6+
/// The identifier is the site's WP.com ID encoded as a decimal string. It deliberately matches
7+
/// the `identifier` of the legacy SiriKit `Site` object so that widget configurations created
8+
/// before the App Intents migration keep resolving to the same site.
9+
///
10+
/// The "ios-widget.ILcGmf" localization key resolves against the app bundle on iOS 26 and
11+
/// the widget extension bundle on iOS 17; see `SelectSiteIntent` for the details.
12+
public struct SiteEntity: AppEntity {
13+
public static let typeDisplayRepresentation = TypeDisplayRepresentation(
14+
name: LocalizedStringResource("ios-widget.ILcGmf", defaultValue: "Site")
15+
)
16+
17+
public static var defaultQuery: SiteEntityQuery { SiteEntityQuery() }
18+
19+
public let id: String
20+
public let name: String
21+
public let domain: String?
22+
23+
public var displayRepresentation: DisplayRepresentation {
24+
if let domain {
25+
return DisplayRepresentation(title: "\(name)", subtitle: "\(domain)")
26+
}
27+
return DisplayRepresentation(title: "\(name)")
28+
}
29+
30+
init(siteID: Int, data: HomeWidgetTodayData) {
31+
self.id = String(siteID)
32+
self.name = data.siteName
33+
self.domain = URLComponents(string: data.url)?.host
34+
}
35+
36+
/// A placeholder for a configured site that cannot currently be resolved from the
37+
/// widget cache. Preserving the identifier keeps the user's widget configuration
38+
/// intact until the cache becomes readable again.
39+
init(unresolvedID: String) {
40+
self.id = unresolvedID
41+
self.name = unresolvedID
42+
self.domain = nil
43+
}
44+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import AppIntents
2+
import BuildSettingsKit
3+
import Foundation
4+
5+
/// Resolves `SiteEntity` values from the widget data cache in the app group container.
6+
///
7+
/// The site list mirrors what the stats widgets can display: the sites written by the app
8+
/// into the Today widget's cache.
9+
///
10+
/// The query deliberately provides no `defaultResult()`: a widget whose site parameter is
11+
/// nil follows the app's current default site dynamically (resolved by `WidgetDataReader`
12+
/// on every reload), matching the legacy SiriKit behavior. A default result would be baked
13+
/// into the configuration at widget-add time and pin the widget to that site forever.
14+
public struct SiteEntityQuery: EntityQuery {
15+
private let appGroup: String
16+
17+
public init() {
18+
self.init(appGroup: BuildSettings.current.appGroupName)
19+
}
20+
21+
init(appGroup: String) {
22+
self.appGroup = appGroup
23+
}
24+
25+
public func entities(for identifiers: [SiteEntity.ID]) async throws -> [SiteEntity] {
26+
let sites = cachedSites()
27+
// A configured widget re-resolves its site through this method on every reload.
28+
// An identifier missing from the cache (deleted mid-write, decode failure) must
29+
// stay attached to the configuration, or the widget silently falls back to the
30+
// default site. Keep unknown identifiers as placeholder entities.
31+
return identifiers.map { identifier in
32+
sites.first { $0.id == identifier } ?? SiteEntity(unresolvedID: identifier)
33+
}
34+
}
35+
36+
public func suggestedEntities() async throws -> [SiteEntity] {
37+
cachedSites()
38+
}
39+
40+
private func cachedSites() -> [SiteEntity] {
41+
guard let items = try? HomeWidgetCache<HomeWidgetTodayData>(appGroup: appGroup).read() else {
42+
return []
43+
}
44+
return
45+
items
46+
.map { SiteEntity(siteID: $0.key, data: $0.value) }
47+
.sorted { lhs, rhs in
48+
let lhsName = lhs.name.lowercased()
49+
let rhsName = rhs.name.lowercased()
50+
guard lhsName != rhsName else {
51+
return (lhs.domain?.lowercased() ?? "") < (rhs.domain?.lowercased() ?? "")
52+
}
53+
return lhsName < rhsName
54+
}
55+
}
56+
}

Modules/Sources/JetpackStatsWidgetsCore/WidgetStatsConfiguration.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ public enum WidgetStatsConfiguration {
55
public static let keychainServiceName = "JetpackTodayWidget"
66
public static let userDefaultsSiteIdKey = "JetpackHomeWidgetsSiteId"
77
public static let userDefaultsLoggedInKey = "JetpackHomeWidgetsLoggedIn"
8+
9+
/// The ID of the account's default site, as stored in the shared user defaults by the app.
10+
public static func defaultSiteID(appGroup: String) -> Int? {
11+
UserDefaults(suiteName: appGroup)?.object(forKey: userDefaultsSiteIdKey) as? Int
12+
}
813
public static let todayFilename = "JetpackHomeWidgetTodayData.plist"
914
public static let allTimeFilename = "JetpackHomeWidgetAllTimeData.plist"
1015
public static let thisWeekFilename = "JetpackHomeWidgetThisWeekData.plist"
@@ -21,7 +26,7 @@ public enum WidgetStatsConfiguration {
2126
case lockScreenAllTimePostsBestViews = "JetpackLockScreenWidgetAllTimePostsBestViews"
2227

2328
public var countKey: String {
24-
return rawValue + "Properties"
29+
rawValue + "Properties"
2530
}
2631
}
2732
}

Modules/Sources/XcodeSupport/XcodeTarget_Intents/Empty.swift

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import Foundation
2+
import Testing
3+
@testable import JetpackStatsWidgetsCore
4+
5+
@Suite
6+
struct SiteEntityQueryTests {
7+
8+
/// Uses the `xctest` app-group prefix so `HomeWidgetCache` writes to a temporary
9+
/// directory instead of a real security application group.
10+
private let appGroup =
11+
"\(HomeWidgetCache<HomeWidgetTodayData>.testAppGroupNamePrefix).site-entity-query.\(UUID().uuidString)"
12+
13+
private var cache: HomeWidgetCache<HomeWidgetTodayData> {
14+
HomeWidgetCache<HomeWidgetTodayData>(appGroup: appGroup)
15+
}
16+
17+
private var query: SiteEntityQuery {
18+
SiteEntityQuery(appGroup: appGroup)
19+
}
20+
21+
private func populateCache(_ sites: [HomeWidgetTodayData]) throws {
22+
try cache.write(items: Dictionary(uniqueKeysWithValues: sites.map { ($0.siteID, $0) }))
23+
}
24+
25+
private func makeSite(siteID: Int, name: String, url: String = "https://example.com") -> HomeWidgetTodayData {
26+
HomeWidgetTodayData(
27+
siteID: siteID,
28+
siteName: name,
29+
url: url,
30+
timeZone: .current,
31+
date: Date(),
32+
stats: TodayWidgetStats(views: 0, visitors: 0, likes: 0, comments: 0)
33+
)
34+
}
35+
36+
// MARK: - Entity mapping
37+
38+
@Test
39+
func entityFieldsMapFromWidgetData() async throws {
40+
try populateCache([
41+
makeSite(siteID: 1234567, name: "My Test Site", url: "https://mytestsite.wordpress.com/some/path")
42+
])
43+
44+
let entities = try await query.entities(for: ["1234567"])
45+
46+
let entity = try #require(entities.first)
47+
#expect(entity.id == "1234567")
48+
#expect(entity.name == "My Test Site")
49+
#expect(entity.domain == "mytestsite.wordpress.com")
50+
}
51+
52+
@Test
53+
func entityDomainIsNilForUnparseableURL() async throws {
54+
try populateCache([makeSite(siteID: 1, name: "Broken", url: "")])
55+
56+
let entities = try await query.entities(for: ["1"])
57+
58+
let entity = try #require(entities.first)
59+
#expect(entity.domain == nil)
60+
}
61+
62+
// MARK: - entities(for:)
63+
64+
@Test
65+
func entitiesForIdentifiersResolvesCachedSites() async throws {
66+
try populateCache([
67+
makeSite(siteID: 1, name: "Alpha"),
68+
makeSite(siteID: 2, name: "Beta"),
69+
makeSite(siteID: 3, name: "Gamma")
70+
])
71+
72+
let entities = try await query.entities(for: ["1", "3"])
73+
74+
#expect(entities.map(\.id) == ["1", "3"])
75+
#expect(entities.map(\.name) == ["Alpha", "Gamma"])
76+
}
77+
78+
/// A configured site must never silently resolve to nothing (the widget would
79+
/// fall back to the default site): identifiers missing from the cache are
80+
/// preserved as placeholder entities.
81+
@Test
82+
func entitiesForIdentifiersPreservesUnknownIdentifiers() async throws {
83+
try populateCache([makeSite(siteID: 1, name: "Alpha")])
84+
85+
let entities = try await query.entities(for: ["1", "999"])
86+
87+
#expect(entities.map(\.id) == ["1", "999"])
88+
#expect(entities.first?.name == "Alpha")
89+
#expect(entities.last?.name == "999")
90+
}
91+
92+
@Test
93+
func entitiesForIdentifiersPreservesIdentifiersWhenCacheIsMissing() async throws {
94+
let entities = try await query.entities(for: ["1"])
95+
96+
#expect(entities.map(\.id) == ["1"])
97+
}
98+
99+
// MARK: - suggestedEntities()
100+
101+
@Test
102+
func suggestedEntitiesReturnsAllCachedSitesSortedByName() async throws {
103+
try populateCache([
104+
makeSite(siteID: 3, name: "zebra"),
105+
makeSite(siteID: 1, name: "Apple"),
106+
makeSite(siteID: 2, name: "mango")
107+
])
108+
109+
let entities = try await query.suggestedEntities()
110+
111+
#expect(entities.map(\.name) == ["Apple", "mango", "zebra"])
112+
}
113+
114+
@Test
115+
func suggestedEntitiesBreaksNameTiesByDomain() async throws {
116+
try populateCache([
117+
makeSite(siteID: 1, name: "Same Name", url: "https://zzz.example.com"),
118+
makeSite(siteID: 2, name: "Same Name", url: "https://aaa.example.com")
119+
])
120+
121+
let entities = try await query.suggestedEntities()
122+
123+
#expect(entities.map(\.id) == ["2", "1"])
124+
}
125+
126+
@Test
127+
func suggestedEntitiesIsEmptyWhenCacheIsMissing() async throws {
128+
let entities = try await query.suggestedEntities()
129+
130+
#expect(entities.isEmpty)
131+
}
132+
}

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
-----
33
* [*] [internal] In-app updates: guard the flexible update notice against double-posting when two update checks race [#25666]
44
* [*] [build tooling] Add a String Catalog localization pipeline (plurals + build-free catalog generation) with a CI coverage gate [#25688]
5+
* [*] [internal] Stats widgets: migrate the site picker from SiriKit to App Intents [#25753]
56

67

78
27.0

Scripts/BuildPhases/GenerateCredentials.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ if [ -f "$WORDPRESS_SECRETS_FILE" ] && [[ " ${WORDPRESS_TARGETS[*]} " == *" $TAR
7171
exit 0
7272
fi
7373

74-
JETPACK_TARGETS=("Jetpack" "JetpackStatsWidgets" "JetpackShareExtension" "JetpackDraftActionExtension" "JetpackNotificationServiceExtension" "JetpackIntents")
74+
JETPACK_TARGETS=("Jetpack" "JetpackStatsWidgets" "JetpackShareExtension" "JetpackDraftActionExtension" "JetpackNotificationServiceExtension")
7575
# If the Jetpack Secrets are available and if we're building Jetpack use them
7676
if [ -f "$JETPACK_SECRETS_FILE" ] && [[ " ${JETPACK_TARGETS[*]} " == *" $TARGET_NAME "* ]]; then
7777
echo "Applying Jetpack Secrets"
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/* Static copies of the widget configuration strings, under the same `ios-widget.`-prefixed
2+
keys that GlotPress maintains in the app's Localizable.strings. iOS 17 resolves the widget
3+
configuration UI's App Intents strings against this extension bundle (newer iOS versions
4+
use the app bundle), so these files must ship here. To refresh after translations change,
5+
copy the `ios-widget.gpCwrM` / `ios-widget.ILcGmf` entries from
6+
WordPress/Resources/<locale>.lproj/Localizable.strings. */
7+
8+
/* This text is used when the user is configuring the iOS widget to suggest them to select the site to configure the widget for */
9+
"ios-widget.gpCwrM" = "تحديد موقع";
10+
11+
/* This text is used when the user is configuring the iOS widget, as a label for the dropdown to select the site to configure it for */
12+
"ios-widget.ILcGmf" = "الموقع";

0 commit comments

Comments
 (0)