Skip to content
Open
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
25 changes: 5 additions & 20 deletions Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,11 @@ let package = Package(
],
resources: [.process("Resources")]
),
.target(name: "JetpackStatsWidgetsCore", swiftSettings: [.swiftLanguageMode(.v5)]),
.target(
name: "JetpackStatsWidgetsCore",
dependencies: ["BuildSettingsKit"],
swiftSettings: [.swiftLanguageMode(.v5)]
),
.target(
name: "JetpackSocial",
dependencies: [
Expand Down Expand Up @@ -405,7 +409,6 @@ enum XcodeSupport {
name: "XcodeTarget_NotificationServiceExtension",
targets: ["XcodeTarget_NotificationServiceExtension"]
),
.library(name: "XcodeTarget_Intents", targets: ["XcodeTarget_Intents"]),
.library(name: "XcodeTarget_StatsWidget", targets: ["XcodeTarget_StatsWidget"])
]
}
Expand Down Expand Up @@ -571,24 +574,6 @@ enum XcodeSupport {
.product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"),
.product(name: "WordPressAPI", package: "wordpress-rs")
]
),
.xcodeTarget(
"XcodeTarget_Intents",
dependencies: [
"BuildSettingsKit",
"JetpackStatsWidgetsCore",
// Even though the extensions are all in Swift, we need to include the Objective-C
// version of CocoaLumberjack to avoid linking issues with other dependencies that
// use it.
//
// Example:
//
// Undefined symbols for architecture arm64:
// "_OBJC_CLASS_$_DDLog", referenced from:
// in AppExtensionsService.o
.product(name: "CocoaLumberjack", package: "CocoaLumberjack"),
.product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack")
]
)
]
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import AppIntents

/// The widget configuration intent that lets the user pick which site a stats widget shows.
///
/// Replaces the SiriKit intent of the same name that was generated from `Sites.intentdefinition`.
/// `intentClassName` and the `site` parameter name must not change: the system uses them to
/// migrate widget configurations created with the SiriKit intent, and a mismatch silently resets
/// users' widgets to the default site.
///
/// The localization keys are the app-bundle names of the identifiers Xcode generated for the
/// legacy `.intentdefinition` ("gpCwrM", "ILcGmf"): GlotPress uploads them under the
/// `ios-widget.` prefix, and the downloaded translations land in the app's
/// `Localizable.strings` under those prefixed keys.
///
/// The keys must resolve in two bundles, because the OS picks a different one depending on
/// version (verified on simulators): iOS 26 resolves the widget configuration UI's strings
/// against the app bundle (whose GlotPress-managed `Localizable.strings` carries the prefixed
/// keys in every locale), while iOS 17 resolves against the widget extension bundle, which
/// ships static copies of the same prefixed keys in
/// `Sources/JetpackStatsWidgets/Resources/<locale>.lproj/Localizable.strings`.
/// A nested SPM package resource bundle is never consulted, so the strings cannot live in
/// this package.
public struct SelectSiteIntent: WidgetConfigurationIntent, CustomIntentMigratedAppIntent {
public static let intentClassName = "SelectSiteIntent"

public static let title = LocalizedStringResource("ios-widget.gpCwrM", defaultValue: "Select Site")

// The legacy intent was ineligible for Siri suggestions; keep this
// configuration-only intent out of Shortcuts and Spotlight the same way.
public static let isDiscoverable = false

@Parameter(title: LocalizedStringResource("ios-widget.ILcGmf", defaultValue: "Site"))
public var site: SiteEntity?

public init() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import AppIntents
import Foundation

/// A WordPress.com (or Jetpack-connected) site, as exposed to the system via App Intents.
///
/// The identifier is the site's WP.com ID encoded as a decimal string. It deliberately matches
/// the `identifier` of the legacy SiriKit `Site` object so that widget configurations created
/// before the App Intents migration keep resolving to the same site.
///
/// The "ios-widget.ILcGmf" localization key resolves against the app bundle on iOS 26 and
/// the widget extension bundle on iOS 17; see `SelectSiteIntent` for the details.
public struct SiteEntity: AppEntity {
public static let typeDisplayRepresentation = TypeDisplayRepresentation(
name: LocalizedStringResource("ios-widget.ILcGmf", defaultValue: "Site")
)

public static var defaultQuery: SiteEntityQuery { SiteEntityQuery() }

public let id: String
public let name: String
public let domain: String?

public var displayRepresentation: DisplayRepresentation {
if let domain {
return DisplayRepresentation(title: "\(name)", subtitle: "\(domain)")
}
return DisplayRepresentation(title: "\(name)")
}

init(siteID: Int, data: HomeWidgetTodayData) {
self.id = String(siteID)
self.name = data.siteName
self.domain = URLComponents(string: data.url)?.host
}

/// A placeholder for a configured site that cannot currently be resolved from the
/// widget cache. Preserving the identifier keeps the user's widget configuration
/// intact until the cache becomes readable again.
init(unresolvedID: String) {
self.id = unresolvedID
self.name = unresolvedID
self.domain = nil
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import AppIntents
import BuildSettingsKit
import Foundation

/// Resolves `SiteEntity` values from the widget data cache in the app group container.
///
/// The site list mirrors what the stats widgets can display: the sites written by the app
/// into the Today widget's cache.
///
/// The query deliberately provides no `defaultResult()`: a widget whose site parameter is
/// nil follows the app's current default site dynamically (resolved by `WidgetDataReader`
/// on every reload), matching the legacy SiriKit behavior. A default result would be baked
/// into the configuration at widget-add time and pin the widget to that site forever.
public struct SiteEntityQuery: EntityQuery {
private let appGroup: String

public init() {
self.init(appGroup: BuildSettings.current.appGroupName)
}

init(appGroup: String) {
self.appGroup = appGroup
}

public func entities(for identifiers: [SiteEntity.ID]) async throws -> [SiteEntity] {
let sites = cachedSites()
// A configured widget re-resolves its site through this method on every reload.
// An identifier missing from the cache (deleted mid-write, decode failure) must
// stay attached to the configuration, or the widget silently falls back to the
// default site. Keep unknown identifiers as placeholder entities.
return identifiers.map { identifier in
sites.first { $0.id == identifier } ?? SiteEntity(unresolvedID: identifier)
}
}

public func suggestedEntities() async throws -> [SiteEntity] {
cachedSites()
}

private func cachedSites() -> [SiteEntity] {
guard let items = try? HomeWidgetCache<HomeWidgetTodayData>(appGroup: appGroup).read() else {
return []
}
return
items
.map { SiteEntity(siteID: $0.key, data: $0.value) }
.sorted { lhs, rhs in
let lhsName = lhs.name.lowercased()
let rhsName = rhs.name.lowercased()
guard lhsName != rhsName else {
return (lhs.domain?.lowercased() ?? "") < (rhs.domain?.lowercased() ?? "")
}
return lhsName < rhsName
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ public enum WidgetStatsConfiguration {
public static let keychainServiceName = "JetpackTodayWidget"
public static let userDefaultsSiteIdKey = "JetpackHomeWidgetsSiteId"
public static let userDefaultsLoggedInKey = "JetpackHomeWidgetsLoggedIn"

/// The ID of the account's default site, as stored in the shared user defaults by the app.
public static func defaultSiteID(appGroup: String) -> Int? {
UserDefaults(suiteName: appGroup)?.object(forKey: userDefaultsSiteIdKey) as? Int
}
public static let todayFilename = "JetpackHomeWidgetTodayData.plist"
public static let allTimeFilename = "JetpackHomeWidgetAllTimeData.plist"
public static let thisWeekFilename = "JetpackHomeWidgetThisWeekData.plist"
Expand All @@ -21,7 +26,7 @@ public enum WidgetStatsConfiguration {
case lockScreenAllTimePostsBestViews = "JetpackLockScreenWidgetAllTimePostsBestViews"

public var countKey: String {
return rawValue + "Properties"
rawValue + "Properties"
}
}
}
3 changes: 0 additions & 3 deletions Modules/Sources/XcodeSupport/XcodeTarget_Intents/Empty.swift

This file was deleted.

132 changes: 132 additions & 0 deletions Modules/Tests/JetpackStatsWidgetsCoreTests/SiteEntityQueryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import Foundation
import Testing
@testable import JetpackStatsWidgetsCore

@Suite
struct SiteEntityQueryTests {

/// Uses the `xctest` app-group prefix so `HomeWidgetCache` writes to a temporary
/// directory instead of a real security application group.
private let appGroup =
"\(HomeWidgetCache<HomeWidgetTodayData>.testAppGroupNamePrefix).site-entity-query.\(UUID().uuidString)"

private var cache: HomeWidgetCache<HomeWidgetTodayData> {
HomeWidgetCache<HomeWidgetTodayData>(appGroup: appGroup)
}

private var query: SiteEntityQuery {
SiteEntityQuery(appGroup: appGroup)
}

private func populateCache(_ sites: [HomeWidgetTodayData]) throws {
try cache.write(items: Dictionary(uniqueKeysWithValues: sites.map { ($0.siteID, $0) }))
}

private func makeSite(siteID: Int, name: String, url: String = "https://example.com") -> HomeWidgetTodayData {
HomeWidgetTodayData(
siteID: siteID,
siteName: name,
url: url,
timeZone: .current,
date: Date(),
stats: TodayWidgetStats(views: 0, visitors: 0, likes: 0, comments: 0)
)
}

// MARK: - Entity mapping

@Test
func entityFieldsMapFromWidgetData() async throws {
try populateCache([
makeSite(siteID: 1234567, name: "My Test Site", url: "https://mytestsite.wordpress.com/some/path")
])

let entities = try await query.entities(for: ["1234567"])

let entity = try #require(entities.first)
#expect(entity.id == "1234567")
#expect(entity.name == "My Test Site")
#expect(entity.domain == "mytestsite.wordpress.com")
}

@Test
func entityDomainIsNilForUnparseableURL() async throws {
try populateCache([makeSite(siteID: 1, name: "Broken", url: "")])

let entities = try await query.entities(for: ["1"])

let entity = try #require(entities.first)
#expect(entity.domain == nil)
}

// MARK: - entities(for:)

@Test
func entitiesForIdentifiersResolvesCachedSites() async throws {
try populateCache([
makeSite(siteID: 1, name: "Alpha"),
makeSite(siteID: 2, name: "Beta"),
makeSite(siteID: 3, name: "Gamma")
])

let entities = try await query.entities(for: ["1", "3"])

#expect(entities.map(\.id) == ["1", "3"])
#expect(entities.map(\.name) == ["Alpha", "Gamma"])
}

/// A configured site must never silently resolve to nothing (the widget would
/// fall back to the default site): identifiers missing from the cache are
/// preserved as placeholder entities.
@Test
func entitiesForIdentifiersPreservesUnknownIdentifiers() async throws {
try populateCache([makeSite(siteID: 1, name: "Alpha")])

let entities = try await query.entities(for: ["1", "999"])

#expect(entities.map(\.id) == ["1", "999"])
#expect(entities.first?.name == "Alpha")
#expect(entities.last?.name == "999")
}

@Test
func entitiesForIdentifiersPreservesIdentifiersWhenCacheIsMissing() async throws {
let entities = try await query.entities(for: ["1"])

#expect(entities.map(\.id) == ["1"])
}

// MARK: - suggestedEntities()

@Test
func suggestedEntitiesReturnsAllCachedSitesSortedByName() async throws {
try populateCache([
makeSite(siteID: 3, name: "zebra"),
makeSite(siteID: 1, name: "Apple"),
makeSite(siteID: 2, name: "mango")
])

let entities = try await query.suggestedEntities()

#expect(entities.map(\.name) == ["Apple", "mango", "zebra"])
}

@Test
func suggestedEntitiesBreaksNameTiesByDomain() async throws {
try populateCache([
makeSite(siteID: 1, name: "Same Name", url: "https://zzz.example.com"),
makeSite(siteID: 2, name: "Same Name", url: "https://aaa.example.com")
])

let entities = try await query.suggestedEntities()

#expect(entities.map(\.id) == ["2", "1"])
}

@Test
func suggestedEntitiesIsEmptyWhenCacheIsMissing() async throws {
let entities = try await query.suggestedEntities()

#expect(entities.isEmpty)
}
}
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,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]


27.0
Expand Down
2 changes: 1 addition & 1 deletion Scripts/BuildPhases/GenerateCredentials.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ if [ -f "$WORDPRESS_SECRETS_FILE" ] && [[ " ${WORDPRESS_TARGETS[*]} " == *" $TAR
exit 0
fi

JETPACK_TARGETS=("Jetpack" "JetpackStatsWidgets" "JetpackShareExtension" "JetpackDraftActionExtension" "JetpackNotificationServiceExtension" "JetpackIntents")
JETPACK_TARGETS=("Jetpack" "JetpackStatsWidgets" "JetpackShareExtension" "JetpackDraftActionExtension" "JetpackNotificationServiceExtension")
# If the Jetpack Secrets are available and if we're building Jetpack use them
if [ -f "$JETPACK_SECRETS_FILE" ] && [[ " ${JETPACK_TARGETS[*]} " == *" $TARGET_NAME "* ]]; then
echo "Applying Jetpack Secrets"
Expand Down
12 changes: 12 additions & 0 deletions Sources/JetpackStatsWidgets/Resources/ar.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/* Static copies of the widget configuration strings, under the same `ios-widget.`-prefixed
keys that GlotPress maintains in the app's Localizable.strings. iOS 17 resolves the widget
configuration UI's App Intents strings against this extension bundle (newer iOS versions
use the app bundle), so these files must ship here. To refresh after translations change,
copy the `ios-widget.gpCwrM` / `ios-widget.ILcGmf` entries from
WordPress/Resources/<locale>.lproj/Localizable.strings. */

/* This text is used when the user is configuring the iOS widget to suggest them to select the site to configure the widget for */
"ios-widget.gpCwrM" = "تحديد موقع";

/* 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 */
"ios-widget.ILcGmf" = "الموقع";
12 changes: 12 additions & 0 deletions Sources/JetpackStatsWidgets/Resources/bg.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/* Static copies of the widget configuration strings, under the same `ios-widget.`-prefixed
keys that GlotPress maintains in the app's Localizable.strings. iOS 17 resolves the widget
configuration UI's App Intents strings against this extension bundle (newer iOS versions
use the app bundle), so these files must ship here. To refresh after translations change,
copy the `ios-widget.gpCwrM` / `ios-widget.ILcGmf` entries from
WordPress/Resources/<locale>.lproj/Localizable.strings. */

/* This text is used when the user is configuring the iOS widget to suggest them to select the site to configure the widget for */
"ios-widget.gpCwrM" = "Select Site";

/* 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 */
"ios-widget.ILcGmf" = "Сайт";
12 changes: 12 additions & 0 deletions Sources/JetpackStatsWidgets/Resources/cs.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/* Static copies of the widget configuration strings, under the same `ios-widget.`-prefixed
keys that GlotPress maintains in the app's Localizable.strings. iOS 17 resolves the widget
configuration UI's App Intents strings against this extension bundle (newer iOS versions
use the app bundle), so these files must ship here. To refresh after translations change,
copy the `ios-widget.gpCwrM` / `ios-widget.ILcGmf` entries from
WordPress/Resources/<locale>.lproj/Localizable.strings. */

/* This text is used when the user is configuring the iOS widget to suggest them to select the site to configure the widget for */
"ios-widget.gpCwrM" = "Vybrat web";

/* 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 */
"ios-widget.ILcGmf" = "Web";
Loading