Files
EveryPublish/client/macos/EveryPublishMacOSApp.swift
T

1018 lines
42 KiB
Swift

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<Void, Never>?
private var knownChallengeIds = Set<String>()
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<T: Decodable, Body: Encodable>(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
}
}
}