Skip to content

Commit 47d23c1

Browse files
committed
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.
1 parent 7ef6503 commit 47d23c1

7 files changed

Lines changed: 281 additions & 2 deletions

File tree

Modules/Package.swift

Lines changed: 5 additions & 1 deletion
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: [
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
}
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+
}

Tests/KeystoneTests/WordPressTest-Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
1111
<key>CFBundleInfoDictionaryVersion</key>
1212
<string>6.0</string>
13+
<key>CFBundleName</key>
14+
<string>${PRODUCT_NAME}</string>
1315
<key>CFBundlePackageType</key>
1416
<string>BNDL</string>
1517
<key>CFBundleShortVersionString</key>

0 commit comments

Comments
 (0)