-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
a338611
commit 04dc5bf
Showing
10 changed files
with
428 additions
and
3 deletions.
There are no files selected for viewing
38 changes: 38 additions & 0 deletions
38
Modules/Sources/WordPressCore/Plugins/InstalledPlugin.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import Foundation | ||
import WordPressAPI | ||
|
||
public struct InstalledPlugin: Equatable, Hashable, Identifiable, Sendable { | ||
public var slug: PluginSlug | ||
public var iconURL: URL? | ||
public var name: String | ||
public var version: String | ||
public var author: String | ||
public var shortDescription: String | ||
|
||
public init(slug: PluginSlug, iconURL: URL?, name: String, version: String, author: String, shortDescription: String) { | ||
self.slug = slug | ||
self.iconURL = iconURL | ||
self.name = name | ||
self.version = version | ||
self.author = author | ||
self.shortDescription = shortDescription | ||
} | ||
|
||
public init(plugin: PluginWithViewContext) { | ||
self.slug = plugin.plugin | ||
iconURL = nil | ||
name = plugin.name | ||
version = plugin.version | ||
author = plugin.author | ||
shortDescription = plugin.description.raw | ||
} | ||
|
||
public var id: String { | ||
slug.slug | ||
} | ||
|
||
public var possibleWpOrgDirectorySlug: PluginWpOrgDirectorySlug? { | ||
guard let maybeWpOrgSlug = slug.slug.split(separator: "/").first else { return nil } | ||
return .init(slug: String(maybeWpOrgSlug)) | ||
} | ||
} |
31 changes: 31 additions & 0 deletions
31
Modules/Sources/WordPressCore/Plugins/InstalledPluginDataStore.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
import Foundation | ||
@preconcurrency import Combine | ||
import WordPressShared | ||
import WordPressAPI | ||
|
||
public protocol InstalledPluginDataStore: DataStore where T == InstalledPlugin, Query == PluginDataStoreQuery { | ||
} | ||
|
||
public enum PluginDataStoreQuery: Equatable, Sendable { | ||
case all | ||
} | ||
|
||
public actor InMemoryInstalledPluginDataStore: InstalledPluginDataStore, InMemoryDataStore { | ||
public typealias T = InstalledPlugin | ||
|
||
public var storage: [T.ID: T] = [:] | ||
public let updates: PassthroughSubject<Set<T.ID>, Never> = .init() | ||
|
||
deinit { | ||
updates.send(completion: .finished) | ||
} | ||
|
||
public init() {} | ||
|
||
public func list(query: Query) throws -> [T] { | ||
switch query { | ||
case .all: | ||
return storage.values.sorted(using: KeyPathComparator(\.slug.slug)) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
import Foundation | ||
import UIKit | ||
import WordPressAPI | ||
|
||
public actor PluginService: PluginServiceProtocol { | ||
private let client: WordPressClient | ||
private let wpOrgClient: WordPressOrgApiClient | ||
private let installedPluginDataStore = InMemoryInstalledPluginDataStore() | ||
private let urlSession: URLSession | ||
|
||
public init(client: WordPressClient) { | ||
self.client = client | ||
self.urlSession = URLSession(configuration: .ephemeral) | ||
wpOrgClient = WordPressOrgApiClient(requestExecutor: urlSession) | ||
} | ||
|
||
public func fetchInstalledPlugins() async throws { | ||
let response = try await self.client.api.plugins.listWithViewContext(params: .init()) | ||
let plugins = response.data.map(InstalledPlugin.init(plugin:)) | ||
try await installedPluginDataStore.store(plugins) | ||
} | ||
|
||
public func streamInstalledPlugins() async -> AsyncStream<Result<[InstalledPlugin], Error>> { | ||
await installedPluginDataStore.listStream(query: .all) | ||
} | ||
|
||
public func resolveIconURL(of slug: PluginWpOrgDirectorySlug) async -> URL? { | ||
// TODO: Cache the icon URL | ||
|
||
if let url = await findIconFromPluginDirectory(slug: slug) { | ||
return url | ||
} | ||
|
||
if let url = await findIconFromSVNServer(slug: slug) { | ||
return url | ||
} | ||
|
||
return nil | ||
} | ||
|
||
} | ||
|
||
private extension PluginService { | ||
func findIconFromPluginDirectory(slug: PluginWpOrgDirectorySlug) async -> URL? { | ||
guard let pluginInfo = try? await wpOrgClient.pluginInformation(slug: slug) else { return nil } | ||
guard let icons = pluginInfo.icons else { return nil } | ||
|
||
let supportedFormat: Set<String> = ["png", "jpg", "jpeg", "gif"] | ||
let urls: [String?] = [icons.default, icons.high, icons.low] | ||
for string in urls { | ||
guard let string, let url = URL(string: string) else { continue } | ||
|
||
if supportedFormat.contains(url.pathExtension) { | ||
return url | ||
} | ||
} | ||
|
||
return nil | ||
} | ||
|
||
func findIconFromSVNServer(slug: PluginWpOrgDirectorySlug) async -> URL? { | ||
let url = URL(string: "https://ps.w.org")! | ||
.appending(path: slug.slug) | ||
.appending(path: "assets") | ||
let size = [256, 128] | ||
let supportedFormat = ["png", "jpg", "jpeg", "gif"] | ||
let candidates = zip(size, supportedFormat).map { size, format in | ||
url.appending(path: "icon-\(size)x\(size).\(format)") | ||
} | ||
|
||
for url in candidates { | ||
var request = URLRequest(url: url) | ||
request.httpMethod = "HEAD" | ||
|
||
if let (_, response) = try? await urlSession.data(for: request), | ||
(response as? HTTPURLResponse)?.statusCode == 200 { | ||
return url | ||
} | ||
} | ||
|
||
return nil | ||
} | ||
} |
19 changes: 19 additions & 0 deletions
19
Modules/Sources/WordPressCore/Plugins/PluginServiceProtocol.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import Foundation | ||
import WordPressAPI | ||
|
||
public protocol PluginServiceProtocol: Actor { | ||
|
||
func fetchInstalledPlugins() async throws | ||
|
||
func streamInstalledPlugins() async -> AsyncStream<Result<[InstalledPlugin], Error>> | ||
|
||
func resolveIconURL(of slug: PluginWpOrgDirectorySlug) async -> URL? | ||
|
||
} | ||
|
||
extension PluginServiceProtocol { | ||
public func resolveIconURL(of plugin: InstalledPlugin) async -> URL? { | ||
guard let slug = plugin.possibleWpOrgDirectorySlug else { return nil } | ||
return await resolveIconURL(of: slug) | ||
} | ||
} |
101 changes: 101 additions & 0 deletions
101
WordPress/Classes/Plugins/Views/InstalledPluginsListView.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
import SwiftUI | ||
import AsyncImageKit | ||
import WordPressUI | ||
import WordPressAPI | ||
import WordPressCore | ||
|
||
struct InstalledPluginsListView: View { | ||
@StateObject private var viewModel: InstalledPluginsListViewModel | ||
|
||
init(client: WordPressClient) { | ||
self.init(service: PluginService(client: client)) | ||
} | ||
|
||
init(service: PluginServiceProtocol) { | ||
_viewModel = StateObject(wrappedValue: .init(service: service)) | ||
} | ||
|
||
var body: some View { | ||
ZStack { | ||
if let error = viewModel.error { | ||
EmptyStateView(error, systemImage: "exclamationmark.triangle.fill") | ||
} else if viewModel.isRefreshing && viewModel.plugins.isEmpty { | ||
Label { Text(Strings.loading) } icon: { ProgressView() } | ||
} else { | ||
List { | ||
Section { | ||
ForEach(viewModel.plugins, id: \.self) { plugin in | ||
PluginListItemView( | ||
plugin: plugin, | ||
iconResolver: PluginIconResolver( | ||
slug: plugin.possibleWpOrgDirectorySlug, | ||
service: viewModel.service | ||
) | ||
) | ||
} | ||
} | ||
.listSectionSeparator(.hidden, edges: .top) | ||
} | ||
.listStyle(.plain) | ||
.refreshable(action: viewModel.refreshItems) | ||
} | ||
} | ||
.navigationTitle(Strings.title) | ||
.task(id: 0) { | ||
await viewModel.onAppear() | ||
} | ||
.task(id: 1) { | ||
await viewModel.performQuery() | ||
} | ||
} | ||
|
||
private enum Strings { | ||
static let title: String = NSLocalizedString("site.plugins.title", value: "Plugins", comment: "Installed plugins list title") | ||
static let loading: String = NSLocalizedString("site.plugins.loading", value: "Loading installed plugins…", comment: "Message displayed when fetching installed plugins from the site") | ||
static let noPluginInstalled: String = NSLocalizedString("site.plugins.noInstalledPlugins", value: "You haven't installed any plugins yet", comment: "No installed plugins message") | ||
} | ||
} | ||
|
||
@MainActor | ||
final class InstalledPluginsListViewModel: ObservableObject { | ||
let service: PluginServiceProtocol | ||
private var initialLoad = false | ||
|
||
@Published var isRefreshing: Bool = false | ||
@Published var plugins: [InstalledPlugin] = [] | ||
@Published var error: String? = nil | ||
|
||
init(service: PluginServiceProtocol) { | ||
self.service = service | ||
} | ||
|
||
func onAppear() async { | ||
if !initialLoad { | ||
initialLoad = true | ||
await refreshItems() | ||
} | ||
} | ||
|
||
@Sendable | ||
func refreshItems() async { | ||
isRefreshing = true | ||
defer { isRefreshing = false } | ||
|
||
do { | ||
try await self.service.fetchInstalledPlugins() | ||
} catch { | ||
self.error = (error as? WpApiError)?.errorMessage ?? error.localizedDescription | ||
} | ||
} | ||
|
||
func performQuery() async { | ||
for await update in await self.service.streamInstalledPlugins() { | ||
switch update { | ||
case let .success(plugins): | ||
self.plugins = plugins | ||
case let .failure(error): | ||
self.error = (error as? WpApiError)?.errorMessage ?? error.localizedDescription | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import Foundation | ||
import AsyncImageKit | ||
import WordPressAPI | ||
import WordPressCore | ||
|
||
struct PluginIconResolver: ImageURLResolver { | ||
let slug: PluginWpOrgDirectorySlug? | ||
weak var service: PluginServiceProtocol? | ||
|
||
var id: String? { | ||
slug?.slug | ||
} | ||
|
||
func imageURL() async -> URL? { | ||
guard let slug else { return nil } | ||
|
||
return await service?.resolveIconURL(of: slug) | ||
} | ||
} |
Oops, something went wrong.