Skip to content

Commit

Permalink
Check plugin updates (#24097)
Browse files Browse the repository at this point in the history
* Check plugin updates

* Remove redundnat `id` parameter
  • Loading branch information
crazytonyli authored Feb 26, 2025
1 parent 42497bd commit b94a037
Show file tree
Hide file tree
Showing 11 changed files with 148 additions and 35 deletions.
42 changes: 36 additions & 6 deletions Modules/Sources/WordPressCore/DataStore/InMemoryDataStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,37 @@ import Foundation
@preconcurrency import Combine

/// A `DataStore` type that stores data in memory.
public actor InMemoryDataStore<T: Sendable & Identifiable>: DataStore {
public actor InMemoryDataStore<T: Sendable & Identifiable>: DataStore where T.ID: Sendable {

public struct Query: Sendable {
enum Filter: Sendable {
case all
case id([T.ID])
case multi(@Sendable (T) -> Bool)
}

// TODO: Replace this with `Predicate` once iOS 17 becomes the minimal deployment target.
let filter: @Sendable (T) -> Bool
let filter: Filter
let sortBy: (any SortComparator<T>)?

public init(sortBy: (any SortComparator<T>)?, filter: @escaping @Sendable (T) -> Bool) {
init(sortBy: (any SortComparator<T>)?) {
self.sortBy = sortBy
self.filter = .all
}

init(sortBy: (any SortComparator<T>)?, filter: @escaping @Sendable (T) -> Bool) {
self.sortBy = sortBy
self.filter = filter
self.filter = .multi(filter)
}

init(id: T.ID) {
self.sortBy = nil
self.filter = .id([id])
}

init(sortBy: (any SortComparator<T>)?, ids: [T.ID]) {
self.sortBy = nil
self.filter = .id(ids)
}
}

Expand All @@ -32,7 +53,16 @@ public actor InMemoryDataStore<T: Sendable & Identifiable>: DataStore {
}

public func list(query: Query) async throws -> [T] {
let result = storage.values.filter(query.filter)
let result: [T]

switch query.filter {
case .all:
result = Array(storage.values)
case let .id(id):
result = id.compactMap { storage[$0] }
case let .multi(filter):
result = storage.values.filter(filter)
}

if let sortBy = query.sortBy {
return result.sorted(using: sortBy)
Expand All @@ -55,7 +85,7 @@ public actor InMemoryDataStore<T: Sendable & Identifiable>: DataStore {
}
}

public func store(_ data: [T]) async throws {
public func store<S: Sequence>(_ data: S) async throws where S.Element == T {
var updated = Set<T.ID>()
for item in data {
updated.insert(item.id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ public typealias InMemoryInstalledPluginDataStore = InMemoryDataStore<InstalledP

extension PluginDataStoreQuery {
public static var all: PluginDataStoreQuery {
.init(sortBy: KeyPathComparator(\.name)) { _ in true }
.init(sortBy: KeyPathComparator(\.name))
}

public static var active: PluginDataStoreQuery {
Expand Down
32 changes: 31 additions & 1 deletion Modules/Sources/WordPressCore/Plugins/PluginService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import WordPressAPI

public actor PluginService: PluginServiceProtocol {
private let client: WordPressClient
private let wordpressCoreVersion: String?
private let wpOrgClient: WordPressOrgApiClient
private let installedPluginDataStore = InMemoryInstalledPluginDataStore()
private let pluginDirectoryDataStore = InMemoryPluginDirectoryDataStore()
private let pluginDirectoryBrowserDataStore = CategorizedPluginInformationDataStore()
private let updateChecksDataStore = PluginUpdateChecksDataStore()
private let urlSession: URLSession

public init(client: WordPressClient) {
public init(client: WordPressClient, wordpressCoreVersion: String?) {
self.client = client
self.wordpressCoreVersion = wordpressCoreVersion
self.urlSession = URLSession(configuration: .ephemeral)
wpOrgClient = WordPressOrgApiClient(requestExecutor: urlSession)
}
Expand All @@ -21,6 +24,14 @@ public actor PluginService: PluginServiceProtocol {
let response = try await self.client.api.plugins.listWithViewContext(params: .init())
let plugins = response.data.map(InstalledPlugin.init(plugin:))
try await installedPluginDataStore.store(plugins)

// Check for plugin updates in the background. No need to block the current task from completion.
// We could move this call out and make the UI invoke it explicitly. However, currently the `checkPluginUpdates`
// function takes a REST API response type, which is not exposed as a public API of `PluginService`.
// We could refactor this API if we need to call `checkPluginUpdates` directly.
Task.detached {
try await self.checkPluginUpdates(plugins: response.data)
}
}

public func fetchPluginInformation(slug: PluginWpOrgDirectorySlug) async throws {
Expand All @@ -46,6 +57,10 @@ public actor PluginService: PluginServiceProtocol {
await pluginDirectoryDataStore.listStream(query: query)
}

public func newVersionUpdates(query: PluginUpdateChecksDataStoreQuery) async -> AsyncStream<Result<[UpdateCheckPluginInfo], Error>> {
await updateChecksDataStore.listStream(query: query)
}

public func resolveIconURL(of slug: PluginWpOrgDirectorySlug, plugin: PluginInformation?) async -> URL? {
// TODO: Cache the icon URL

Expand Down Expand Up @@ -160,4 +175,19 @@ private extension PluginService {

return nil
}

func checkPluginUpdates(plugins: [PluginWithViewContext]) async throws {
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),
plugins: plugins
)
let updateAvailable = updateCheck.plugins

// let updateAvailable = ["jetpack/jetpack": UpdateCheckPluginInfo(id: "w.org/plugins/jetpack", slug: PluginWpOrgDirectorySlug(slug: "jetpack"), plugin: PluginSlug(slug: "jetpack/jetpack"), newVersion: "14.3", url: "https://wordpress.org/plugins/jetpack/", package: "https://downloads.wordpress.org/plugin/jetpack.14.3.zip", icons: nil, banners: Banners(low: "", high: ""), bannersRtl: Banners(low: "", high: ""), requires: "6.6", tested: "6.7.2", requiresPhp: "7.2")]

try await updateChecksDataStore.delete(query: .all)
try await updateChecksDataStore.store(updateAvailable.values)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public protocol PluginServiceProtocol: Actor {

func installedPluginsUpdates(query: PluginDataStoreQuery) async -> AsyncStream<Result<[InstalledPlugin], Error>>
func pluginInformationUpdates(query: PluginDirectoryDataStoreQuery) async -> AsyncStream<Result<[PluginInformation], Error>>
func newVersionUpdates(query: PluginUpdateChecksDataStoreQuery) async -> AsyncStream<Result<[UpdateCheckPluginInfo], Error>>

func findInstalledPlugin(slug: PluginWpOrgDirectorySlug) async throws -> InstalledPlugin?

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation
import WordPressAPIInternal

extension UpdateCheckPluginInfo: @retroactive Identifiable {
public var id: PluginSlug { plugin }
}

public typealias PluginUpdateChecksDataStoreQuery = InMemoryDataStore<UpdateCheckPluginInfo>.Query
public typealias PluginUpdateChecksDataStore = InMemoryDataStore<UpdateCheckPluginInfo>

extension PluginUpdateChecksDataStoreQuery {
public static var all: Self {
.init(sortBy: nil)
}

public static func slug(_ slug: PluginSlug) -> Self {
.init(id: slug)
}
}
2 changes: 1 addition & 1 deletion Modules/Sources/WordPressCore/Users/UserDataStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ public typealias InMemoryUserDataStore = InMemoryDataStore<DisplayUser>

extension UserDataStoreQuery {
public static var all: UserDataStoreQuery {
.init(sortBy: KeyPathComparator(\.username)) { _ in true }
.init(sortBy: KeyPathComparator(\.username))
}

public static func id(_ id: T.ID) -> UserDataStoreQuery {
Expand Down
2 changes: 1 addition & 1 deletion Modules/Sources/WordPressCore/WordPressClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import WordPressAPI
public actor WordPressClient {

public let api: WordPressAPI
private let rootUrl: String
public let rootUrl: String

public init(api: WordPressAPI, rootUrl: ParsedUrl) {
self.api = api
Expand Down
20 changes: 15 additions & 5 deletions WordPress/Classes/Plugins/Views/InstalledPluginsListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,14 @@ import SwiftUI
import AsyncImageKit
import WordPressUI
import WordPressAPI
import WordPressAPIInternal
import WordPressCore

struct InstalledPluginsListView: View {
@StateObject private var viewModel: InstalledPluginsListViewModel

@State private var presentAddNewPlugin = false

init(client: WordPressClient) {
self.init(service: PluginService(client: client))
}

init(service: PluginServiceProtocol) {
_viewModel = StateObject(wrappedValue: .init(service: service))
}
Expand All @@ -28,7 +25,7 @@ struct InstalledPluginsListView: View {
Section {
ForEach(viewModel.displayingPlugins, id: \.self) { plugin in
ZStack {
PluginListItemView(plugin: plugin, viewModel: viewModel)
PluginListItemView(plugin: plugin, updateAvailable: viewModel.updateAvailable.index(forKey: plugin.slug) != nil, service: viewModel.service)
if let slug = plugin.possibleWpOrgDirectorySlug {
// Using `PluginListItemView` as `NavigationLink`'s content would show an disclosure
// indicator on the list cell, which looks a bit off with the ellipsis button on the
Expand Down Expand Up @@ -76,6 +73,9 @@ struct InstalledPluginsListView: View {
.task {
await viewModel.onAppear()
}
.task {
await viewModel.versionUpdate()
}
.task(id: viewModel.filter) {
await viewModel.performQuery()
}
Expand Down Expand Up @@ -118,6 +118,7 @@ final class InstalledPluginsListViewModel: ObservableObject {
@Published var isRefreshing: Bool = false
@Published var filter: PluginFilter = .all
@Published var displayingPlugins: [InstalledPlugin] = []
@Published var updateAvailable: [PluginSlug: UpdateCheckPluginInfo] = [:]
@Published var error: String? = nil

@Published var updating: Set<PluginSlug> = []
Expand Down Expand Up @@ -156,6 +157,15 @@ final class InstalledPluginsListViewModel: ObservableObject {
}
}

func versionUpdate() async {
for await update in await self.service.newVersionUpdates(query: .all) {
guard let updates = try? update.get() else { continue }
self.updateAvailable = updates.reduce(into: [:]) {
$0[$1.plugin] = $1
}
}
}

func uninstall(slug: PluginSlug) async {
self.updating.insert(slug)
defer { self.updating.remove(slug) }
Expand Down
25 changes: 16 additions & 9 deletions WordPress/Classes/Plugins/Views/PluginDetailsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ struct PluginDetailsView: View {

private let pluginInfo: BasicPluginInfo

@State var newVersion: UpdateCheckPluginInfo? = nil
@State private var tappedScreenshot: Screenshot? = nil
@StateObject var viewModel: WordPressPluginDetailViewModel
@State var isShowingSafariView = false
Expand Down Expand Up @@ -104,7 +103,7 @@ struct PluginDetailsView: View {
}
} else if let error = viewModel.operation?.errorMessage {
errorView(title: SharedStrings.Error.generic, message: error)
} else if let newVersion {
} else if let newVersion = viewModel.newVersion {
updateAvailableView(newVersion)
}

Expand All @@ -131,6 +130,9 @@ struct PluginDetailsView: View {
.task {
await viewModel.onAppear()
}
.task(id: viewModel.installed?.slug) {
await viewModel.versionUpdate()
}
.task(id: slug) {
await viewModel.performQuery()
}
Expand Down Expand Up @@ -217,12 +219,6 @@ struct PluginDetailsView: View {
}

Spacer()

Button(Strings.updateNow) {
// TODO: Handle update action
}
.buttonStyle(.bordered)
.tint(.blue)
}
.padding()
.background(Color(.systemGray6))
Expand Down Expand Up @@ -448,6 +444,7 @@ final class WordPressPluginDetailViewModel: ObservableObject {
@Published private(set) var isLoading = false
@Published private(set) var plugin: PluginInformation?
@Published private(set) var installed: InstalledPlugin?
@Published var newVersion: UpdateCheckPluginInfo?
@Published private(set) var error: String?

@Published private(set) fileprivate var operation: PluginOperationStatus?
Expand Down Expand Up @@ -492,6 +489,16 @@ final class WordPressPluginDetailViewModel: ObservableObject {
}
}

func versionUpdate() async {
if let slug = installed?.slug {
for await update in await service.newVersionUpdates(query: .slug(slug)) {
newVersion = (try? update.get().first)
}
} else {
newVersion = nil
}
}

func updatePluginStatus(_ plugin: InstalledPlugin, activated: Bool) async {
if let operation, !operation.isCompleted {
DDLogWarn("Can't update plugin status at the moment, because there is another operation in progress: \(operation)")
Expand Down Expand Up @@ -640,7 +647,7 @@ private enum Strings {
static func versionAvailable(_ version: String) -> String {
let format = NSLocalizedString(
"pluginDetails.update.versionAvailable",
value: "Version %@ is available",
value: "Version %@ is available. Please update it from your WordPress site dashboard.",
comment: "Message shown when a plugin update is available. The placeholder is the new version number"
)
return String(format: format, version)
Expand Down
34 changes: 24 additions & 10 deletions WordPress/Classes/Plugins/Views/PluginListItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,12 @@ struct PluginListItemView: View {
@State private var isShowingSafariView = false

let plugin: InstalledPlugin
let viewModel: InstalledPluginsListViewModel

// Add this computed property to avoid direct state access in the view body
private var isUpdating: Bool {
viewModel.updating.contains(plugin.slug)
}
let updateAvailable: Bool
let service: PluginServiceProtocol

var body: some View {
HStack(alignment: .top) {
PluginIconView(slug: plugin.possibleWpOrgDirectorySlug, service: viewModel.service)
PluginIconView(slug: plugin.possibleWpOrgDirectorySlug, service: service)

VStack(alignment: .leading, spacing: 4) {
Text(plugin.name.makePlainText())
Expand All @@ -33,9 +29,27 @@ struct PluginListItemView: View {
.font(.body)
.foregroundStyle(.primary)

Text(Strings.version(plugin.version))
.font(.caption)
.foregroundStyle(.secondary)
HStack(alignment: .center) {
Text(Strings.version(plugin.version))
.font(.caption)
.foregroundStyle(.secondary)

if updateAvailable {
Label {
Text("Update available")
} icon: {
Image(systemName: "arrow.triangle.2.circlepath")
.imageScale(.small)
}
.font(.caption.bold())
.foregroundStyle(.orange)
.labelStyle(.titleAndIcon)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.orange.opacity(0.15))
.clipShape(Capsule())
}
}
}

Spacer()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,13 @@ extension BlogDetailsViewController {
return
}

let wordpressCoreVersion = blog.version as? String

let viewController: UIViewController
if Feature.enabled(.pluginManagementOverhaul) {
let feature = NSLocalizedString("applicationPasswordRequired.feature.plugins", value: "Plugin Management", comment: "Feature name for managing plugins in the app")
let rootView = ApplicationPasswordRequiredView(blog: self.blog, localizedFeatureName: feature) { client in
let service = PluginService(client: client)
let service = PluginService(client: client, wordpressCoreVersion: wordpressCoreVersion)
InstalledPluginsListView(service: service)
}
viewController = UIHostingController(rootView: rootView)
Expand Down

0 comments on commit b94a037

Please sign in to comment.