This commit is contained in:
@@ -0,0 +1,903 @@
|
||||
import SwiftUI
|
||||
import MacToolsPluginKit
|
||||
|
||||
struct LaunchControlManagerView: View {
|
||||
@ObservedObject var controller: LaunchControlController
|
||||
private let localization: PluginLocalization
|
||||
|
||||
@State private var scopeFilter: LaunchControlFilter = .user
|
||||
@State private var originFilter: LaunchControlOriginFilter = .all
|
||||
@State private var stateFilter: LaunchControlStateFilter = .all
|
||||
@State private var searchText = ""
|
||||
@State private var pendingAction: LaunchControlPendingAction?
|
||||
|
||||
init(
|
||||
controller: LaunchControlController,
|
||||
localization: PluginLocalization = PluginLocalization(bundle: .main)
|
||||
) {
|
||||
self.controller = controller
|
||||
self.localization = localization
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) {
|
||||
summaryHeader
|
||||
toolbar
|
||||
|
||||
if let message = controller.snapshot.errorMessage {
|
||||
statusBanner(message: message, systemImage: "exclamationmark.triangle.fill", color: .orange)
|
||||
} else if let message = controller.snapshot.operationMessage {
|
||||
statusBanner(message: message, systemImage: "checkmark.circle.fill", color: .green)
|
||||
}
|
||||
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
itemList
|
||||
.frame(minWidth: 280, idealWidth: 330, maxWidth: 360)
|
||||
|
||||
Divider()
|
||||
|
||||
detailPane
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.frame(minHeight: 500)
|
||||
.pluginSettingsCardBackground(.plugin)
|
||||
|
||||
scanActivity
|
||||
}
|
||||
.onAppear {
|
||||
if controller.snapshot.items.isEmpty {
|
||||
controller.refresh()
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
pendingAction?.confirmationTitle(localization: localization)
|
||||
?? localization.string("manager.confirm.defaultTitle", defaultValue: "确认操作"),
|
||||
isPresented: Binding(
|
||||
get: { pendingAction != nil },
|
||||
set: { if !$0 { pendingAction = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
if let pendingAction {
|
||||
Button(pendingAction.buttonTitle(localization: localization), role: pendingAction.role) {
|
||||
perform(pendingAction)
|
||||
self.pendingAction = nil
|
||||
}
|
||||
}
|
||||
Button(localization.string("manager.confirm.cancel", defaultValue: "取消"), role: .cancel) {
|
||||
pendingAction = nil
|
||||
}
|
||||
} message: {
|
||||
if let pendingAction {
|
||||
Text(pendingAction.message(localization: localization))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryHeader: some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
metric(localization.string("manager.metric.total", defaultValue: "总数"), value: controller.snapshot.items.count, color: .primary)
|
||||
metric(localization.string("manager.metric.favorite", defaultValue: "关注"), value: controller.snapshot.items.filter(\.isFavorite).count, color: .yellow)
|
||||
metric(localization.string("manager.metric.running", defaultValue: "运行中"), value: controller.snapshot.items.filter { $0.state == .running }.count, color: .green)
|
||||
metric(localization.string("manager.metric.userCreated", defaultValue: "用户创建"), value: controller.snapshot.items.filter { $0.origin == .userCreated }.count, color: .orange)
|
||||
metric(localization.string("manager.metric.appCreated", defaultValue: "应用创建"), value: controller.snapshot.items.filter { $0.origin == .thirdParty }.count, color: .blue)
|
||||
metric(localization.string("manager.metric.failed", defaultValue: "异常"), value: controller.snapshot.items.filter { $0.state == .failed }.count, color: .red)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let target = controller.snapshot.currentScanTarget {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.scaleEffect(0.72)
|
||||
Text(URL(fileURLWithPath: target).lastPathComponent.isEmpty ? target : URL(fileURLWithPath: target).lastPathComponent)
|
||||
.font(PluginSettingsTheme.Typography.secondaryLabel)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: 260, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var toolbar: some View {
|
||||
HStack(spacing: 10) {
|
||||
Picker(localization.string("manager.filter.scope", defaultValue: "范围"), selection: $scopeFilter) {
|
||||
ForEach(LaunchControlFilter.allCases) { filter in
|
||||
Text(filter.title(localization: localization)).tag(filter)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 112)
|
||||
|
||||
Picker(localization.string("manager.filter.origin", defaultValue: "来源"), selection: $originFilter) {
|
||||
ForEach(LaunchControlOriginFilter.allCases) { filter in
|
||||
Text(filter.title(localization: localization)).tag(filter)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 112)
|
||||
|
||||
Picker(localization.string("manager.filter.state", defaultValue: "状态"), selection: $stateFilter) {
|
||||
ForEach(LaunchControlStateFilter.allCases) { filter in
|
||||
Text(filter.title(localization: localization)).tag(filter)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 112)
|
||||
|
||||
TextField(localization.string("manager.search.placeholder", defaultValue: "搜索 label、命令或路径"), text: $searchText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(minWidth: 180, idealWidth: 260, maxWidth: 320)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
originFilter = originFilter == .favorite ? .all : .favorite
|
||||
} label: {
|
||||
Label(localization.string("manager.action.favoriteFilter", defaultValue: "关注"), systemImage: originFilter == .favorite ? "star.fill" : "star")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
controller.refresh()
|
||||
} label: {
|
||||
Label(
|
||||
controller.snapshot.isRefreshing
|
||||
? localization.string("manager.action.refreshing", defaultValue: "刷新中")
|
||||
: localization.string("manager.action.refresh", defaultValue: "刷新"),
|
||||
systemImage: "arrow.clockwise"
|
||||
)
|
||||
}
|
||||
.disabled(controller.snapshot.isRefreshing)
|
||||
}
|
||||
}
|
||||
|
||||
private var itemList: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text(localization.string("manager.list.title", defaultValue: "启动项"))
|
||||
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
|
||||
Spacer()
|
||||
Text("\(filteredItems.count) / \(controller.snapshot.items.count)")
|
||||
.font(PluginSettingsTheme.Typography.secondaryLabel)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 12)
|
||||
|
||||
List(selection: selectedItemBinding) {
|
||||
ForEach(filteredItems) { item in
|
||||
LaunchControlItemRow(
|
||||
item: item,
|
||||
localization: localization,
|
||||
onFavoriteToggle: {
|
||||
controller.setFavorite(!item.isFavorite, for: item)
|
||||
}
|
||||
)
|
||||
.tag(item.id)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.overlay {
|
||||
if controller.snapshot.isRefreshing && controller.snapshot.items.isEmpty {
|
||||
ProgressView(localization.string("manager.list.scanning", defaultValue: "正在扫描"))
|
||||
.controlSize(.small)
|
||||
} else if filteredItems.isEmpty {
|
||||
ContentUnavailableView(
|
||||
localization.string("manager.list.empty", defaultValue: "没有匹配项目"),
|
||||
systemImage: "magnifyingglass"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(PluginSettingsTheme.Palette.nativeFieldBackground)
|
||||
}
|
||||
|
||||
private var detailPane: some View {
|
||||
Group {
|
||||
if let item = selectedItem {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
detailHeader(item)
|
||||
actionBar(item)
|
||||
LaunchControlNoteEditor(item: item, controller: controller, localization: localization)
|
||||
.id(item.id)
|
||||
keyFields(item)
|
||||
rawPlist(item)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.padding(16)
|
||||
}
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "list.bullet.rectangle")
|
||||
.font(.system(size: PluginSettingsTheme.Size.emptyStateIcon, weight: .regular))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(controller.snapshot.isRefreshing
|
||||
? localization.string("manager.placeholder.loadingTitle", defaultValue: "正在读取启动项")
|
||||
: localization.string("manager.placeholder.title", defaultValue: "选择一个启动项")
|
||||
)
|
||||
.font(PluginSettingsTheme.Typography.pageDescription.weight(.semibold))
|
||||
Text(controller.snapshot.isRefreshing
|
||||
? localization.string("manager.placeholder.loadingDescription", defaultValue: "左侧列表会随扫描进度逐步更新")
|
||||
: localization.string("manager.placeholder.description", defaultValue: "查看 plist 字段、运行状态和可用操作")
|
||||
)
|
||||
.font(PluginSettingsTheme.Typography.secondaryLabel)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var scanActivity: some View {
|
||||
if controller.snapshot.isRefreshing || !controller.snapshot.scanLogEntries.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(localization.string("manager.scanActivity.title", defaultValue: "扫描进度"))
|
||||
.font(PluginSettingsTheme.Typography.sectionTitle)
|
||||
Spacer()
|
||||
if controller.snapshot.isRefreshing {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.scaleEffect(0.72)
|
||||
}
|
||||
}
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(Array(controller.snapshot.scanLogEntries.enumerated()), id: \.offset) { index, entry in
|
||||
Text(entry)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.id(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 88)
|
||||
.onChange(of: controller.snapshot.scanLogEntries.count) {
|
||||
if let lastIndex = controller.snapshot.scanLogEntries.indices.last {
|
||||
proxy.scrollTo(lastIndex, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.pluginSettingsCardBackground(.plugin)
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredItems: [LaunchControlItem] {
|
||||
controller.snapshot.items.filter { item in
|
||||
let scopeMatches = scopeFilter.scope.map { $0 == item.scope } ?? true
|
||||
let originMatches = originFilter.matches(item)
|
||||
let stateMatches = stateFilter.matches(item.state)
|
||||
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let searchMatches: Bool
|
||||
if query.isEmpty {
|
||||
searchMatches = true
|
||||
} else {
|
||||
let haystack = [
|
||||
item.label,
|
||||
item.commandText(localization: localization),
|
||||
item.plistURL.path,
|
||||
item.origin.title(localization: localization),
|
||||
item.triggerSummary(localization: localization),
|
||||
item.note
|
||||
].joined(separator: "\n")
|
||||
searchMatches = haystack.localizedCaseInsensitiveContains(query)
|
||||
}
|
||||
return scopeMatches && originMatches && stateMatches && searchMatches
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedItem: LaunchControlItem? {
|
||||
if let selectedID = controller.snapshot.selectedItemID,
|
||||
let visibleItem = filteredItems.first(where: { $0.id == selectedID }) {
|
||||
return visibleItem
|
||||
}
|
||||
|
||||
return filteredItems.first ?? controller.snapshot.selectedItem
|
||||
}
|
||||
|
||||
private var selectedItemBinding: Binding<String?> {
|
||||
Binding(
|
||||
get: { selectedItem?.id },
|
||||
set: { controller.selectItem(id: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func detailHeader(_ item: LaunchControlItem) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: item.origin == .system ? "lock.shield" : "powerplug")
|
||||
.font(PluginSettingsTheme.Typography.pageTitle.weight(.semibold))
|
||||
.foregroundStyle(item.origin == .system ? Color.secondary : Color.orange)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(item.label)
|
||||
.font(PluginSettingsTheme.Typography.pageTitle)
|
||||
.lineLimit(3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.textSelection(.enabled)
|
||||
Text(item.plistURL.path)
|
||||
.font(PluginSettingsTheme.Typography.controlLabel)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Button {
|
||||
controller.setFavorite(!item.isFavorite, for: item)
|
||||
} label: {
|
||||
Image(systemName: item.isFavorite ? "star.fill" : "star")
|
||||
.font(PluginSettingsTheme.Typography.pageDescription.weight(.semibold))
|
||||
.foregroundStyle(item.isFavorite ? Color.yellow : Color.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(item.isFavorite
|
||||
? localization.string("manager.favorite.removeHelp", defaultValue: "取消关注")
|
||||
: localization.string("manager.favorite.addHelp", defaultValue: "关注启动项")
|
||||
)
|
||||
}
|
||||
|
||||
FlowLayout(spacing: 8, rowSpacing: 6) {
|
||||
badge(item.scope.title(localization: localization), color: .blue)
|
||||
badge(item.origin.title(localization: localization), color: item.origin == .system ? .gray : .orange)
|
||||
badge(item.statusText(localization: localization), color: item.state == .failed ? .red : .green)
|
||||
if !item.canManage {
|
||||
badge(localization.string("manager.badge.readOnly", defaultValue: "只读"), color: .gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func actionBar(_ item: LaunchControlItem) -> some View {
|
||||
FlowLayout(spacing: 8, rowSpacing: 8) {
|
||||
Button {
|
||||
controller.setFavorite(!item.isFavorite, for: item)
|
||||
} label: {
|
||||
Label(
|
||||
item.isFavorite
|
||||
? localization.string("manager.action.unfavorite", defaultValue: "取消关注")
|
||||
: localization.string("manager.action.favorite", defaultValue: "关注"),
|
||||
systemImage: item.isFavorite ? "star.slash" : "star"
|
||||
)
|
||||
}
|
||||
|
||||
Button {
|
||||
controller.openInFinder(item)
|
||||
} label: {
|
||||
Label(localization.string("manager.action.revealInFinder", defaultValue: "在 Finder 中显示"), systemImage: "finder")
|
||||
}
|
||||
|
||||
if item.canManage {
|
||||
Button {
|
||||
pendingAction = .bootstrap(item)
|
||||
} label: {
|
||||
Label(LaunchControlManagedAction.bootstrap.title(localization: localization), systemImage: "tray.and.arrow.down")
|
||||
}
|
||||
.disabled(item.isLoaded)
|
||||
|
||||
Button {
|
||||
pendingAction = .bootout(item)
|
||||
} label: {
|
||||
Label(LaunchControlManagedAction.bootout.title(localization: localization), systemImage: "tray.and.arrow.up")
|
||||
}
|
||||
.disabled(!item.isLoaded)
|
||||
|
||||
Button {
|
||||
pendingAction = item.isDisabled ? .enable(item) : .disable(item)
|
||||
} label: {
|
||||
Label(
|
||||
item.isDisabled
|
||||
? LaunchControlManagedAction.enable.title(localization: localization)
|
||||
: LaunchControlManagedAction.disable.title(localization: localization),
|
||||
systemImage: item.isDisabled ? "checkmark.circle" : "nosign"
|
||||
)
|
||||
}
|
||||
|
||||
Button {
|
||||
pendingAction = item.state == .running ? .stop(item) : .start(item)
|
||||
} label: {
|
||||
Label(
|
||||
item.state == .running
|
||||
? LaunchControlManagedAction.stop.title(localization: localization)
|
||||
: LaunchControlManagedAction.start.title(localization: localization),
|
||||
systemImage: item.state == .running ? "stop.circle" : "play.circle"
|
||||
)
|
||||
}
|
||||
.disabled(item.isDisabled)
|
||||
|
||||
Button {
|
||||
pendingAction = .restart(item)
|
||||
} label: {
|
||||
Label(LaunchControlManagedAction.restart.title(localization: localization), systemImage: "arrow.clockwise.circle")
|
||||
}
|
||||
.disabled(item.isDisabled)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
private func keyFields(_ item: LaunchControlItem) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(localization.string("manager.keyFields.title", defaultValue: "关键字段"))
|
||||
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
|
||||
|
||||
fieldRow(
|
||||
"ProgramArguments",
|
||||
value: item.commandText(localization: localization),
|
||||
help: localization.string("manager.field.programArguments.help", defaultValue: "启动时执行的命令与参数。")
|
||||
)
|
||||
fieldRow(
|
||||
"RunAtLoad",
|
||||
value: item.runAtLoad ? "true" : "false",
|
||||
help: localization.string("manager.field.runAtLoad.help", defaultValue: "加载 LaunchAgent 后是否立即运行一次。")
|
||||
)
|
||||
fieldRow(
|
||||
"KeepAlive",
|
||||
value: item.keepAliveDescription ?? notSetText,
|
||||
help: localization.string("manager.field.keepAlive.help", defaultValue: "进程退出后是否按条件自动拉起。")
|
||||
)
|
||||
fieldRow(
|
||||
"StartInterval",
|
||||
value: item.startInterval.map { secondsText($0) } ?? notSetText,
|
||||
help: localization.string("manager.field.startInterval.help", defaultValue: "按固定秒数间隔触发。")
|
||||
)
|
||||
fieldRow(
|
||||
"StartCalendarInterval",
|
||||
value: item.startCalendarDescription ?? notSetText,
|
||||
help: localization.string("manager.field.startCalendar.help", defaultValue: "按日历时间触发。")
|
||||
)
|
||||
fieldRow(
|
||||
localization.string("manager.field.triggerSummary.title", defaultValue: "触发摘要"),
|
||||
value: item.triggerSummary(localization: localization),
|
||||
help: localization.string("manager.field.triggerSummary.help", defaultValue: "根据常见字段生成的可读说明。")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func rawPlist(_ item: LaunchControlItem) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(localization.string("manager.rawPlist.title", defaultValue: "原始 plist"))
|
||||
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
Text(item.rawPlist.isEmpty
|
||||
? localization.string("manager.rawPlist.unreadable", defaultValue: "无法以 UTF-8 显示原始内容")
|
||||
: item.rawPlist
|
||||
)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
}
|
||||
.frame(minHeight: 180, maxHeight: 260)
|
||||
.pluginSettingsCardBackground(.recessed)
|
||||
}
|
||||
}
|
||||
|
||||
private func fieldRow(_ title: String, value: String, help: String) -> some View {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 12, verticalSpacing: 4) {
|
||||
GridRow {
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 138, alignment: .leading)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(value)
|
||||
.font(PluginSettingsTheme.Typography.rowTitle)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.textSelection(.enabled)
|
||||
Text(help)
|
||||
.font(PluginSettingsTheme.Typography.rowDescription)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.pluginSettingsCardBackground(.plugin)
|
||||
}
|
||||
|
||||
private func statusBanner(message: String, systemImage: String, color: Color) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: systemImage)
|
||||
.foregroundStyle(color)
|
||||
Text(message)
|
||||
.font(PluginSettingsTheme.Typography.secondaryLabel)
|
||||
.lineLimit(3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer()
|
||||
}
|
||||
.padding(10)
|
||||
.background(color.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.control, style: .continuous))
|
||||
}
|
||||
|
||||
private func metric(_ title: String, value: Int, color: Color) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Text("\(value)")
|
||||
.font(PluginSettingsTheme.Typography.pageTitle)
|
||||
.foregroundStyle(color)
|
||||
.monospacedDigit()
|
||||
Text(title)
|
||||
.font(PluginSettingsTheme.Typography.secondaryLabel)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.pluginSettingsCardBackground(.plugin)
|
||||
}
|
||||
|
||||
private func badge(_ title: String, color: Color) -> some View {
|
||||
Text(title)
|
||||
.font(PluginSettingsTheme.Typography.statusBadge)
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(color.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
private func perform(_ action: LaunchControlPendingAction) {
|
||||
switch action {
|
||||
case let .bootstrap(item):
|
||||
controller.bootstrap(item)
|
||||
case let .bootout(item):
|
||||
controller.bootout(item)
|
||||
case let .enable(item):
|
||||
controller.enable(item)
|
||||
case let .disable(item):
|
||||
controller.disable(item)
|
||||
case let .start(item):
|
||||
controller.start(item)
|
||||
case let .stop(item):
|
||||
controller.stop(item)
|
||||
case let .restart(item):
|
||||
controller.restart(item)
|
||||
}
|
||||
}
|
||||
|
||||
private var notSetText: String {
|
||||
localization.string("manager.field.notSet", defaultValue: "未设置")
|
||||
}
|
||||
|
||||
private func secondsText(_ seconds: Int) -> String {
|
||||
localization.format("manager.seconds", defaultValue: "%d 秒", seconds)
|
||||
}
|
||||
}
|
||||
|
||||
private struct FlowLayout: Layout {
|
||||
var spacing: CGFloat
|
||||
var rowSpacing: CGFloat
|
||||
|
||||
func sizeThatFits(
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout ()
|
||||
) -> CGSize {
|
||||
let rows = rows(proposal: proposal, subviews: subviews)
|
||||
return CGSize(
|
||||
width: proposal.width ?? rows.map(\.width).max() ?? 0,
|
||||
height: rows.last.map { $0.y + $0.height } ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
func placeSubviews(
|
||||
in bounds: CGRect,
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout ()
|
||||
) {
|
||||
for row in rows(proposal: ProposedViewSize(width: bounds.width, height: proposal.height), subviews: subviews) {
|
||||
for item in row.items {
|
||||
subviews[item.index].place(
|
||||
at: CGPoint(x: bounds.minX + item.x, y: bounds.minY + row.y),
|
||||
proposal: ProposedViewSize(item.size)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func rows(proposal: ProposedViewSize, subviews: Subviews) -> [FlowRow] {
|
||||
let maxWidth = proposal.width ?? .greatestFiniteMagnitude
|
||||
var rows: [FlowRow] = []
|
||||
var currentItems: [FlowItem] = []
|
||||
var currentWidth: CGFloat = 0
|
||||
var currentHeight: CGFloat = 0
|
||||
var y: CGFloat = 0
|
||||
|
||||
for index in subviews.indices {
|
||||
let size = subviews[index].sizeThatFits(.unspecified)
|
||||
let nextWidth = currentItems.isEmpty ? size.width : currentWidth + spacing + size.width
|
||||
if nextWidth > maxWidth, !currentItems.isEmpty {
|
||||
rows.append(FlowRow(items: currentItems, y: y, width: currentWidth, height: currentHeight))
|
||||
y += currentHeight + rowSpacing
|
||||
currentItems = []
|
||||
currentWidth = 0
|
||||
currentHeight = 0
|
||||
}
|
||||
|
||||
let x = currentItems.isEmpty ? 0 : currentWidth + spacing
|
||||
currentItems.append(FlowItem(index: index, x: x, size: size))
|
||||
currentWidth = currentItems.isEmpty ? size.width : x + size.width
|
||||
currentHeight = max(currentHeight, size.height)
|
||||
}
|
||||
|
||||
if !currentItems.isEmpty {
|
||||
rows.append(FlowRow(items: currentItems, y: y, width: currentWidth, height: currentHeight))
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
private struct FlowRow {
|
||||
let items: [FlowItem]
|
||||
let y: CGFloat
|
||||
let width: CGFloat
|
||||
let height: CGFloat
|
||||
}
|
||||
|
||||
private struct FlowItem {
|
||||
let index: Int
|
||||
let x: CGFloat
|
||||
let size: CGSize
|
||||
}
|
||||
|
||||
private struct LaunchControlItemRow: View {
|
||||
let item: LaunchControlItem
|
||||
let localization: PluginLocalization
|
||||
let onFavoriteToggle: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Circle()
|
||||
.fill(statusColor)
|
||||
.frame(width: 8, height: 8)
|
||||
.padding(.top, 6)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Text(item.label)
|
||||
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
|
||||
.lineLimit(1)
|
||||
if item.origin == .userCreated {
|
||||
Text(localization.string("manager.itemRow.userBadge", defaultValue: "用户"))
|
||||
.font(PluginSettingsTheme.Typography.statusBadge)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
Text(item.commandText(localization: localization))
|
||||
.font(PluginSettingsTheme.Typography.rowDescription)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(localization.format(
|
||||
"manager.itemRow.scopeStatus",
|
||||
defaultValue: "%@ · %@",
|
||||
item.scope.title(localization: localization),
|
||||
item.statusText(localization: localization)
|
||||
))
|
||||
.font(PluginSettingsTheme.Typography.secondaryLabel)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
Button(action: onFavoriteToggle) {
|
||||
Image(systemName: item.isFavorite ? "star.fill" : "star")
|
||||
.font(PluginSettingsTheme.Typography.secondaryLabel.weight(.semibold))
|
||||
.foregroundStyle(item.isFavorite ? Color.yellow : Color.secondary)
|
||||
.frame(width: 18, height: 18)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(item.isFavorite
|
||||
? localization.string("manager.favorite.removeHelp", defaultValue: "取消关注")
|
||||
: localization.string("manager.favorite.addHelp", defaultValue: "关注启动项")
|
||||
)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch item.state {
|
||||
case .running:
|
||||
return .green
|
||||
case .failed:
|
||||
return .red
|
||||
case .disabled:
|
||||
return .gray
|
||||
case .loaded:
|
||||
return .blue
|
||||
case .unloaded,
|
||||
.unknown:
|
||||
return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum LaunchControlPendingAction: Identifiable {
|
||||
case bootstrap(LaunchControlItem)
|
||||
case bootout(LaunchControlItem)
|
||||
case enable(LaunchControlItem)
|
||||
case disable(LaunchControlItem)
|
||||
case start(LaunchControlItem)
|
||||
case stop(LaunchControlItem)
|
||||
case restart(LaunchControlItem)
|
||||
|
||||
var id: String {
|
||||
"\(actionName)-\(item.id)"
|
||||
}
|
||||
|
||||
var item: LaunchControlItem {
|
||||
switch self {
|
||||
case let .bootstrap(item),
|
||||
let .bootout(item),
|
||||
let .enable(item),
|
||||
let .disable(item),
|
||||
let .start(item),
|
||||
let .stop(item),
|
||||
let .restart(item):
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
var actionName: String {
|
||||
action.title()
|
||||
}
|
||||
|
||||
func actionName(localization: PluginLocalization) -> String {
|
||||
action.title(localization: localization)
|
||||
}
|
||||
|
||||
var action: LaunchControlManagedAction {
|
||||
switch self {
|
||||
case .bootstrap:
|
||||
return .bootstrap
|
||||
case .bootout:
|
||||
return .bootout
|
||||
case .enable:
|
||||
return .enable
|
||||
case .disable:
|
||||
return .disable
|
||||
case .start:
|
||||
return .start
|
||||
case .stop:
|
||||
return .stop
|
||||
case .restart:
|
||||
return .restart
|
||||
}
|
||||
}
|
||||
|
||||
var buttonTitle: String {
|
||||
"\(actionName) \(item.label)"
|
||||
}
|
||||
|
||||
func buttonTitle(localization: PluginLocalization) -> String {
|
||||
localization.format(
|
||||
"manager.confirm.buttonTitle",
|
||||
defaultValue: "%@ %@",
|
||||
actionName(localization: localization),
|
||||
item.label
|
||||
)
|
||||
}
|
||||
|
||||
var confirmationTitle: String {
|
||||
confirmationTitle(localization: PluginLocalization(bundle: .main))
|
||||
}
|
||||
|
||||
func confirmationTitle(localization: PluginLocalization) -> String {
|
||||
localization.format(
|
||||
"manager.confirm.title",
|
||||
defaultValue: "确认%@启动项?",
|
||||
actionName(localization: localization)
|
||||
)
|
||||
}
|
||||
|
||||
var message: String {
|
||||
message(localization: PluginLocalization(bundle: .main))
|
||||
}
|
||||
|
||||
func message(localization: PluginLocalization) -> String {
|
||||
localization.format(
|
||||
"manager.confirm.message",
|
||||
defaultValue: "%@\n%@\n\n此操作会调用 launchctl %@。用户级 LaunchAgent 通常可以恢复,但禁用、卸载或停止可能影响后台任务。",
|
||||
item.label,
|
||||
item.plistURL.path,
|
||||
actionName(localization: localization)
|
||||
)
|
||||
}
|
||||
|
||||
var role: ButtonRole? {
|
||||
switch self {
|
||||
case .bootout,
|
||||
.disable,
|
||||
.stop:
|
||||
return .destructive
|
||||
case .bootstrap,
|
||||
.enable,
|
||||
.start,
|
||||
.restart:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LaunchControlNoteEditor: View {
|
||||
let item: LaunchControlItem
|
||||
let controller: LaunchControlController
|
||||
let localization: PluginLocalization
|
||||
@State private var draft: String
|
||||
|
||||
init(
|
||||
item: LaunchControlItem,
|
||||
controller: LaunchControlController,
|
||||
localization: PluginLocalization = PluginLocalization(bundle: .main)
|
||||
) {
|
||||
self.item = item
|
||||
self.controller = controller
|
||||
self.localization = localization
|
||||
_draft = State(initialValue: item.note)
|
||||
}
|
||||
|
||||
private var isDirty: Bool {
|
||||
draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
!= item.note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text(localization.string("manager.note.title", defaultValue: "备注"))
|
||||
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
|
||||
Spacer()
|
||||
if isDirty {
|
||||
Button(localization.string("manager.note.save", defaultValue: "保存")) {
|
||||
controller.setNote(draft, for: item)
|
||||
}
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
TextEditor(text: $draft)
|
||||
.font(.system(size: 12))
|
||||
.scrollContentBackground(.hidden)
|
||||
.frame(minHeight: 60, maxHeight: 120)
|
||||
.padding(6)
|
||||
.pluginSettingsCardBackground(.recessed)
|
||||
.overlay(alignment: .topLeading) {
|
||||
if draft.isEmpty {
|
||||
Text(localization.string(
|
||||
"manager.note.placeholder",
|
||||
defaultValue: "为这个启动项添加本地备注(仅保存在本机,不写入 plist)。"
|
||||
))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.horizontal, 11)
|
||||
.padding(.top, 13)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user