1018 lines
42 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|