Skip to content
6 changes: 3 additions & 3 deletions Modules/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ let package = Package(
.package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"),
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.13.0"),
// We can't use wordpress-rs branches nor commits here. Only tags work.
.package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20260114"),
.package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20260122"),
.package(
url: "https://github.com/Automattic/color-studio",
revision: "bf141adc75e2769eb469a3e095bdc93dc30be8de"
Expand Down
72 changes: 72 additions & 0 deletions Modules/Sources/WordPressCore/ApiCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Foundation
import WordPressAPI
import WordPressAPIInternal
import WordPressApiCache

extension WordPressApiCache {
static func bootstrap() -> WordPressApiCache? {
let instance: WordPressApiCache? = .onDiskCache() ?? .memoryCache()
instance?.startListeningForUpdates()
return instance
}

private static func onDiskCache() -> WordPressApiCache? {
let cacheURL: URL
do {
cacheURL = try FileManager.default
.url(for: .libraryDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
.appending(path: "app.sqlite")
} catch {
NSLog("Failed to create api cache file: \(error)")
return nil
}

if let cache = WordPressApiCache.onDiskCache(file: cacheURL) {
return cache
}

if FileManager.default.fileExists(at: cacheURL) {
do {
try FileManager.default.removeItem(at: cacheURL)

if let cache = WordPressApiCache.onDiskCache(file: cacheURL) {
return cache
}
} catch {
NSLog("Failed to delete sqlite database: \(error)")
}
}

return nil
}

private static func onDiskCache(file: URL) -> WordPressApiCache? {
let cache: WordPressApiCache
do {
cache = try WordPressApiCache(url: file)
} catch {
NSLog("Failed to create an instance: \(error)")
return nil
}

do {
_ = try cache.performMigrations()
} catch {
NSLog("Failed to migrate database: \(error)")
return nil
}

return cache
}

private static func memoryCache() -> WordPressApiCache? {
do {
let cache = try WordPressApiCache()
_ = try cache.performMigrations()
return cache
} catch {
NSLog("Failed to create memory cache: \(error)")
return nil
}
}
}
2 changes: 1 addition & 1 deletion Modules/Sources/WordPressCore/Plugins/PluginService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ private extension PluginService {
let updateCheck = try await wpOrgClient.checkPluginUpdates(
// Use a fairely recent version if the actual version is unknown.
wordpressCoreVersion: wordpressCoreVersion ?? "6.6",
siteUrl: ParsedUrl.parse(input: client.rootUrl),
siteUrl: ParsedUrl.parse(input: client.siteURL.absoluteString),
plugins: plugins
)
let updateAvailable = updateCheck.plugins
Expand Down
35 changes: 30 additions & 5 deletions Modules/Sources/WordPressCore/WordPressClient.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,38 @@
import Foundation
import WordPressAPI
import WordPressAPIInternal
import WordPressApiCache

public actor WordPressClient {

public final actor WordPressClient: Sendable {
public let siteURL: URL
public let api: WordPressAPI
public let rootUrl: String

public init(api: WordPressAPI, rootUrl: ParsedUrl) {
private var _cache: WordPressApiCache?
public var cache: WordPressApiCache? {
get {
if _cache == nil {
_cache = WordPressApiCache.bootstrap()
}
return _cache
}
}

private var _service: WpSelfHostedService?
public var service: WpSelfHostedService? {
get {
if _service == nil, let cache {
do {
_service = try api.createSelfHostedService(cache: cache)
} catch {
NSLog("Failed to create service: \(error)")
}
}
return _service
}
}

public init(api: WordPressAPI, siteURL: URL) {
self.api = api
self.rootUrl = rootUrl.url()
self.siteURL = siteURL
}
}
7 changes: 7 additions & 0 deletions Sources/WordPressData/Swift/Blog+Plans.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,11 @@
1031] // 2y Ecommerce Plan
.contains(planID?.intValue)
}

public var supportsCoreRESTAPI: Bool {
if isHostedAtWPcom {
return isAtomic()
}
return true
}
}
37 changes: 24 additions & 13 deletions Sources/WordPressData/Swift/Blog+SelfHosted.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,57 +184,68 @@ public extension WpApiApplicationPasswordDetails {
}
}

public enum WordPressSite {
case dotCom(siteId: Int, authToken: String)
case selfHosted(blogId: TaggedManagedObjectID<Blog>, apiRootURL: ParsedUrl, username: String, authToken: String)
public enum WordPressSite: Hashable {
case dotCom(siteURL: URL, siteId: Int, authToken: String)
case selfHosted(blogId: TaggedManagedObjectID<Blog>, siteURL: URL, apiRootURL: ParsedUrl, username: String, authToken: String)

public init(blog: Blog) throws {
let siteURL = try blog.getUrl()
// Directly access the site content when available.
if let restApiRootURL = blog.restApiRootURL,
let restApiRootURL = try? ParsedUrl.parse(input: restApiRootURL),
let username = blog.username,
let authToken = try? blog.getApplicationToken() {
self = .selfHosted(blogId: TaggedManagedObjectID(blog), apiRootURL: restApiRootURL, username: username, authToken: authToken)
self = .selfHosted(blogId: TaggedManagedObjectID(blog), siteURL: siteURL, apiRootURL: restApiRootURL, username: username, authToken: authToken)
} else if let account = blog.account, let siteId = blog.dotComID?.intValue {
// When the site is added via a WP.com account, access the site via WP.com
let authToken = try account.authToken ?? WPAccount.token(forUsername: account.username)
self = .dotCom(siteId: siteId, authToken: authToken)
self = .dotCom(siteURL: siteURL, siteId: siteId, authToken: authToken)
} else {
// In theory, this branch should never run, because the two if statements above should have covered all paths.
// But we'll keep it here as the fallback.
let url = try blog.restApiRootURL ?? blog.getUrl().appending(path: "wp-json").absoluteString
let apiRootURL = try ParsedUrl.parse(input: url)
self = .selfHosted(blogId: TaggedManagedObjectID(blog), apiRootURL: apiRootURL, username: try blog.getUsername(), authToken: try blog.getApplicationToken())
let url = try blog.getUrl()
let apiRootURL = try ParsedUrl.parse(input: blog.restApiRootURL ?? blog.getUrl().appending(path: "wp-json").absoluteString)
self = .selfHosted(blogId: TaggedManagedObjectID(blog), siteURL: url, apiRootURL: apiRootURL, username: try blog.getUsername(), authToken: try blog.getApplicationToken())
}
}

public var siteURL: URL {
switch self {
case let .dotCom(siteURL, _, _):
return siteURL
case let .selfHosted(_, siteURL, _, _, _):
return siteURL
}
}

public static func throughDotCom(blog: Blog) -> Self? {
guard
let siteURL = try? blog.getUrl(),
let account = blog.account,
let siteId = blog.dotComID?.intValue,
let authToken = try? account.authToken ?? WPAccount.token(forUsername: account.username)
else { return nil }

return .dotCom(siteId: siteId, authToken: authToken)
return .dotCom(siteURL: siteURL, siteId: siteId, authToken: authToken)
}

public func blog(in context: NSManagedObjectContext) throws -> Blog? {
switch self {
case let .dotCom(siteId, _):
case let .dotCom(_, siteId, _):
return try Blog.lookup(withID: siteId, in: context)
case let .selfHosted(blogId, _, _, _):
case let .selfHosted(blogId, _, _, _, _):
return try context.existingObject(with: blogId)
}
}

public func blogId(in coreDataStack: CoreDataStack) -> TaggedManagedObjectID<Blog>? {
switch self {
case let .dotCom(siteId, _):
case let .dotCom(_, siteId, _):
return coreDataStack.performQuery { context in
guard let blog = try? Blog.lookup(withID: siteId, in: context) else { return nil }
return TaggedManagedObjectID(blog)
}
case let .selfHosted(id, _, _, _):
case let .selfHosted(id, _, _, _, _):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) would be nice to wrap this associated values in a struct

return id
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ class UserListViewModelTests: XCTestCase {
override func setUp() async throws {
try await super.setUp()

let client = try WordPressClient(api: .init(urlSession: .shared, apiRootUrl: .parse(input: "https://example.com/wp-json"), authentication: .none), rootUrl: .parse(input: "https://example.com"))
let client = try WordPressClient(
api: .init(urlSession: .shared, apiRootUrl: .parse(input: "https://example.com/wp-json"), authentication: .none),
siteURL: URL(string: "https://example.com")!
)
service = UserService(client: client)
viewModel = await UserListViewModel(userService: service, currentUserId: 0)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ struct ApplicationPasswordRequiredView<Content: View>: View {
} else if showLoading {
ProgressView()
} else if let site {
builder(WordPressClient(site: site))
builder(WordPressClientFactory.shared.instance(for: site))
} else {
RestApiUpgradePrompt(localizedFeatureName: localizedFeatureName) {
Task {
Expand Down
49 changes: 39 additions & 10 deletions WordPress/Classes/Networking/WordPressClient.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import Foundation
import Combine
import WordPressAPI
Expand All @@ -6,12 +7,37 @@ import WordPressCore
import WordPressData
import WordPressShared

public final class WordPressClientFactory: Sendable {
public static let shared = WordPressClientFactory()

private let instances = OSAllocatedUnfairLock<[WordPressSite: WordPressClient]>(initialState: [:])
private init() {}

public func instance(for site: WordPressSite) -> WordPressClient {
instances.withLock { dict in
if let client = dict[site] {
return client
} else {
let client = WordPressClient(site: site)
dict[site] = client
return client
}
}
}

public func reset() {
instances.withLock { dict in
dict.removeAll()
}
}
}

extension WordPressClient {
static var requestedWithInvalidAuthenticationNotification: Foundation.Notification.Name {
.init("WordPressClient.requestedWithInvalidAuthenticationNotification")
}

init(site: WordPressSite) {
fileprivate convenience init(site: WordPressSite) {
// Currently, the app supports both account passwords and application passwords.
// When a site is initially signed in with an account password, WordPress login cookies are stored
// in `URLSession.shared`. After switching the site to application password authentication,
Expand All @@ -26,15 +52,18 @@ extension WordPressClient {
let provider = WpAuthenticationProvider.dynamic(
dynamicAuthenticationProvider: AutoUpdateAuthenticationProvider(site: site, coreDataStack: ContextManager.shared)
)
let siteURL: URL
let apiRootURL: ParsedUrl
let resolver: ApiUrlResolver
switch site {
case let .dotCom(siteId, _):
case let .dotCom(url, siteId, _):
siteURL = url
apiRootURL = try! ParsedUrl.parse(input: AppEnvironment.current.wordPressComApiBase.absoluteString)
resolver = WpComDotOrgApiUrlResolver(siteId: "\(siteId)", baseUrl: .custom(apiRootURL))
case let .selfHosted(_, url, _, _):
apiRootURL = url
resolver = WpOrgSiteApiUrlResolver(apiRootUrl: url)
case let .selfHosted(_, url, apiRoot, _, _):
siteURL = url
apiRootURL = apiRoot
resolver = WpOrgSiteApiUrlResolver(apiRootUrl: apiRoot)
}
let api = WordPressAPI(
urlSession: session,
Expand All @@ -43,7 +72,7 @@ extension WordPressClient {
authenticationProvider: provider,
appNotifier: notifier,
)
self.init(api: api, rootUrl: apiRootURL)
self.init(api: api, siteURL: siteURL)
}

func installJetpack() async throws -> PluginWithEditContext {
Expand Down Expand Up @@ -74,9 +103,9 @@ private final class AutoUpdateAuthenticationProvider: @unchecked Sendable, WpDyn
self.site = site
self.coreDataStack = coreDataStack
self.authentication = switch site {
case let .dotCom(_, authToken):
case let .dotCom(_, _, authToken):
.bearer(token: authToken)
case let .selfHosted(_, _, username, authToken):
case let .selfHosted(_, _, _, username, authToken):
.init(username: username, password: authToken)
}

Expand Down Expand Up @@ -144,13 +173,13 @@ private class AppNotifier: @unchecked Sendable, WpAppNotifier {
private extension WordPressSite {
func authentication(in context: NSManagedObjectContext) -> WpAuthentication {
switch self {
case let .dotCom(siteId, _):
case let .dotCom(_, siteId, _):
guard let blog = try? Blog.lookup(withID: siteId, in: context),
let token = blog.authToken else {
return WpAuthentication.none
}
return WpAuthentication.bearer(token: token)
case let .selfHosted(blogId, _, _, _):
case let .selfHosted(blogId, _, _, _, _):
guard let blog = try? context.existingObject(with: blogId),
let username = try? blog.getUsername(),
let password = try? blog.getApplicationToken()
Expand Down
Loading