Files
ggbond268--mactools/Plugins/LaunchControl/Sources/LaunchControlManagerView.swift
T
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

904 lines
35 KiB
Swift

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)
}
}
}
}
}