import AppKit import CoreImage.CIFilterBuiltins import Foundation import ServiceManagement import SwiftUI import UniformTypeIdentifiers import UserNotifications struct CoreStatus: Codable { var online = false var session = "" var server = "" var deviceId: UInt64 = 0 var name = "" } struct Challenge: Codable, Identifiable, Equatable, Hashable { var challengeId: String var accountId = "" var platform = "" var kind = "" var qrToken = "" var qrUrl = "" var prompt = "" var expiresAt: Int64 = 0 var id: String { challengeId } } struct ChallengeEnvelope: Codable { var list: [Challenge] } struct CoreConfig: Codable { var server = "http://127.0.0.1:8090" var webUrl = "http://127.0.0.1:3013" var fakeAll = false } struct LocalInfo: Codable { var port: Int var token: String } struct LocalResponse: Codable { var code: Int? var message: String? } enum ConnectionState: Equatable { case starting case unpaired case connecting case online case reconnecting case coreUnavailable var title: String { switch self { case .starting: return "启动中" case .unpaired: return "未配对" case .connecting: return "连接中" case .online: return "在线" case .reconnecting: return "重连中" case .coreUnavailable: return "核心不可用" } } var icon: String { switch self { case .starting: return "arrow.triangle.2.circlepath" case .unpaired: return "link.badge.plus" case .connecting: return "arrow.triangle.2.circlepath" case .online: return "checkmark.circle.fill" case .reconnecting: return "wifi.exclamationmark" case .coreUnavailable: return "exclamationmark.triangle.fill" } } } @MainActor final class CoreController: NSObject, ObservableObject { @Published private(set) var status = CoreStatus() @Published private(set) var challenges: [Challenge] = [] @Published var config = CoreConfig() @Published var errorMessage = "" @Published var notice = "" @Published private(set) var logs = "" @Published private(set) var coreReady = false @Published private(set) var connectionState: ConnectionState = .starting @Published private(set) var lastError = "" @Published private(set) var lastErrorSource = "" private var process: Process? private var localInfo: LocalInfo? private var refreshTask: Task? private var knownChallengeIds = Set() private var logHandle: FileHandle? private var dataDirectory: URL { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] return base.appendingPathComponent("EveryPublish", isDirectory: true).appendingPathComponent("data", isDirectory: true) } override init() { super.init() loadConfig() requestNotifications() NotificationCenter.default.addObserver(self, selector: #selector(terminateCore), name: NSApplication.willTerminateNotification, object: nil) startCore() refreshTask = Task { [weak self] in while !Task.isCancelled { await self?.refresh() try? await Task.sleep(for: .seconds(2)) } } } deinit { refreshTask?.cancel() NotificationCenter.default.removeObserver(self) try? logHandle?.close() process?.terminate() } @objc private func terminateCore() { process?.terminate() } func openWebsite() { let urlString = config.webUrl.isEmpty ? config.server : config.webUrl guard let url = URL(string: urlString) else { errorMessage = "网站地址无效" return } guard NSWorkspace.shared.open(url) else { errorMessage = "无法打开网站,请检查网站地址" return } } func openDataDirectory() { do { try FileManager.default.createDirectory(at: dataDirectory, withIntermediateDirectories: true) guard NSWorkspace.shared.open(dataDirectory) else { errorMessage = "无法打开数据目录" return } } catch { errorMessage = "创建数据目录失败:\(error.localizedDescription)" } } func pair(code: String, name: String) async { connectionState = .connecting lastError = "" lastErrorSource = "" do { let trimmedCode = code.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) let deviceName = trimmedName.isEmpty ? (Host.current().localizedName ?? "macOS Agent") : trimmedName guard Self.isValidPairCode(trimmedCode) else { connectionState = status.deviceId == 0 ? .unpaired : .reconnecting errorMessage = "配对码应为 6 位大写字母或数字" return } let response: LocalResponse = try await request(path: "/pair", method: "POST", body: ["code": trimmedCode, "name": deviceName]) guard response.code == 0 else { connectionState = status.deviceId == 0 ? .unpaired : .reconnecting let serverMessage = response.message ?? "配对失败" if serverMessage == "接口不存在" || serverMessage.localizedCaseInsensitiveContains("not found") { errorMessage = "当前服务器是 Web-only/mock 模式,不需要客户端配对。请直接使用网页;若要启用客户端,请将 EXECUTOR_MODE 设置为 agent 后重启服务器。" } else { errorMessage = serverMessage } lastErrorSource = "配对服务" lastError = errorMessage return } notice = "配对成功,正在连接服务器" await refresh() } catch { connectionState = .coreUnavailable lastErrorSource = "本机核心" lastError = "配对请求未送达:\(error.localizedDescription)" errorMessage = lastError } } func solve(challenge: Challenge, value: String) async { do { let response: LocalResponse = try await request(path: "/challenges/\(challenge.challengeId)", method: "POST", body: ["value": value]) guard response.code == 0 else { errorMessage = response.message ?? "提交挑战失败" return } notice = "挑战已提交,等待服务器确认" await refresh() } catch { lastErrorSource = "本机核心" lastError = "挑战提交未送达:\(error.localizedDescription)" errorMessage = lastError } } func saveAndRestart(server: String, webUrl: String, fakeAll: Bool, loginItem: Bool) { let server = server.trimmingCharacters(in: .whitespacesAndNewlines) let webUrl = webUrl.trimmingCharacters(in: .whitespacesAndNewlines) guard Self.isValidHTTPURL(server), Self.isValidHTTPURL(webUrl) else { errorMessage = "服务器地址和网站地址都必须是有效的 http 或 https 地址" return } config.server = server config.webUrl = webUrl config.fakeAll = fakeAll do { try FileManager.default.createDirectory(at: dataDirectory, withIntermediateDirectories: true) let data = try JSONEncoder.pretty.encode(config) try data.write(to: dataDirectory.appendingPathComponent("config.json"), options: [.atomic]) setLoginItem(loginItem) startCore() notice = "设置已保存,核心进程正在重启" } catch { errorMessage = "保存设置失败:\(error.localizedDescription)" } } func clearMessages() { errorMessage = "" notice = "" } func restartCore() { startCore() notice = "核心进程正在重启" } func refreshNow() { Task { await refresh() } } func clearLogs() { let file = dataDirectory.appendingPathComponent("agent-core.log") do { try logHandle?.close() logHandle = nil try Data().write(to: file, options: [.atomic]) if process?.isRunning == true, let handle = try? FileHandle(forWritingTo: file) { handle.seekToEndOfFile() logHandle = handle process?.standardOutput = handle process?.standardError = handle } logs = "" notice = "日志已清空" } catch { errorMessage = "清空日志失败:\(error.localizedDescription)" } } func refreshLogs() { reloadLogs() } func exportLogs() { let panel = NSSavePanel() panel.nameFieldStringValue = "everypublish-agent-core.log" panel.allowedContentTypes = [.plainText] guard panel.runModal() == .OK, let destination = panel.url else { return } do { try Data(logs.utf8).write(to: destination, options: [.atomic]) notice = "日志已导出" } catch { errorMessage = "日志导出失败:\(error.localizedDescription)" } } private func refresh() async { guard readLocalInfo() else { coreReady = false if process?.isRunning == true { connectionState = .starting lastErrorSource = "本机核心" lastError = "正在等待核心控制服务就绪" } else if connectionState != .starting { connectionState = .coreUnavailable lastErrorSource = "核心进程" lastError = "核心进程未运行,请点击“重启核心”" } return } do { status = try await request(path: "/status", method: "GET", body: Optional<[String: String]>.none) let envelope: ChallengeEnvelope = try await request(path: "/challenges", method: "GET", body: Optional<[String: String]>.none) coreReady = true connectionState = status.deviceId == 0 ? .unpaired : (status.online ? .online : .reconnecting) if status.online { lastError = "" lastErrorSource = "" } else if status.deviceId > 0 { lastErrorSource = "Agent WSS" lastError = "设备已配对,正在等待服务器 Agent 通道连接" } updateChallenges(envelope.list) reloadLogs() } catch { coreReady = false connectionState = .coreUnavailable lastErrorSource = "本机核心控制服务" lastError = "无法读取核心状态:\(error.localizedDescription)" reloadLogs() } } private func updateChallenges(_ list: [Challenge]) { let ids = Set(list.map(\.challengeId)) let fresh = list.filter { !knownChallengeIds.isEmpty && !knownChallengeIds.contains($0.challengeId) } if !fresh.isEmpty { let platforms = fresh.map { $0.platform.isEmpty ? "平台" : $0.platform }.joined(separator: "、") notify(title: "EveryPublish 需要处理挑战", body: "\(platforms) 有新的扫码或验证码任务") } knownChallengeIds = ids challenges = list } private func startCore() { process?.terminationHandler = nil process?.terminate() if process?.isRunning == true { process?.waitUntilExit() } process = nil try? logHandle?.close() logHandle = nil localInfo = nil coreReady = false connectionState = .starting lastError = "" lastErrorSource = "" try? FileManager.default.createDirectory(at: dataDirectory, withIntermediateDirectories: true) try? FileManager.default.removeItem(at: dataDirectory.appendingPathComponent("local.json")) guard let executable = coreExecutable() else { connectionState = .coreUnavailable lastErrorSource = "核心进程" lastError = "找不到 agent-core,请重新构建 macOS 客户端" errorMessage = "找不到 agent-core,请重新构建 macOS 客户端" return } let task = Process() task.executableURL = executable task.arguments = ["-dir", dataDirectory.path] let logURL = dataDirectory.appendingPathComponent("agent-core.log") FileManager.default.createFile(atPath: logURL.path, contents: nil) if let handle = try? FileHandle(forWritingTo: logURL) { handle.seekToEndOfFile() logHandle = handle task.standardOutput = handle task.standardError = handle } do { try task.run() process = task task.terminationHandler = { [weak self, weak task] _ in Task { @MainActor in guard let self, let task, self.process === task else { return } self.process = nil self.localInfo = nil self.coreReady = false self.connectionState = .coreUnavailable self.lastErrorSource = "核心进程" self.lastError = "agent-core 已退出,请查看日志并重启核心" self.reloadLogs() } } } catch { connectionState = .coreUnavailable lastErrorSource = "核心进程" lastError = "核心程序启动失败:\(error.localizedDescription)" errorMessage = "核心程序启动失败:\(error.localizedDescription)" } } private func coreExecutable() -> URL? { if let path = ProcessInfo.processInfo.environment["EP_CORE_PATH"], FileManager.default.isExecutableFile(atPath: path) { return URL(fileURLWithPath: path) } if let bundled = Bundle.main.url(forResource: "agent-core", withExtension: nil), FileManager.default.isExecutableFile(atPath: bundled.path) { return bundled } let current = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) let candidates = [ current.appendingPathComponent("client/dist/agent-core"), current.appendingPathComponent("../dist/agent-core"), ] return candidates.first { FileManager.default.isExecutableFile(atPath: $0.path) } } private func readLocalInfo() -> Bool { let file = dataDirectory.appendingPathComponent("local.json") guard let data = try? Data(contentsOf: file), let info = try? JSONDecoder().decode(LocalInfo.self, from: data), info.port > 0, !info.token.isEmpty else { localInfo = nil return false } localInfo = info return true } private func loadConfig() { let file = dataDirectory.appendingPathComponent("config.json") guard let data = try? Data(contentsOf: file), let stored = try? JSONDecoder().decode(CoreConfig.self, from: data) else { return } config = stored } private func reloadLogs() { let file = dataDirectory.appendingPathComponent("agent-core.log") let maxBytes = 160_000 guard let handle = try? FileHandle(forReadingFrom: file) else { logs = "" return } defer { try? handle.close() } let size = (try? handle.seekToEnd()) ?? 0 let start = size > UInt64(maxBytes) ? size - UInt64(maxBytes) : 0 try? handle.seek(toOffset: start) guard let data = try? handle.readToEnd(), let text = String(data: data, encoding: .utf8) else { return } logs = text } static func isValidPairCode(_ code: String) -> Bool { let allowed = CharacterSet(charactersIn: "ABCDEFGHJKMNPQRSTUVWXYZ23456789") return code.count == 6 && code.unicodeScalars.allSatisfy { allowed.contains($0) } } private static func isValidHTTPURL(_ value: String) -> Bool { guard let url = URL(string: value), let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme), url.host != nil else { return false } return true } private func request(path: String, method: String, body: Body?) async throws -> T { guard let info = localInfo else { throw URLError(.notConnectedToInternet) } guard let url = URL(string: "http://127.0.0.1:\(info.port)\(path)") else { throw URLError(.badURL) } var request = URLRequest(url: url) request.httpMethod = method request.timeoutInterval = 8 request.setValue(info.token, forHTTPHeaderField: "X-Agent-Token") if let body { request.httpBody = try JSONEncoder().encode(body) request.setValue("application/json", forHTTPHeaderField: "Content-Type") } let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } guard (200..<300).contains(http.statusCode) else { if let envelope = try? JSONDecoder().decode(LocalResponse.self, from: data), let message = envelope.message, !message.isEmpty { throw NSError(domain: "EveryPublish.Core", code: http.statusCode, userInfo: [NSLocalizedDescriptionKey: message]) } throw URLError(.badServerResponse) } return try JSONDecoder().decode(T.self, from: data) } private func requestNotifications() { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } } private func notify(title: String, body: String) { let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)) } private func setLoginItem(_ enabled: Bool) { if #available(macOS 13.0, *) { if enabled { try? SMAppService.mainApp.register() } else { try? SMAppService.mainApp.unregister() } } } } private extension JSONEncoder { static var pretty: JSONEncoder { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] return encoder } } @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { let controller = CoreController() private var window: NSWindow? func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.regular) showMainWindow() } func showMainWindow() { if let window { window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) return } let hosting = NSHostingController(rootView: ContentView(controller: controller)) let window = NSWindow(contentViewController: hosting) window.title = "EveryPublish" window.setContentSize(NSSize(width: 980, height: 660)) window.minSize = NSSize(width: 780, height: 540) window.styleMask = [.titled, .closable, .miniaturizable, .resizable] window.isReleasedWhenClosed = false window.center() window.makeKeyAndOrderFront(nil) self.window = window NSApp.activate(ignoringOtherApps: true) } } @main struct EveryPublishMacOSApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate var body: some Scene { MenuBarExtra { Button("打开客户端") { appDelegate.showMainWindow() } Divider() Text(appDelegate.controller.status.online ? "已连接" : (appDelegate.controller.coreReady ? "核心已就绪" : "核心未就绪")) Button("退出") { NSApp.terminate(nil) } } label: { Image(systemName: appDelegate.controller.status.online ? "checkmark.circle.fill" : "circle.dashed") } } } struct ContentView: View { @ObservedObject var controller: CoreController @State private var selection = "overview" var body: some View { NavigationSplitView { List(selection: $selection) { Section("连接") { Label("概览", systemImage: "rectangle.3.group").tag("overview") Label("连接客户端", systemImage: "link.badge.plus").tag("pair") Label("挑战中心", systemImage: "qrcode.viewfinder").tag("challenges") } Section("诊断") { Label("活动日志", systemImage: "text.magnifyingglass").tag("logs") Label("设置", systemImage: "gearshape").tag("settings") } } .navigationTitle("EveryPublish") .safeAreaInset(edge: .bottom) { ConnectionBadge(controller: controller) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 14) .padding(.vertical, 10) } } detail: { Group { switch selection { case "pair": PairView(controller: controller) case "challenges": ChallengeView(controller: controller) case "logs": LogView(controller: controller) case "settings": SettingsView(controller: controller) default: OverviewView(controller: controller) } } .padding(28) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Color(nsColor: .windowBackgroundColor)) } .navigationSplitViewStyle(.balanced) .alert("无法完成操作", isPresented: Binding(get: { !controller.errorMessage.isEmpty }, set: { if !$0 { controller.clearMessages() } })) { Button("知道了") { controller.clearMessages() } } message: { Text(controller.errorMessage) } .overlay(alignment: .bottom) { if !controller.notice.isEmpty { Text(controller.notice) .font(.callout) .padding(.horizontal, 16) .padding(.vertical, 10) .background(.regularMaterial, in: Capsule()) .shadow(radius: 8, y: 3) .padding() } } .onReceive(NotificationCenter.default.publisher(for: .showLogs)) { _ in selection = "logs" } .onReceive(NotificationCenter.default.publisher(for: .showSettings)) { _ in selection = "settings" } } } struct PageHeader: View { let title: String let subtitle: String var body: some View { VStack(alignment: .leading, spacing: 5) { Text(title).font(.system(size: 28, weight: .bold, design: .rounded)) Text(subtitle).foregroundStyle(.secondary) } } } struct OverviewView: View { @ObservedObject var controller: CoreController var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { HStack(alignment: .top) { PageHeader(title: "概览", subtitle: "连接状态、待处理挑战和本机核心都在这里。") Spacer() ConnectionBadge(controller: controller) } ConnectionHero(controller: controller) HStack(spacing: 12) { MetricCard(title: "设备", value: controller.status.deviceId == 0 ? "未连接" : "#\(controller.status.deviceId)", icon: "desktopcomputer", tint: .blue) MetricCard(title: "待处理挑战", value: "\(controller.challenges.count)", icon: "qrcode.viewfinder", tint: .purple) MetricCard(title: "核心", value: controller.coreReady ? "就绪" : "未就绪", icon: "cpu", tint: controller.coreReady ? .green : .orange) } GroupBox("连接详情") { VStack(spacing: 0) { InfoRow(title: "服务器", value: controller.status.server.isEmpty ? controller.config.server : controller.status.server, icon: "server.rack") InfoRow(title: "网站", value: controller.config.webUrl, icon: "globe") InfoRow(title: "设备名称", value: controller.status.name.isEmpty ? "未设置" : controller.status.name, icon: "macbook") InfoRow(title: "会话", value: controller.status.session.isEmpty ? "-" : controller.status.session, icon: "rectangle.connected.to.line.below") } } if controller.status.deviceId == 0 { StatusCallout(icon: "link.badge.plus", title: "还没有连接此客户端", message: "打开“连接客户端”页面,在网页端生成一次性连接码后输入。") } else if !controller.status.online { StatusCallout(icon: "wifi.exclamationmark", title: "正在等待服务器连接", message: controller.lastError.isEmpty ? "核心会自动重试,详细过程可在活动日志查看。" : controller.lastError) } HStack(spacing: 10) { Button { controller.openWebsite() } label: { Label("打开网站", systemImage: "safari") }.buttonStyle(.borderedProminent) Button { NotificationCenter.default.post(name: .showLogs, object: nil) } label: { Label("查看日志", systemImage: "text.magnifyingglass") }.buttonStyle(.bordered) Button { controller.restartCore() } label: { Label("重启核心", systemImage: "arrow.clockwise") }.buttonStyle(.bordered) } } } } } struct ConnectionHero: View { @ObservedObject var controller: CoreController var body: some View { HStack(spacing: 16) { Image(systemName: controller.connectionState.icon) .font(.system(size: 30, weight: .semibold)) .foregroundStyle(controller.status.online ? .green : .orange) .frame(width: 58, height: 58) .background((controller.status.online ? Color.green : Color.orange).opacity(0.12), in: RoundedRectangle(cornerRadius: 14)) VStack(alignment: .leading, spacing: 5) { Text(controller.connectionState.title).font(.title2.bold()) Text(controller.lastError.isEmpty ? "本机核心正在维护与服务器的安全连接" : controller.lastError) .foregroundStyle(.secondary) .lineLimit(2) } Spacer() } .padding(18) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) } } private extension Notification.Name { static let showLogs = Notification.Name("EveryPublish.showLogs") static let showSettings = Notification.Name("EveryPublish.showSettings") } struct ConnectionBadge: View { @ObservedObject var controller: CoreController var body: some View { Label(controller.connectionState.title, systemImage: controller.connectionState.icon) .font(.callout.weight(.medium)) .foregroundStyle(controller.status.online ? .green : .orange) .padding(.horizontal, 10) .padding(.vertical, 6) .background((controller.status.online ? Color.green : Color.orange).opacity(0.12), in: Capsule()) } } struct MetricCard: View { let title: String let value: String let icon: String let tint: Color var body: some View { VStack(alignment: .leading, spacing: 8) { Image(systemName: icon).foregroundStyle(tint) Text(title).font(.caption).foregroundStyle(.secondary) Text(value).font(.title3.bold()) } .frame(maxWidth: .infinity, alignment: .leading) .padding(14) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) } } struct InfoRow: View { let title: String let value: String let icon: String var body: some View { HStack(spacing: 10) { Image(systemName: icon).foregroundStyle(.secondary).frame(width: 18) Text(title).foregroundStyle(.secondary) Spacer() Text(value).lineLimit(1).textSelection(.enabled) } .padding(.vertical, 8) } } struct StatusCallout: View { let icon: String let title: String let message: String var body: some View { HStack(alignment: .top, spacing: 10) { Image(systemName: icon).foregroundStyle(.orange) VStack(alignment: .leading, spacing: 3) { Text(title).font(.callout.weight(.semibold)) Text(message).font(.callout).foregroundStyle(.secondary) } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) } } struct PairView: View { @ObservedObject var controller: CoreController @State private var code = "" @State private var name = "" @State private var loading = false @FocusState private var codeFocused: Bool var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { PageHeader(title: "连接客户端", subtitle: "只需要一次授权。连接码由网页端生成,客户端不会生成或保存连接码。") VStack(alignment: .leading, spacing: 0) { StepRow(number: "1", title: "打开网页控制台", detail: "登录后进入“设置 → 客户端连接”。", actionTitle: "打开网站") { controller.openWebsite() } Divider().padding(.leading, 22) StepRow(number: "2", title: "生成一次性连接码", detail: "网页端生成 6 位、5 分钟有效的连接码。每个码只能使用一次。") Divider().padding(.leading, 22) StepRow(number: "3", title: "在这里输入并连接", detail: "输入连接码后,核心会自动建立安全通道。") } .padding(16) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) VStack(alignment: .leading, spacing: 12) { Text("输入连接码").font(.headline) TextField("6 位连接码", text: $code) .textFieldStyle(.roundedBorder) .textCase(.uppercase) .focused($codeFocused) .onChange(of: code) { value in code = String(value.uppercased().filter { $0.isLetter || $0.isNumber }.prefix(6)) } TextField("客户端名称(可选)", text: $name) .textFieldStyle(.roundedBorder) HStack { Text("服务器:\(controller.config.server)") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) Spacer() Button("去设置修改") { NotificationCenter.default.post(name: .showSettings, object: nil) }.buttonStyle(.link) } Button { loading = true Task { await controller.pair(code: code, name: name) loading = false } } label: { Label(loading ? "连接中…" : "连接客户端", systemImage: "link") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .disabled(loading || !CoreController.isValidPairCode(code.uppercased())) } .padding(18) .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 14)) if controller.status.deviceId > 0 { StatusCallout(icon: "checkmark.seal", title: "此客户端已连接过", message: "设备编号 #\(controller.status.deviceId)。如需换工作区,请先在设置中清理本地数据。") } } } .onAppear { codeFocused = controller.status.deviceId == 0 } } } struct StepRow: View { let number: String let title: String let detail: String var actionTitle: String? = nil var action: (() -> Void)? = nil var body: some View { HStack(alignment: .top, spacing: 12) { Text(number).font(.callout.bold()).foregroundStyle(.white).frame(width: 22, height: 22).background(Color.accentColor, in: Circle()) VStack(alignment: .leading, spacing: 3) { HStack { Text(title).font(.callout.weight(.semibold)) Spacer() if let actionTitle, let action { Button(actionTitle, action: action).buttonStyle(.link) } } Text(detail).font(.callout).foregroundStyle(.secondary) } } .padding(.vertical, 9) } } struct ChallengeView: View { @ObservedObject var controller: CoreController @State private var selected: Challenge? @State private var value = "" @State private var loading = false var body: some View { VStack(alignment: .leading, spacing: 18) { HStack { PageHeader(title: "挑战中心", subtitle: "扫码、验证码和应用确认都在这里处理。") Spacer() Text("\(controller.challenges.count) 项").foregroundStyle(.secondary) } if controller.challenges.isEmpty { EmptyState(icon: "checkmark.seal", title: "暂无待处理挑战", message: "平台要求扫码或验证码时,挑战会出现在这里。") } else { HStack(alignment: .top, spacing: 16) { List(controller.challenges, selection: $selected) { challenge in VStack(alignment: .leading, spacing: 4) { Text("\(challenge.platform.isEmpty ? "平台" : challenge.platform) · \(challenge.kind)").font(.callout.weight(.semibold)) Text(challenge.prompt.isEmpty ? "等待处理" : challenge.prompt).font(.caption).foregroundStyle(.secondary).lineLimit(2) } .padding(.vertical, 4) .tag(challenge) } .frame(minWidth: 260, idealWidth: 300) if let selected { ChallengeDetail(challenge: selected, value: $value, loading: $loading) { Task { loading = true await controller.solve(challenge: selected, value: value.isEmpty ? "confirmed" : value) value = "" loading = false } } } else { EmptyState(icon: "cursorarrow.click", title: "选择一项挑战", message: "右侧会显示二维码、提示和提交入口。") } } } } } } struct ChallengeDetail: View { let challenge: Challenge @Binding var value: String @Binding var loading: Bool let submit: () -> Void var body: some View { VStack(alignment: .leading, spacing: 14) { Text("处理挑战").font(.headline) if !challenge.qrUrl.isEmpty || !challenge.qrToken.isEmpty { QRCodeView(value: challenge.qrUrl.isEmpty ? challenge.qrToken : challenge.qrUrl) .frame(width: 210, height: 210) .padding(10) .background(.white, in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(.quaternary)) Text(challenge.qrUrl.isEmpty ? challenge.qrToken : challenge.qrUrl) .font(.caption).foregroundStyle(.secondary).textSelection(.enabled).lineLimit(2) } if !challenge.prompt.isEmpty { Text(challenge.prompt).font(.callout).foregroundStyle(.secondary) } TextField("验证码或确认值(扫码后可留空)", text: $value) .textFieldStyle(.roundedBorder) Button { submit() } label: { Label(loading ? "提交中…" : "提交验证", systemImage: "checkmark") } .buttonStyle(.borderedProminent) .disabled(loading) Spacer() } .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) } } struct QRCodeView: View { let value: String var body: some View { if let image = makeImage(value) { Image(nsImage: image).interpolation(.none).resizable().scaledToFit() } else { Image(systemName: "qrcode").font(.system(size: 54)).foregroundStyle(.secondary) } } private func makeImage(_ value: String) -> NSImage? { guard let data = value.data(using: .utf8) else { return nil } let filter = CIFilter.qrCodeGenerator() filter.setValue(data, forKey: "inputMessage") guard let output = filter.outputImage?.transformed(by: CGAffineTransform(scaleX: 8, y: 8)) else { return nil } let rep = NSCIImageRep(ciImage: output) let image = NSImage(size: rep.size) image.addRepresentation(rep) return image } } struct EmptyState: View { let icon: String let title: String let message: String var body: some View { VStack(spacing: 9) { Image(systemName: icon).font(.system(size: 36)).foregroundStyle(.secondary) Text(title).font(.headline) Text(message).font(.callout).foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } struct LogView: View { @ObservedObject var controller: CoreController @State private var filter = "" @State private var paused = false private var visibleLogs: String { let lines = controller.logs.split(separator: "\n", omittingEmptySubsequences: false) let filtered = filter.isEmpty ? lines : lines.filter { $0.localizedCaseInsensitiveContains(filter) } return filtered.joined(separator: "\n") } var body: some View { VStack(alignment: .leading, spacing: 14) { HStack { PageHeader(title: "活动日志", subtitle: "连接、配对、挑战和任务下发的原始事件。") Spacer() Button { paused.toggle() } label: { Label(paused ? "继续" : "暂停", systemImage: paused ? "play.fill" : "pause.fill") }.buttonStyle(.bordered) Button { controller.refreshLogs() } label: { Label("刷新", systemImage: "arrow.clockwise") }.buttonStyle(.bordered) Button { controller.exportLogs() } label: { Label("导出", systemImage: "square.and.arrow.up") }.buttonStyle(.bordered) Button(role: .destructive) { controller.clearLogs() } label: { Label("清空", systemImage: "trash") }.buttonStyle(.bordered) } TextField("筛选日志,例如 ws、error、task", text: $filter).textFieldStyle(.roundedBorder) ScrollViewReader { proxy in ScrollView { Text(visibleLogs.isEmpty ? "暂无日志" : visibleLogs) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(14) .id("log-end") } .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(.quaternary)) .onChange(of: controller.logs) { _ in if !paused { proxy.scrollTo("log-end", anchor: .bottom) } } } } .onAppear { controller.refreshLogs() } } } struct SettingsView: View { @ObservedObject var controller: CoreController @State private var server = "" @State private var webUrl = "" @State private var fakeAll = false @State private var loginItem = false var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { PageHeader(title: "设置", subtitle: "配置服务器地址、本机执行模式和启动行为。") Form { Section("连接") { TextField("服务器地址", text: $server) TextField("网站地址", text: $webUrl) Text("连接码只在网页端生成;如果服务器是 Web-only/mock 模式,不需要客户端配对。") .font(.caption).foregroundStyle(.secondary) } Section("本机") { Toggle("模拟执行(本地联调)", isOn: $fakeAll) Toggle("登录时自动启动", isOn: $loginItem) } Section { Button { controller.saveAndRestart(server: server, webUrl: webUrl, fakeAll: fakeAll, loginItem: loginItem) } label: { Label("保存并重启核心", systemImage: "arrow.clockwise") }.buttonStyle(.borderedProminent) Button { controller.openDataDirectory() } label: { Label("打开数据目录", systemImage: "folder") }.buttonStyle(.bordered) } } .formStyle(.grouped) Text("本地数据目录:\(FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("EveryPublish/data").path)") .font(.caption).foregroundStyle(.secondary).textSelection(.enabled) } } .onAppear { server = controller.config.server webUrl = controller.config.webUrl fakeAll = controller.config.fakeAll } } }