572 lines
20 KiB
Swift
572 lines
20 KiB
Swift
import SwiftUI
|
|
import MacToolsPluginKit
|
|
|
|
struct PluginManagementSettingsView: View {
|
|
@ObservedObject var pluginHost: PluginHost
|
|
var appRelauncher: any AppRelaunching = AppRelauncher()
|
|
|
|
@State private var alertMessage: String?
|
|
@State private var activeOperationID: String?
|
|
@State private var searchText: String = ""
|
|
@State private var selectedFilter: PluginCategoryFilter = .all
|
|
@AppStorage(PluginMarketplaceSortMode.userDefaultsKey) private var sortMode = PluginMarketplaceSortMode.notInstalledFirst
|
|
@State private var bulkUpdateProgressText: String?
|
|
@State private var bulkUpdateProgressOpacity: Double = 0
|
|
@State private var bulkUpdateProgressHideTask: Task<Void, Never>?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) {
|
|
header
|
|
|
|
if pluginHost.pluginManagementItems.isEmpty {
|
|
ContentUnavailableView(
|
|
AppL10n.plugins("plugin.marketplace.empty.title", defaultValue: "暂无插件"),
|
|
systemImage: "shippingbox",
|
|
description: Text(AppL10n.plugins(
|
|
"plugin.marketplace.empty.description",
|
|
defaultValue: "刷新插件列表后,可以在这里安装、更新和卸载。"
|
|
))
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
PluginFilterBarView(
|
|
searchText: $searchText,
|
|
selectedFilter: $selectedFilter,
|
|
countsByFilter: countsByFilter
|
|
)
|
|
|
|
marketplaceSortPicker
|
|
}
|
|
|
|
if filteredItems.isEmpty {
|
|
ContentUnavailableView(
|
|
AppL10n.plugins("plugin.filter.empty.title", defaultValue: "未找到匹配的插件"),
|
|
systemImage: "magnifyingglass",
|
|
description: Text(AppL10n.plugins("plugin.filter.empty.description", defaultValue: "尝试调整关键字或切换分类。"))
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 10) {
|
|
ForEach(filteredItems) { item in
|
|
PluginManagementRow(
|
|
item: item,
|
|
isBusy: activeOperationID == item.id
|
|
|| pluginHost.automaticPluginUpdateStatus.isUpdatingPlugin(id: item.id),
|
|
isInteractionDisabled: activeOperationID != nil
|
|
|| pluginHost.automaticPluginUpdateStatus.isActive,
|
|
onInstall: { runOperation(id: item.id) { try await pluginHost.installPluginFromCatalog(pluginID: item.id) } },
|
|
onUpdate: { runOperation(id: item.id) { try await pluginHost.updatePluginFromCatalog(pluginID: item.id) } },
|
|
onUninstall: { uninstall(item) },
|
|
onRelaunch: { appRelauncher.relaunch() }
|
|
)
|
|
}
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(PluginSettingsTheme.Spacing.pagePadding)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.background(SettingsStyle.contentBackground)
|
|
.task {
|
|
guard !pluginHost.automaticPluginUpdateStatus.isActive else {
|
|
return
|
|
}
|
|
|
|
await pluginHost.refreshPluginCatalog()
|
|
}
|
|
.alert(
|
|
AppL10n.plugins("plugin.marketplace.operationFailed.title", defaultValue: "插件操作失败"),
|
|
isPresented: Binding(
|
|
get: { alertMessage != nil },
|
|
set: { isPresented in
|
|
if !isPresented {
|
|
alertMessage = nil
|
|
}
|
|
}
|
|
)
|
|
) {
|
|
Button(AppL10n.settings("common.ok", defaultValue: "好"), role: .cancel) {}
|
|
} message: {
|
|
Text(alertMessage ?? "")
|
|
}
|
|
.onAppear {
|
|
syncAutomaticBulkUpdateProgress(pluginHost.automaticPluginUpdateStatus)
|
|
}
|
|
.onChange(of: pluginHost.automaticPluginUpdateStatus) { _, status in
|
|
syncAutomaticBulkUpdateProgress(status)
|
|
}
|
|
}
|
|
|
|
private var filteredItems: [PluginManagementItem] {
|
|
let filtered = pluginHost.pluginManagementItems.filter {
|
|
PluginListFilter.matches(managementItem: $0, query: searchText, filter: selectedFilter)
|
|
}
|
|
return PluginMarketplaceSortMode.sorted(filtered, by: sortMode)
|
|
}
|
|
|
|
private var countsByFilter: [PluginCategoryFilter: Int] {
|
|
PluginListFilter.countsByFilter(
|
|
managementItems: pluginHost.pluginManagementItems,
|
|
query: searchText
|
|
)
|
|
}
|
|
|
|
private var marketplaceSortPicker: some View {
|
|
let sortLabel = AppL10n.plugins("plugin.marketplace.sort.label", defaultValue: "排序")
|
|
let sortHelp = AppL10n.plugins(
|
|
"plugin.marketplace.sort.help",
|
|
defaultValue: "调整市场列表排序,不影响菜单栏功能顺序。"
|
|
)
|
|
|
|
return HStack(spacing: 8) {
|
|
Spacer(minLength: 0)
|
|
|
|
Text(sortLabel)
|
|
.font(PluginSettingsTheme.Typography.rowDescription)
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityHidden(true)
|
|
|
|
Picker(sortLabel, selection: $sortMode) {
|
|
ForEach(PluginMarketplaceSortMode.allCases) { mode in
|
|
Text(mode.displayName)
|
|
.tag(mode)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
.labelsHidden()
|
|
.fixedSize()
|
|
.help(sortHelp)
|
|
.accessibilityLabel(sortLabel)
|
|
.accessibilityValue(sortMode.displayName)
|
|
.accessibilityHint(sortHelp)
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(alignment: .center, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(AppL10n.plugins("plugin.marketplace.title", defaultValue: "市场"))
|
|
.font(PluginSettingsTheme.Typography.pageTitle)
|
|
|
|
HStack(spacing: 8) {
|
|
Text(pluginHost.pluginCatalogStatus.title)
|
|
.font(PluginSettingsTheme.Typography.pageDescription)
|
|
.foregroundStyle(.secondary)
|
|
|
|
if let lastUpdatedAt = pluginHost.pluginCatalogStatus.lastUpdatedAt {
|
|
Text(lastUpdatedAt, style: .time)
|
|
.font(PluginSettingsTheme.Typography.pageDescription)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Text(pluginHost.pluginCatalogStatus.detailText)
|
|
.font(PluginSettingsTheme.Typography.rowDescription)
|
|
.foregroundStyle(pluginHost.pluginCatalogStatus.errorMessage == nil ? Color.secondary : Color.orange)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
if shouldShowBulkUpdateControls {
|
|
bulkUpdateProgressLabel
|
|
|
|
Button {
|
|
runBulkUpdate()
|
|
} label: {
|
|
PluginManagementActionLabel(
|
|
title: AppL10n.plugins("plugin.marketplace.updateAll", defaultValue: "全部更新"),
|
|
busyTitle: AppL10n.plugins("plugin.marketplace.updating", defaultValue: "更新中"),
|
|
isBusy: isBulkPluginUpdateBusy,
|
|
width: 74
|
|
)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(
|
|
activeOperationID != nil
|
|
|| !hasAvailablePluginUpdates
|
|
|| pluginHost.pluginCatalogStatus.isRefreshing
|
|
|| pluginHost.automaticPluginUpdateStatus.isActive
|
|
)
|
|
}
|
|
|
|
Button {
|
|
runOperation(id: "catalog.refresh") {
|
|
await pluginHost.refreshPluginCatalog()
|
|
}
|
|
} label: {
|
|
Label(AppL10n.plugins("plugin.marketplace.refresh", defaultValue: "刷新列表"), systemImage: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(
|
|
activeOperationID != nil
|
|
|| pluginHost.pluginCatalogStatus.isRefreshing
|
|
|| pluginHost.automaticPluginUpdateStatus.isActive
|
|
)
|
|
}
|
|
}
|
|
|
|
private var hasAvailablePluginUpdates: Bool {
|
|
pluginHost.pluginManagementItems.contains { $0.canUpdate }
|
|
}
|
|
|
|
private var shouldShowBulkUpdateControls: Bool {
|
|
hasAvailablePluginUpdates || isBulkPluginUpdateBusy || bulkUpdateProgressText != nil
|
|
}
|
|
|
|
private var isBulkPluginUpdateBusy: Bool {
|
|
activeOperationID == "catalog.updateAll"
|
|
|| pluginHost.automaticPluginUpdateStatus.phase == .updating
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var bulkUpdateProgressLabel: some View {
|
|
if let bulkUpdateProgressText {
|
|
Text(bulkUpdateProgressText)
|
|
.font(PluginSettingsTheme.Typography.rowDescription)
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
.lineLimit(1)
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
.opacity(bulkUpdateProgressOpacity)
|
|
}
|
|
}
|
|
|
|
private func runOperation(id: String, _ operation: @escaping () async throws -> Void) {
|
|
guard activeOperationID == nil,
|
|
!pluginHost.automaticPluginUpdateStatus.isActive
|
|
else {
|
|
return
|
|
}
|
|
|
|
activeOperationID = id
|
|
hideBulkUpdateProgressText()
|
|
|
|
Task {
|
|
do {
|
|
try await operation()
|
|
} catch {
|
|
alertMessage = error.localizedDescription
|
|
}
|
|
|
|
activeOperationID = nil
|
|
}
|
|
}
|
|
|
|
private func runBulkUpdate() {
|
|
guard activeOperationID == nil,
|
|
!pluginHost.automaticPluginUpdateStatus.isActive
|
|
else {
|
|
return
|
|
}
|
|
|
|
activeOperationID = "catalog.updateAll"
|
|
bulkUpdateProgressHideTask?.cancel()
|
|
showBulkUpdateProgressText(
|
|
AppL10n.pluginsFormat(
|
|
"plugin.marketplace.bulkUpdate.progressFormat",
|
|
defaultValue: "已完成 %d/%d",
|
|
0,
|
|
availablePluginUpdateCount
|
|
)
|
|
)
|
|
|
|
Task {
|
|
do {
|
|
try await pluginHost.updateAvailablePluginsFromCatalog { progress in
|
|
showBulkUpdateProgressText(
|
|
AppL10n.pluginsFormat(
|
|
"plugin.marketplace.bulkUpdate.progressFormat",
|
|
defaultValue: "已完成 %d/%d",
|
|
progress.completedCount,
|
|
progress.totalCount
|
|
)
|
|
)
|
|
}
|
|
|
|
activeOperationID = nil
|
|
showBulkUpdateProgressText(
|
|
AppL10n.plugins("plugin.marketplace.bulkUpdate.completed", defaultValue: "更新完成")
|
|
)
|
|
scheduleBulkUpdateProgressFadeOut()
|
|
} catch {
|
|
activeOperationID = nil
|
|
alertMessage = error.localizedDescription
|
|
hideBulkUpdateProgressText()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func showBulkUpdateProgressText(_ text: String) {
|
|
bulkUpdateProgressHideTask?.cancel()
|
|
bulkUpdateProgressText = text
|
|
|
|
withAnimation(.easeOut(duration: 0.15)) {
|
|
bulkUpdateProgressOpacity = 1
|
|
}
|
|
}
|
|
|
|
private func scheduleBulkUpdateProgressFadeOut() {
|
|
bulkUpdateProgressHideTask?.cancel()
|
|
bulkUpdateProgressHideTask = Task {
|
|
withAnimation(.easeOut(duration: 2)) {
|
|
bulkUpdateProgressOpacity = 0
|
|
}
|
|
|
|
try? await Task.sleep(for: .seconds(2))
|
|
|
|
guard !Task.isCancelled else {
|
|
return
|
|
}
|
|
|
|
bulkUpdateProgressText = nil
|
|
}
|
|
}
|
|
|
|
private func syncAutomaticBulkUpdateProgress(_ status: PluginAutomaticUpdateStatus) {
|
|
switch status.phase {
|
|
case .updating:
|
|
guard !status.pluginIDs.isEmpty else {
|
|
return
|
|
}
|
|
|
|
showBulkUpdateProgressText(
|
|
status.message
|
|
?? AppL10n.pluginsFormat(
|
|
"plugin.marketplace.bulkUpdate.progressFormat",
|
|
defaultValue: "已完成 %d/%d",
|
|
0,
|
|
status.pluginIDs.count
|
|
)
|
|
)
|
|
case .completed:
|
|
guard bulkUpdateProgressText != nil, !status.pluginIDs.isEmpty else {
|
|
return
|
|
}
|
|
|
|
showBulkUpdateProgressText(
|
|
AppL10n.plugins("plugin.marketplace.bulkUpdate.completed", defaultValue: "更新完成")
|
|
)
|
|
scheduleBulkUpdateProgressFadeOut()
|
|
case .failed:
|
|
hideBulkUpdateProgressText()
|
|
case .idle, .checking:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func hideBulkUpdateProgressText() {
|
|
bulkUpdateProgressHideTask?.cancel()
|
|
|
|
withAnimation(.easeOut(duration: 0.2)) {
|
|
bulkUpdateProgressOpacity = 0
|
|
}
|
|
|
|
bulkUpdateProgressText = nil
|
|
}
|
|
|
|
private func uninstall(_ item: PluginManagementItem) {
|
|
guard activeOperationID == nil else {
|
|
return
|
|
}
|
|
|
|
do {
|
|
try pluginHost.uninstallDynamicPlugin(pluginID: item.id)
|
|
} catch {
|
|
alertMessage = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private var availablePluginUpdateCount: Int {
|
|
pluginHost.pluginManagementItems.filter(\.canUpdate).count
|
|
}
|
|
}
|
|
|
|
private struct PluginManagementRow: View {
|
|
let item: PluginManagementItem
|
|
let isBusy: Bool
|
|
let isInteractionDisabled: Bool
|
|
let onInstall: () -> Void
|
|
let onUpdate: () -> Void
|
|
let onUninstall: () -> Void
|
|
let onRelaunch: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(alignment: .center, spacing: 14) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.fill(statusColor.opacity(0.14))
|
|
|
|
Image(systemName: statusImageName)
|
|
.font(PluginSettingsTheme.Typography.pageDescription.weight(.semibold))
|
|
.foregroundStyle(statusColor)
|
|
}
|
|
.frame(width: PluginSettingsTheme.Size.metricIcon, height: PluginSettingsTheme.Size.metricIcon)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 8) {
|
|
Text(item.title)
|
|
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
|
|
|
|
PluginReleaseChannelBadge(releaseChannel: item.releaseChannel)
|
|
|
|
Text(item.version)
|
|
.font(PluginSettingsTheme.Typography.statusBadge)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
detail
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
if let visibleStatusText {
|
|
Text(visibleStatusText)
|
|
.font(PluginSettingsTheme.Typography.secondaryLabel.weight(.semibold))
|
|
.foregroundStyle(statusColor)
|
|
.frame(width: 58, alignment: .trailing)
|
|
}
|
|
|
|
actionButtons
|
|
}
|
|
.padding(PluginSettingsTheme.Spacing.cardContent)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.pluginSettingsCardBackground(.host)
|
|
}
|
|
|
|
private var detail: some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
Text(item.detailText)
|
|
.font(PluginSettingsTheme.Typography.rowDescription)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
.truncationMode(.middle)
|
|
|
|
if item.requiresRelaunchAction {
|
|
Button(AppL10n.plugins("plugin.marketplace.relaunchNow", defaultValue: "立即重启"), action: onRelaunch)
|
|
.font(PluginSettingsTheme.Typography.rowDescription)
|
|
.buttonStyle(.link)
|
|
.disabled(isInteractionDisabled)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var actionButtons: some View {
|
|
if item.canInstall {
|
|
Button(action: onInstall) {
|
|
PluginManagementActionLabel(
|
|
title: AppL10n.plugins("plugin.marketplace.install", defaultValue: "安装"),
|
|
busyTitle: AppL10n.plugins("plugin.marketplace.installing", defaultValue: "安装中"),
|
|
isBusy: isBusy,
|
|
width: actionButtonLabelWidth
|
|
)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(isInteractionDisabled)
|
|
}
|
|
|
|
if item.canUpdate {
|
|
Button(action: onUpdate) {
|
|
PluginManagementActionLabel(
|
|
title: AppL10n.plugins("plugin.marketplace.update", defaultValue: "更新"),
|
|
busyTitle: AppL10n.plugins("plugin.marketplace.updating", defaultValue: "更新中"),
|
|
isBusy: isBusy,
|
|
width: actionButtonLabelWidth
|
|
)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(isInteractionDisabled)
|
|
}
|
|
|
|
if item.canUninstall {
|
|
Button(role: .destructive, action: onUninstall) {
|
|
Text(AppL10n.plugins("plugin.marketplace.uninstall", defaultValue: "卸载"))
|
|
.frame(width: actionButtonLabelWidth)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(isInteractionDisabled)
|
|
}
|
|
}
|
|
|
|
private var visibleStatusText: String? {
|
|
switch item.state {
|
|
case .available, .enabled, .disabled:
|
|
return nil
|
|
case .localDevelopment, .updateAvailable, .restartRequired, .failed, .incompatible, .revoked:
|
|
return item.statusText
|
|
}
|
|
}
|
|
|
|
private var actionButtonLabelWidth: CGFloat {
|
|
64
|
|
}
|
|
|
|
private var statusColor: Color {
|
|
switch item.state {
|
|
case .available, .localDevelopment:
|
|
return .blue
|
|
case .enabled:
|
|
return .green
|
|
case .disabled:
|
|
return .secondary
|
|
case .updateAvailable, .restartRequired:
|
|
return .accentColor
|
|
case .failed, .incompatible, .revoked:
|
|
return .orange
|
|
}
|
|
}
|
|
|
|
private var statusImageName: String {
|
|
switch item.state {
|
|
case .available:
|
|
return "arrow.down.circle.fill"
|
|
case .localDevelopment:
|
|
return "hammer.circle.fill"
|
|
case .enabled:
|
|
return "checkmark.seal.fill"
|
|
case .disabled:
|
|
return "pause.circle"
|
|
case .updateAvailable:
|
|
return "arrow.triangle.2.circlepath.circle.fill"
|
|
case .restartRequired:
|
|
return "restart.circle.fill"
|
|
case .failed, .incompatible, .revoked:
|
|
return "exclamationmark.triangle.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension PluginManagementItem {
|
|
var requiresRelaunchAction: Bool {
|
|
if case .restartRequired = state {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
}
|
|
|
|
private struct PluginManagementActionLabel: View {
|
|
let title: String
|
|
let busyTitle: String
|
|
let isBusy: Bool
|
|
let width: CGFloat
|
|
|
|
var body: some View {
|
|
HStack(spacing: 6) {
|
|
if isBusy {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.scaleEffect(0.75)
|
|
}
|
|
|
|
Text(isBusy ? busyTitle : title)
|
|
}
|
|
.frame(width: width)
|
|
}
|
|
}
|