mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 11:24:50 +00:00
177 lines
6.3 KiB
Swift
177 lines
6.3 KiB
Swift
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
private struct PluginTextDraft: Identifiable {
|
|
let id = UUID()
|
|
var value: String
|
|
}
|
|
|
|
struct PluginEditorSheet: View {
|
|
@ObservedObject var model: PluginManagementModel
|
|
let session: PluginEditorSession
|
|
@Environment(\.dismiss) private var dismiss
|
|
/// Each presentation seeds fresh drafts; row identities survive edits and moves.
|
|
@State private var names: [PluginTextDraft]
|
|
@State private var arguments: [PluginTextDraft]
|
|
@State private var mode: PluginTransportMode
|
|
@State private var path: String
|
|
@State private var failure: String?
|
|
@State private var showFilePicker = false
|
|
|
|
init(model: PluginManagementModel, session: PluginEditorSession) {
|
|
self.model = model
|
|
self.session = session
|
|
_names = State(initialValue: session.initialNames.map { PluginTextDraft(value: $0) })
|
|
_arguments = State(initialValue: session.initialArguments.map { PluginTextDraft(value: $0) })
|
|
_path = State(initialValue: session.initialPath)
|
|
_mode = State(initialValue: session.initialMode)
|
|
}
|
|
|
|
private var nameValues: [String] { names.map(\.value) }
|
|
private var isManaged: Bool { session.managedProgram != nil }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text(title).font(.title2)
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("插件名称")
|
|
if isManaged {
|
|
Text(nameValues.joined(separator: ", ")).textSelection(.enabled)
|
|
} else {
|
|
PluginValueList(values: $names, placeholder: "插件名称", allowsReordering: false)
|
|
if let issue = model.namesIssue(nameValues, session: session) { fieldError(issue) }
|
|
}
|
|
}
|
|
if !isManaged {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("可执行文件路径")
|
|
HStack {
|
|
TextField("可执行文件路径", text: $path).textFieldStyle(.roundedBorder)
|
|
Button("选择文件…") { showFilePicker = true }
|
|
}
|
|
.padding(.all, 4)
|
|
if let issue = model.pathIssue(path) { fieldError(issue) }
|
|
}
|
|
if nameValues.contains(where: {
|
|
ManagedPluginCatalog.info(
|
|
forProgram: $0.trimmingCharacters(in: .whitespacesAndNewlines)) != nil
|
|
}) {
|
|
Text("保存后将覆盖所列名称对应的内置插件。")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Picker("插件传输模式", selection: $mode) {
|
|
ForEach(PluginTransportMode.allCases, id: \.self) { mode in
|
|
Text(verbatim: mode.rawValue).tag(mode)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text("本地启动参数")
|
|
Spacer()
|
|
if isManaged {
|
|
Button("清空参数") { arguments.removeAll() }.disabled(arguments.isEmpty)
|
|
}
|
|
}
|
|
PluginValueList(values: $arguments, placeholder: "参数", allowsReordering: true)
|
|
Text("每项作为一个完整参数传递,无需 shell 引号。仅在本机生效。")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
if let failure {
|
|
Label(failure, systemImage: "exclamationmark.triangle.fill")
|
|
.font(.footnote).foregroundStyle(.red)
|
|
}
|
|
HStack {
|
|
Spacer()
|
|
Button("取消", role: .cancel) { dismiss() }.keyboardShortcut(.cancelAction)
|
|
Button("保存") {
|
|
do {
|
|
try model.save(
|
|
session, names: nameValues, path: path, arguments: arguments.map(\.value), mode: mode)
|
|
dismiss()
|
|
} catch { failure = PluginManagementCopy.error(error) }
|
|
}
|
|
.keyboardShortcut(.defaultAction)
|
|
.disabled(
|
|
model.snapshot.mappingsUnreadable
|
|
|| (!isManaged
|
|
&& (model.namesIssue(nameValues, session: session) != nil
|
|
|| model.pathIssue(path) != nil))
|
|
)
|
|
}
|
|
}
|
|
.padding(24)
|
|
.frame(width: 600, height: 520)
|
|
.fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.item]) { result in
|
|
switch result {
|
|
case .success(let url): path = url.path
|
|
case .failure: failure = String(localized: "无法选择文件,请重试。")
|
|
}
|
|
}
|
|
}
|
|
|
|
private var title: String {
|
|
if isManaged { return String(localized: "内置插件设置") }
|
|
return session.isEditing ? String(localized: "编辑插件") : String(localized: "新增插件")
|
|
}
|
|
|
|
private func fieldError(_ error: PluginMappingError) -> some View {
|
|
Text(PluginManagementCopy.error(error)).font(.caption).foregroundStyle(.red)
|
|
}
|
|
}
|
|
|
|
private struct PluginValueList: View {
|
|
@Binding var values: [PluginTextDraft]
|
|
let placeholder: LocalizedStringKey
|
|
let allowsReordering: Bool
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
ForEach($values) { $item in
|
|
HStack {
|
|
TextField(placeholder, text: $item.value).textFieldStyle(.roundedBorder)
|
|
if allowsReordering {
|
|
Button {
|
|
move(item.id, by: -1)
|
|
} label: {
|
|
Image(systemName: "arrow.up")
|
|
.frame(width: 16, height: 16)
|
|
}
|
|
.help("上移").accessibilityLabel("上移")
|
|
.disabled(values.first?.id == item.id)
|
|
Button {
|
|
move(item.id, by: 1)
|
|
} label: {
|
|
Image(systemName: "arrow.down")
|
|
.frame(width: 16, height: 16)
|
|
}
|
|
.help("下移").accessibilityLabel("下移")
|
|
.disabled(values.last?.id == item.id)
|
|
}
|
|
Button {
|
|
values.removeAll { $0.id == item.id }
|
|
} label: {
|
|
Image(systemName: "minus")
|
|
.frame(width: 16, height: 16)
|
|
}
|
|
.help("删除此项").accessibilityLabel("删除此项")
|
|
}
|
|
}
|
|
Button("添加一项") { values.append(PluginTextDraft(value: "")) }
|
|
}
|
|
.padding(.all, 4)
|
|
}
|
|
|
|
private func move(_ id: UUID, by offset: Int) {
|
|
guard let index = values.firstIndex(where: { $0.id == id }),
|
|
values.indices.contains(index + offset)
|
|
else { return }
|
|
values.swapAt(index, index + offset)
|
|
}
|
|
}
|