From 12ea0da0f78ebb1de2e51610adfc73fdf81e1c18 Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Thu, 23 Jul 2026 01:23:26 -0700 Subject: [PATCH 1/8] feat: add persistent authenticated BabelDOC executor --- .../GlossCore/BabelDOCExecutorClient.swift | 1393 +++++++++++++++ .../GlossCore/BabelDOCExternalEngine.swift | 145 +- .../GlossCore/BabelDOCServiceSession.swift | 1568 ++++++++++++++++- .../BabelDOCExecutorClientTests.swift | 815 +++++++++ .../BabelDOCExternalEngineTests.swift | 10 + .../BabelDOCServiceSessionTests.swift | 369 ++++ 6 files changed, 4246 insertions(+), 54 deletions(-) create mode 100644 Sources/GlossCore/BabelDOCExecutorClient.swift create mode 100644 Tests/GlossCoreTests/BabelDOCExecutorClientTests.swift diff --git a/Sources/GlossCore/BabelDOCExecutorClient.swift b/Sources/GlossCore/BabelDOCExecutorClient.swift new file mode 100644 index 0000000..1ad7a1a --- /dev/null +++ b/Sources/GlossCore/BabelDOCExecutorClient.swift @@ -0,0 +1,1393 @@ +import Foundation + +public enum BabelDOCExecutorLifecycleState: String, Codable, Sendable { + case stopped + case starting + case ready + case reconnecting + case stopping + case failed +} + +public struct BabelDOCExecutorExecutionSnapshot: Codable, Equatable, Sendable { + public let executionID: String + public let taskID: String + public let status: String + public let initialSequence: Int64 + public let firstAvailableSequence: Int64? + public let lastSequence: Int64 + public let workerFinished: Bool + public let createdAt: Double + public let finishedAt: Double? + + enum CodingKeys: String, CodingKey { + case executionID = "execution_id" + case taskID = "task_id" + case status + case initialSequence = "initial_sequence" + case firstAvailableSequence = "first_available_sequence" + case lastSequence = "last_sequence" + case workerFinished = "worker_finished" + case createdAt = "created_at" + case finishedAt = "finished_at" + } + + public var isTerminal: Bool { + status == "succeeded" || status == "failed" || status == "cancelled" + } +} + +public struct BabelDOCExecutorServiceSnapshot: Equatable, Sendable { + public let installed: Bool + public let runtimeVersion: String? + public let endpoint: URL? + public let processIdentifier: Int32? + public let processStartTime: Double? + public let instanceID: String? + public let lifecycleState: BabelDOCExecutorLifecycleState + public let activeTaskID: String? + public let activeExecutionID: String? + public let activeStatus: String? + public let activeProgress: Double? + public let lastError: String? + + public init( + installed: Bool, + runtimeVersion: String? = nil, + endpoint: URL? = nil, + processIdentifier: Int32? = nil, + processStartTime: Double? = nil, + instanceID: String? = nil, + lifecycleState: BabelDOCExecutorLifecycleState, + activeTaskID: String? = nil, + activeExecutionID: String? = nil, + activeStatus: String? = nil, + activeProgress: Double? = nil, + lastError: String? = nil + ) { + self.installed = installed + self.runtimeVersion = runtimeVersion + self.endpoint = endpoint + self.processIdentifier = processIdentifier + self.processStartTime = processStartTime + self.instanceID = instanceID + self.lifecycleState = lifecycleState + self.activeTaskID = activeTaskID + self.activeExecutionID = activeExecutionID + self.activeStatus = activeStatus + self.activeProgress = activeProgress + self.lastError = lastError + } +} + +public struct BabelDOCExecutorConnection: Sendable { + public let baseURL: URL + public let bearerToken: String + public let workrootURL: URL + public let layoutServiceBaseURL: URL + public let instanceID: String + public let processIdentifier: Int32 + public let processStartTime: Double? + public let parentProcessIdentifier: Int32? + public let runtimeVersion: String + let stateHandler: @Sendable (BabelDOCExecutorClientState) -> Void + + public init( + baseURL: URL, + bearerToken: String, + workrootURL: URL, + layoutServiceBaseURL: URL, + instanceID: String, + processIdentifier: Int32, + processStartTime: Double? = nil, + parentProcessIdentifier: Int32? = Int32(ProcessInfo.processInfo.processIdentifier), + runtimeVersion: String + ) { + self.baseURL = baseURL + self.bearerToken = bearerToken + self.workrootURL = workrootURL + self.layoutServiceBaseURL = layoutServiceBaseURL + self.instanceID = instanceID + self.processIdentifier = processIdentifier + self.processStartTime = processStartTime + self.parentProcessIdentifier = parentProcessIdentifier + self.runtimeVersion = runtimeVersion + self.stateHandler = { _ in } + } + + init( + baseURL: URL, + bearerToken: String, + workrootURL: URL, + layoutServiceBaseURL: URL, + instanceID: String, + processIdentifier: Int32, + processStartTime: Double?, + parentProcessIdentifier: Int32? = Int32(ProcessInfo.processInfo.processIdentifier), + runtimeVersion: String, + _stateHandler: @escaping @Sendable (BabelDOCExecutorClientState) -> Void + ) { + self.baseURL = baseURL + self.bearerToken = bearerToken + self.workrootURL = workrootURL + self.layoutServiceBaseURL = layoutServiceBaseURL + self.instanceID = instanceID + self.processIdentifier = processIdentifier + self.processStartTime = processStartTime + self.parentProcessIdentifier = parentProcessIdentifier + self.runtimeVersion = runtimeVersion + self.stateHandler = _stateHandler + } +} + +public protocol BabelDOCExecutorManaging: Sendable { + func executorConnection( + runtime: BabelDOCRuntimeLaunch, + timeout: Duration + ) async throws -> BabelDOCExecutorConnection +} + +public enum BabelDOCLegacyFallbackPolicy: Equatable, Sendable { + /// Only runtimes without the executor protocol may use the transitional CLI path. + case unsupportedRuntimeOnly + /// Require the authenticated executor for every translation. + case never +} + +public enum BabelDOCExecutorError: LocalizedError, Equatable, Sendable { + case unsupportedRuntime + case unavailable(String) + case incompatibleRuntime(String) + case authenticationFailed + case invalidResponse(String) + case serviceError(status: Int, code: String, message: String) + case busy(BabelDOCExecutorExecutionSnapshot?) + case replayGap(BabelDOCExecutorExecutionSnapshot?) + case cursorAhead(BabelDOCExecutorExecutionSnapshot?) + case executionFailed(code: String, message: String) + case executionCancelled + case outputMissing + + public var errorDescription: String? { + switch self { + case .unsupportedRuntime: + "已安装的 BabelDOC 尚不支持常驻执行服务。" + case .unavailable(let message): + "BabelDOC 常驻执行服务不可用:\(message)" + case .incompatibleRuntime(let message): + "BabelDOC 运行时不兼容:\(message)" + case .authenticationFailed: + "BabelDOC 常驻执行服务认证失败。" + case .invalidResponse(let message): + "BabelDOC 常驻执行服务返回了无效响应:\(message)" + case .serviceError(_, _, let message): + "BabelDOC 常驻执行服务失败:\(message)" + case .busy(let snapshot): + if let taskID = snapshot?.taskID { + "BabelDOC 正在处理任务 \(taskID)。" + } else { + "BabelDOC 正在处理其他任务。" + } + case .replayGap: + "BabelDOC 进度历史已过期,正在使用权威任务快照恢复。" + case .cursorAhead: + "BabelDOC 进度游标与服务状态不一致。" + case .executionFailed(_, let message): + "BabelDOC 处理失败:\(message)" + case .executionCancelled: + "BabelDOC 任务已取消。" + case .outputMissing: + "BabelDOC 已结束,但没有生成可用的 PDF。" + } + } +} + +struct BabelDOCExecutorClientState: Sendable { + let taskID: String? + let executionID: String? + let status: String? + let progress: Double? + let error: String? +} + +actor BabelDOCExecutorConnectionRegistry { + static let shared = BabelDOCExecutorConnectionRegistry() + + private var connectionsByLayoutURL: [String: BabelDOCExecutorConnection] = [:] + + func register(_ connection: BabelDOCExecutorConnection) { + connectionsByLayoutURL[Self.key(connection.layoutServiceBaseURL)] = connection + } + + func unregister(layoutServiceBaseURL: URL) { + connectionsByLayoutURL.removeValue(forKey: Self.key(layoutServiceBaseURL)) + } + + func connection(layoutServiceBaseURL: URL) -> BabelDOCExecutorConnection? { + connectionsByLayoutURL[Self.key(layoutServiceBaseURL)] + } + + private static func key(_ url: URL) -> String { + url.absoluteURL.standardized.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } +} + +public struct BabelDOCExecutorRuntimeResponse: Decodable, Sendable { + public struct Runtime: Decodable, Sendable { + public let name: String + public let version: String + } + + public struct Service: Decodable, Sendable { + public let serviceID: String + public let instanceID: String + public let pid: Int32 + public let processStartTime: Double? + public let endpoint: String + public let parentPID: Int32? + public let parentStartTime: Double? + + enum CodingKeys: String, CodingKey { + case serviceID = "service_id" + case instanceID = "instance_id" + case pid + case processStartTime = "process_start_time" + case endpoint + case parentPID = "parent_pid" + case parentStartTime = "parent_start_time" + } + } + + public let runtimeAPIVersion: Int + public let runtime: Runtime + public let capabilities: [String] + public let service: Service + + enum CodingKeys: String, CodingKey { + case runtimeAPIVersion = "runtime_api_version" + case runtime + case capabilities + case service + } +} + +private struct BabelDOCExecutorHealthResponse: Decodable { + let ok: Bool + let serviceID: String + let instanceID: String + let pid: Int32 + let processStartTime: Double? + let endpoint: String + let parentPID: Int32? + + enum CodingKeys: String, CodingKey { + case ok + case serviceID = "service_id" + case instanceID = "instance_id" + case pid + case processStartTime = "process_start_time" + case endpoint + case parentPID = "parent_pid" + } +} + +private struct BabelDOCExecutionContainer: Decodable { + let execution: BabelDOCExecutorExecutionSnapshot? +} + +private struct BabelDOCExecutionCreated: Decodable { + let executionID: String + let status: String + let initialSequence: Int64 + let replayed: Bool + + enum CodingKeys: String, CodingKey { + case executionID = "execution_id" + case status + case initialSequence = "initial_sequence" + case replayed + } +} + +private struct BabelDOCExecutorErrorPayload: Decodable { + let code: String + let message: String + let snapshot: BabelDOCExecutorExecutionSnapshot? +} + +private struct BabelDOCExecutorEvent: Decodable { + let schemaVersion: Int + let serviceID: String + let instanceID: String + let type: String + let executionID: String + let sequence: Int64? + let payload: JSONValue + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case serviceID = "service_id" + case instanceID = "instance_id" + case type + case executionID = "execution_id" + case sequence + case payload + } +} + +private struct BabelDOCExecutorPerformance: Decodable { + struct PhaseTimings: Decodable { + let launching: Int + let parsing: Int + let translating: Int + let typesetting: Int + let saving: Int + let finalizing: Int + } + + let phase: String + let elapsedMilliseconds: Int + let phaseTimingsMilliseconds: PhaseTimings + let layoutIRCacheStatus: String? + + enum CodingKeys: String, CodingKey { + case phase + case elapsedMilliseconds = "elapsed_milliseconds" + case phaseTimingsMilliseconds = "phase_timings_milliseconds" + case layoutIRCacheStatus = "layout_ir_cache_status" + } + + var timings: BabelDOCPhaseTimings { + BabelDOCPhaseTimings( + launchingMilliseconds: phaseTimingsMilliseconds.launching, + parsingMilliseconds: phaseTimingsMilliseconds.parsing, + translatingMilliseconds: phaseTimingsMilliseconds.translating, + typesettingMilliseconds: phaseTimingsMilliseconds.typesetting, + savingMilliseconds: phaseTimingsMilliseconds.saving, + finalizingMilliseconds: phaseTimingsMilliseconds.finalizing + ) + } +} + +private struct BabelDOCExecutionRequestBody: Encodable { + struct Paths: Encodable { + let inputFile: String + let outputDir: String + let workingDir: String + + enum CodingKeys: String, CodingKey { + case inputFile = "input_file" + case outputDir = "output_dir" + case workingDir = "working_dir" + } + } + + struct TranslationConfiguration: Encodable { + let debug = false + let langIn: String + let langOut: String + let pages: String? = nil + let noDual: Bool + let noMono: Bool + let skipClean: Bool + let dualTranslateFirst = false + let disableRichTextTranslate = true + let useSideBySideDual = false + let useAlternatingPagesDual = false + let skipScannedDetection: Bool + let ocrWorkaround = false + let customSystemPrompt: String? = nil + let primaryFontFamily: String? = nil + let autoExtractGlossary = false + let autoEnableOCRWorkaround = false + let onlyIncludeTranslatedPage = false + let mergeAlternatingLineNumbers = true + let removeNonFormulaLines = false + + enum CodingKeys: String, CodingKey { + case debug + case langIn = "lang_in" + case langOut = "lang_out" + case pages + case noDual = "no_dual" + case noMono = "no_mono" + case skipClean = "skip_clean" + case dualTranslateFirst = "dual_translate_first" + case disableRichTextTranslate = "disable_rich_text_translate" + case useSideBySideDual = "use_side_by_side_dual" + case useAlternatingPagesDual = "use_alternating_pages_dual" + case skipScannedDetection = "skip_scanned_detection" + case ocrWorkaround = "ocr_workaround" + case customSystemPrompt = "custom_system_prompt" + case primaryFontFamily = "primary_font_family" + case autoExtractGlossary = "auto_extract_glossary" + case autoEnableOCRWorkaround = "auto_enable_ocr_workaround" + case onlyIncludeTranslatedPage = "only_include_translated_page" + case mergeAlternatingLineNumbers = "merge_alternating_line_numbers" + case removeNonFormulaLines = "remove_non_formula_lines" + } + } + + struct RuntimeLimits: Encodable { + let qps: Int + let reportIntervalSeconds: Double + let maxPagesPerPart: Int + let poolMaxWorkers: Int + let termPoolMaxWorkers: Int + + enum CodingKeys: String, CodingKey { + case qps + case reportIntervalSeconds = "report_interval_seconds" + case maxPagesPerPart = "max_pages_per_part" + case poolMaxWorkers = "pool_max_workers" + case termPoolMaxWorkers = "term_pool_max_workers" + } + } + + struct Gateway: Encodable { + let model: String + let baseURL: String + let apiKey: String + + enum CodingKeys: String, CodingKey { + case model + case baseURL = "base_url" + case apiKey = "api_key" + } + } + + struct LayoutGateway: Encodable { + let adapter = "rpc_doclayout8" + let baseURL: String + let requiresLineExtraction = false + + enum CodingKeys: String, CodingKey { + case adapter + case baseURL = "base_url" + case requiresLineExtraction = "requires_line_extraction" + } + } + + struct Gateways: Encodable { + let mainLLM: Gateway + let ateLLM: Gateway + let layout: LayoutGateway + + enum CodingKeys: String, CodingKey { + case mainLLM = "main_llm" + case ateLLM = "ate_llm" + case layout + } + } + + struct Assets: Encodable { + struct LayoutIRCache: Encodable { + let enabled: Bool + } + + let glossaries: [String] = [] + let layoutIRCache: LayoutIRCache + + enum CodingKeys: String, CodingKey { + case glossaries + case layoutIRCache = "layout_ir_cache" + } + } + + struct Metadata: Encodable { + let metadataExtraData: String? = nil + + enum CodingKeys: String, CodingKey { + case metadataExtraData = "metadata_extra_data" + } + } + + let taskID: String + let paths: Paths + let translationConfig: TranslationConfiguration + let runtimeLimits: RuntimeLimits + let gateways: Gateways + let assets: Assets + let metadata = Metadata() + + enum CodingKeys: String, CodingKey { + case taskID = "task_id" + case paths + case translationConfig = "translation_config" + case runtimeLimits = "runtime_limits" + case gateways + case assets + case metadata + } +} + +private final class BabelDOCExecutionIDBox: @unchecked Sendable { + private let lock = NSLock() + private var value: String? + + func set(_ newValue: String) { + lock.lock() + value = newValue + lock.unlock() + } + + func get() -> String? { + lock.lock() + defer { lock.unlock() } + return value + } +} + +private final class BabelDOCJobCleanupBox: @unchecked Sendable { + private let lock = NSLock() + private var removeJobDirectory = true + + func preserve() { + lock.lock() + removeJobDirectory = false + lock.unlock() + } + + func shouldRemove() -> Bool { + lock.lock() + defer { lock.unlock() } + return removeJobDirectory + } +} + +public struct BabelDOCExecutorClient: Sendable { + private static let eventStreamRequestTimeout: TimeInterval = 24 * 60 * 60 + + public let connection: BabelDOCExecutorConnection + private let session: URLSession + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + public init( + connection: BabelDOCExecutorConnection, + sessionConfiguration: URLSessionConfiguration = .ephemeral + ) { + self.connection = connection + let configuration = + sessionConfiguration.copy() as? URLSessionConfiguration + ?? sessionConfiguration + configuration.waitsForConnectivity = false + configuration.timeoutIntervalForRequest = 15 + configuration.timeoutIntervalForResource = 24 * 60 * 60 + configuration.httpAdditionalHeaders = [ + "Accept": "application/json", + "Cache-Control": "no-store", + ] + self.session = URLSession(configuration: configuration) + } + + public func runtime( + requireCurrentParent: Bool = true + ) async throws -> BabelDOCExecutorRuntimeResponse { + let response: BabelDOCExecutorRuntimeResponse = try await json( + method: "GET", + path: "/v1/runtime" + ) + try Self.validateRuntime( + response, + connection: connection, + requireCurrentParent: requireCurrentParent + ) + return response + } + + public func health(requireCurrentParent: Bool = true) async throws -> Bool { + let response: BabelDOCExecutorHealthResponse = try await json( + method: "GET", + path: "/healthz" + ) + guard response.ok, + response.serviceID == "gloss-babeldoc", + response.instanceID == connection.instanceID, + response.pid == connection.processIdentifier, + URL(string: response.endpoint) == connection.baseURL + else { + throw BabelDOCExecutorError.incompatibleRuntime( + "服务身份与已保存会话不一致" + ) + } + let expectedParent = + requireCurrentParent + ? Int32(ProcessInfo.processInfo.processIdentifier) + : connection.parentProcessIdentifier + if let expectedParent, response.parentPID != expectedParent { + throw BabelDOCExecutorError.incompatibleRuntime("服务父进程身份不一致") + } + if let expected = connection.processStartTime, + let actual = response.processStartTime, + abs(expected - actual) > 0.01 + { + throw BabelDOCExecutorError.incompatibleRuntime("进程启动时间不一致") + } + return true + } + + public func currentExecution() async throws -> BabelDOCExecutorExecutionSnapshot? { + let response: BabelDOCExecutionContainer = try await json( + method: "GET", + path: "/v1/executions/current" + ) + return response.execution + } + + public func latestExecution() async throws -> BabelDOCExecutorExecutionSnapshot? { + let response: BabelDOCExecutionContainer = try await json( + method: "GET", + path: "/v1/executions/latest" + ) + return response.execution + } + + public func execution( + id: String + ) async throws -> BabelDOCExecutorExecutionSnapshot { + try await json(method: "GET", path: "/v1/executions/\(id)") + } + + @discardableResult + public func cancel( + executionID: String + ) async throws -> BabelDOCExecutorExecutionSnapshot { + try await json( + method: "POST", + path: "/v1/executions/\(executionID)/cancel", + body: Data("{}".utf8) + ) + } + + public func cancelCurrent() async throws { + guard let current = try await currentExecution() else { return } + _ = try await cancel(executionID: current.executionID) + } + + public func shutdown(cancelActive: Bool = true) async throws { + struct ShutdownBody: Encodable { + let cancelActive: Bool + + enum CodingKeys: String, CodingKey { + case cancelActive = "cancel_active" + } + } + struct ShutdownResponse: Decodable { + let status: String + } + let body = try encoder.encode(ShutdownBody(cancelActive: cancelActive)) + let response: ShutdownResponse = try await json( + method: "POST", + path: "/v1/shutdown", + body: body + ) + guard response.status == "stopping" else { + throw BabelDOCExecutorError.invalidResponse("shutdown status") + } + } + + func translate( + _ request: BabelDOCTranslationRequest, + onOutput: (@Sendable (String) -> Void)?, + onProgress: (@Sendable (BabelDOCProgressUpdate) -> Void)? + ) async throws -> BabelDOCTranslationResult { + let taskID = "gloss-\(UUID().uuidString.lowercased())" + let jobDirectory = connection.workrootURL + .appendingPathComponent("jobs", isDirectory: true) + .appendingPathComponent(taskID, isDirectory: true) + let inputDirectory = jobDirectory.appendingPathComponent("input", isDirectory: true) + let executorOutput = jobDirectory.appendingPathComponent("output", isDirectory: true) + let executorWorking = jobDirectory.appendingPathComponent("working", isDirectory: true) + let stagedInput = inputDirectory.appendingPathComponent( + request.inputURL.lastPathComponent.isEmpty ? "input.pdf" : request.inputURL.lastPathComponent + ) + try Self.preparePrivateDirectory(inputDirectory) + try Self.preparePrivateDirectory(executorOutput) + try Self.preparePrivateDirectory(executorWorking) + do { + try FileManager.default.copyItem(at: request.inputURL, to: stagedInput) + } catch { + try? FileManager.default.removeItem(at: jobDirectory) + throw error + } + let jobCleanup = BabelDOCJobCleanupBox() + defer { + if jobCleanup.shouldRemove() { + try? FileManager.default.removeItem(at: jobDirectory) + } + } + + let relativeInput = try Self.relative(stagedInput, to: connection.workrootURL) + let relativeOutput = try Self.relative(executorOutput, to: connection.workrootURL) + let relativeWorking = try Self.relative(executorWorking, to: connection.workrootURL) + let gateway = BabelDOCExecutionRequestBody.Gateway( + model: "gloss-provider", + baseURL: request.bridgeBaseURL.absoluteString, + apiKey: request.bridgeToken + ) + let body = BabelDOCExecutionRequestBody( + taskID: taskID, + paths: .init( + inputFile: relativeInput, + outputDir: relativeOutput, + workingDir: relativeWorking + ), + translationConfig: .init( + langIn: request.sourceLanguageCode.lowercased(), + langOut: request.targetLanguageCode, + noDual: request.outputMode == .monolingual, + noMono: request.outputMode == .bilingual, + skipClean: + ProcessInfo.processInfo.environment["GLOSS_BABELDOC_SKIP_CLEAN"] == "1", + skipScannedDetection: request.skipScannedDetection + ), + runtimeLimits: .init( + qps: request.qps, + reportIntervalSeconds: 0.25, + maxPagesPerPart: request.maximumPagesPerPart, + poolMaxWorkers: request.qps, + termPoolMaxWorkers: request.qps + ), + gateways: .init( + mainLLM: gateway, + ateLLM: gateway, + layout: .init(baseURL: connection.layoutServiceBaseURL.absoluteString) + ), + assets: .init( + layoutIRCache: .init(enabled: request.layoutCacheDirectoryURL != nil) + ) + ) + let executionID = BabelDOCExecutionIDBox() + let timeline = BabelDOCExternalEngine.ProgressTimeline() + onProgress?(timeline.initialUpdate()) + connection.stateHandler( + .init( + taskID: taskID, + executionID: nil, + status: "submitting", + progress: 0, + error: nil + ) + ) + + return try await withTaskCancellationHandler { + do { + let created: BabelDOCExecutionCreated = try await json( + method: "POST", + path: "/v1/executions", + body: try encoder.encode(body) + ) + executionID.set(created.executionID) + connection.stateHandler( + .init( + taskID: taskID, + executionID: created.executionID, + status: created.status, + progress: 0, + error: nil + ) + ) + let resultPayload = try await consumeEvents( + executionID: created.executionID, + initialSequence: created.initialSequence, + executorOutput: executorOutput, + timeline: timeline, + onOutput: onOutput, + onProgress: onProgress + ) + let result = try materializeResult( + resultPayload, + executorOutput: executorOutput, + destination: request.outputDirectory, + timeline: timeline, + onProgress: onProgress + ) + connection.stateHandler( + .init( + taskID: taskID, + executionID: created.executionID, + status: "succeeded", + progress: 100, + error: nil + ) + ) + return result + } catch is CancellationError { + let reachedTerminal = await waitForCancelledWorker( + executionID: executionID.get(), + taskID: taskID + ) + if !reachedTerminal { + jobCleanup.preserve() + } + connection.stateHandler( + .init( + taskID: taskID, + executionID: executionID.get(), + status: "cancelled", + progress: nil, + error: nil + ) + ) + throw CancellationError() + } catch BabelDOCExecutorError.executionCancelled { + let reachedTerminal = await waitForCancelledWorker( + executionID: executionID.get(), + taskID: taskID + ) + if !reachedTerminal { + jobCleanup.preserve() + } + connection.stateHandler( + .init( + taskID: taskID, + executionID: executionID.get(), + status: "cancelled", + progress: nil, + error: nil + ) + ) + throw CancellationError() + } catch { + let reachedTerminal = await waitForCancelledWorker( + executionID: executionID.get(), + taskID: taskID + ) + if !reachedTerminal { + jobCleanup.preserve() + } + connection.stateHandler( + .init( + taskID: taskID, + executionID: executionID.get(), + status: "failed", + progress: nil, + error: error.localizedDescription + ) + ) + throw error + } + } onCancel: { + guard let value = executionID.get() else { return } + Task { + _ = try? await cancel(executionID: value) + } + } + } + + func waitForCancelledWorker( + executionID: String?, + taskID: String, + registrationTimeout: Duration = .seconds(1), + registrationPollInterval: Duration = .milliseconds(50) + ) async -> Bool { + await Task.detached(priority: .utility) { + var resolvedExecutionID = executionID + if resolvedExecutionID == nil { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: registrationTimeout) + while resolvedExecutionID == nil { + if let current = try? await self.currentExecution(), + current.taskID == taskID + { + resolvedExecutionID = current.executionID + break + } + guard clock.now < deadline else { + return false + } + try? await Task.sleep(for: registrationPollInterval) + } + } + guard let resolvedExecutionID else { + return false + } + + _ = try? await self.cancel(executionID: resolvedExecutionID) + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(10)) + while clock.now < deadline { + if let snapshot = try? await self.execution(id: resolvedExecutionID), + snapshot.isTerminal, + snapshot.workerFinished + { + return true + } + try? await Task.sleep(for: .milliseconds(100)) + } + return false + }.value + } + + private func consumeEvents( + executionID: String, + initialSequence: Int64, + executorOutput: URL, + timeline: BabelDOCExternalEngine.ProgressTimeline, + onOutput: (@Sendable (String) -> Void)?, + onProgress: (@Sendable (BabelDOCProgressUpdate) -> Void)? + ) async throws -> JSONValue? { + var cursor = initialSequence + var reconnectAttempts = 0 + while true { + try Task.checkCancellation() + do { + var receivedEvent = false + let events = eventStream( + executionID: executionID, + afterSequence: cursor + ) + for try await event in events { + receivedEvent = true + reconnectAttempts = 0 + guard event.schemaVersion == 1, + event.serviceID == "gloss-babeldoc", + event.instanceID == connection.instanceID, + event.executionID == executionID + else { + throw BabelDOCExecutorError.incompatibleRuntime( + "事件流服务身份不一致" + ) + } + if let sequence = event.sequence { + guard sequence > cursor else { continue } + cursor = sequence + } + if event.type != "heartbeat" { + let line = + try String( + data: encoder.encode(event.payload), + encoding: .utf8 + ) ?? "" + if !line.isEmpty { + onOutput?(line + "\n") + } + } + switch event.type { + case "progress": + if let update = + Self.performanceProgress(from: event.payload) + ?? Self.progressEvent(from: event.payload) + .flatMap(timeline.update) + { + onProgress?(update) + connection.stateHandler( + .init( + taskID: nil, + executionID: executionID, + status: "running", + progress: update.overallProgress, + error: nil + ) + ) + } + case "result": + return event.payload + case "error": + let code = event.payload["code"]?.stringValue ?? "babeldoc_failed" + let message = + event.payload["message_for_user"]?.stringValue + ?? event.payload["message"]?.stringValue + ?? "translation failed" + throw BabelDOCExecutorError.executionFailed( + code: code, + message: message + ) + case "cancelled": + throw BabelDOCExecutorError.executionCancelled + case "stream_error": + let snapshot = try Self.snapshot(from: event.payload["snapshot"]) + if let snapshot, snapshot.isTerminal { + return try terminalRecovery( + snapshot, + executorOutput: executorOutput + ) + } + throw BabelDOCExecutorError.replayGap(snapshot) + default: + continue + } + } + if !receivedEvent { + let snapshot = try await execution(id: executionID) + if snapshot.isTerminal { + return try terminalRecovery(snapshot, executorOutput: executorOutput) + } + try await Task.sleep(for: .milliseconds(150)) + } + } catch let error as BabelDOCExecutorError { + switch error { + case .replayGap(let snapshot), .cursorAhead(let snapshot): + if let snapshot, snapshot.isTerminal { + return try terminalRecovery(snapshot, executorOutput: executorOutput) + } + throw error + case .unavailable where reconnectAttempts < 3: + reconnectAttempts += 1 + try await Task.sleep( + for: .milliseconds(150 * reconnectAttempts) + ) + continue + default: + throw error + } + } catch where reconnectAttempts < 3 { + reconnectAttempts += 1 + let snapshot = try? await execution(id: executionID) + if let snapshot, snapshot.isTerminal { + return try terminalRecovery(snapshot, executorOutput: executorOutput) + } + try await Task.sleep(for: .milliseconds(150 * reconnectAttempts)) + } + } + } + + private func eventStream( + executionID: String, + afterSequence: Int64 + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + let path = + "/v1/executions/\(executionID)/events?after_sequence=\(afterSequence)" + do { + var request = try makeRequest(method: "GET", path: path) + // Translation streams are intentionally quiet while a model or + // typesetter is working. Keep control requests fail-fast, but + // let this authenticated local stream live for the job. + request.timeoutInterval = Self.eventStreamRequestTimeout + let (bytes, rawResponse) = try await session.bytes(for: request) + guard let response = rawResponse as? HTTPURLResponse else { + throw BabelDOCExecutorError.invalidResponse("缺少 HTTP 响应") + } + if response.statusCode == 410 || response.statusCode == 409 { + var body = Data() + for try await byte in bytes { + body.append(byte) + } + let payload = try? decoder.decode( + BabelDOCExecutorErrorPayload.self, + from: body + ) + if response.statusCode == 410 { + throw BabelDOCExecutorError.replayGap(payload?.snapshot) + } + throw BabelDOCExecutorError.cursorAhead(payload?.snapshot) + } + guard (200...299).contains(response.statusCode) else { + var body = Data() + for try await byte in bytes { + body.append(byte) + } + try validate(response: response, data: body) + throw BabelDOCExecutorError.invalidResponse("空错误响应") + } + for try await line in bytes.lines { + try Task.checkCancellation() + guard !line.isEmpty, let data = line.data(using: .utf8) else { + continue + } + continuation.yield( + try decoder.decode(BabelDOCExecutorEvent.self, from: data) + ) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + private func terminalRecovery( + _ snapshot: BabelDOCExecutorExecutionSnapshot, + executorOutput: URL + ) throws -> JSONValue? { + switch snapshot.status { + case "succeeded": + guard + let discovered = try? BabelDOCExternalEngine.discoverOutputs( + in: executorOutput + ), + discovered.monolingualPDF != nil || discovered.bilingualPDF != nil + else { + throw BabelDOCExecutorError.outputMissing + } + return nil + case "cancelled": + throw BabelDOCExecutorError.executionCancelled + case "failed": + throw BabelDOCExecutorError.executionFailed( + code: "babeldoc_failed", + message: "任务失败,且终端事件已不在服务的重放窗口中" + ) + default: + throw BabelDOCExecutorError.replayGap(snapshot) + } + } + + private func materializeResult( + _ payload: JSONValue?, + executorOutput: URL, + destination: URL, + timeline: BabelDOCExternalEngine.ProgressTimeline, + onProgress: (@Sendable (BabelDOCProgressUpdate) -> Void)? + ) throws -> BabelDOCTranslationResult { + try FileManager.default.createDirectory( + at: destination, + withIntermediateDirectories: true + ) + var mono: URL? + var dual: URL? + if let files = payload?["files"], case .object(let object) = files { + mono = try materialize( + object["mono_no_watermark_pdf"] ?? object["mono_pdf"], + destination: destination + ) + dual = try materialize( + object["dual_no_watermark_pdf"] ?? object["dual_pdf"], + destination: destination + ) + } else { + let discovered = try BabelDOCExternalEngine.discoverOutputs(in: executorOutput) + mono = try copy(discovered.monolingualPDF, to: destination) + dual = try copy(discovered.bilingualPDF, to: destination) + } + guard mono != nil || dual != nil else { + throw BabelDOCExecutorError.outputMissing + } + let completed = timeline.finish() + let performance = Self.performance(from: payload?["performance"]) + let finalUpdate = + performance.map { + BabelDOCProgressUpdate( + phase: .completed, + overallProgress: 100, + elapsedMilliseconds: $0.elapsedMilliseconds, + timings: $0.timings + ) + } ?? completed + onProgress?(finalUpdate) + let cacheStatus = + performance?.layoutIRCacheStatus + ?? payload?["layout_cache_status"]?.stringValue + ?? payload?["metrics"]?["layout_cache_status"]?.stringValue + return BabelDOCTranslationResult( + monolingualPDF: mono, + bilingualPDF: dual, + log: "", + timings: finalUpdate.timings, + layoutCacheStatus: cacheStatus + ) + } + + private func materialize( + _ value: JSONValue?, + destination: URL + ) throws -> URL? { + guard let relativePath = value?.stringValue else { return nil } + let source = connection.workrootURL.appendingPathComponent(relativePath) + let canonicalRoot = connection.workrootURL.resolvingSymlinksInPath().standardizedFileURL + let canonicalSource = source.resolvingSymlinksInPath().standardizedFileURL + let prefix = + canonicalRoot.path.hasSuffix("/") + ? canonicalRoot.path + : canonicalRoot.path + "/" + guard canonicalSource.path.hasPrefix(prefix), + FileManager.default.isReadableFile(atPath: canonicalSource.path) + else { + throw BabelDOCExecutorError.invalidResponse("输出文件越过 workroot") + } + return try copy(canonicalSource, to: destination) + } + + private func copy(_ source: URL?, to destination: URL) throws -> URL? { + guard let source else { return nil } + let target = destination.appendingPathComponent(source.lastPathComponent) + if FileManager.default.fileExists(atPath: target.path) { + try FileManager.default.removeItem(at: target) + } + try FileManager.default.copyItem(at: source, to: target) + return target + } + + private func json( + method: String, + path: String, + body: Data? = nil + ) async throws -> T { + let (data, response) = try await data(method: method, path: path, body: body) + try validate(response: response, data: data) + do { + return try decoder.decode(T.self, from: data) + } catch { + throw BabelDOCExecutorError.invalidResponse(error.localizedDescription) + } + } + + private func data( + method: String, + path: String, + body: Data? = nil + ) async throws -> (Data, HTTPURLResponse) { + let request = try makeRequest(method: method, path: path, body: body) + do { + let (data, response) = try await session.data(for: request) + guard let response = response as? HTTPURLResponse else { + throw BabelDOCExecutorError.invalidResponse("缺少 HTTP 响应") + } + return (data, response) + } catch let error as BabelDOCExecutorError { + throw error + } catch { + throw BabelDOCExecutorError.unavailable(error.localizedDescription) + } + } + + private func makeRequest( + method: String, + path: String, + body: Data? = nil + ) throws -> URLRequest { + guard let url = URL(string: path, relativeTo: connection.baseURL)?.absoluteURL else { + throw BabelDOCExecutorError.invalidResponse("无效服务 URL") + } + var request = URLRequest(url: url) + request.httpMethod = method + request.httpBody = body + request.setValue( + "Bearer \(connection.bearerToken)", + forHTTPHeaderField: "Authorization" + ) + if body != nil { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + return request + } + + private func validate(response: HTTPURLResponse, data: Data) throws { + guard !(200...299).contains(response.statusCode) else { return } + if response.statusCode == 401 { + throw BabelDOCExecutorError.authenticationFailed + } + let payload = try? decoder.decode(BabelDOCExecutorErrorPayload.self, from: data) + if response.statusCode == 409, payload?.code == "busy" { + throw BabelDOCExecutorError.busy(payload?.snapshot) + } + throw BabelDOCExecutorError.serviceError( + status: response.statusCode, + code: payload?.code ?? "http_\(response.statusCode)", + message: payload?.message ?? HTTPURLResponse.localizedString(forStatusCode: response.statusCode) + ) + } + + static func validateRuntime( + _ response: BabelDOCExecutorRuntimeResponse, + connection: BabelDOCExecutorConnection, + requireCurrentParent: Bool = true + ) throws { + let requiredCapabilities = [ + "executor.http.v1", + "executor.events.ndjson.v1", + "layout.rpc-doclayout8.v1", + "runtime-info.v1", + ] + guard response.runtimeAPIVersion == 1, + response.runtime.name == "gloss-babeldoc", + response.service.serviceID == "gloss-babeldoc", + response.service.instanceID == connection.instanceID, + response.service.pid == connection.processIdentifier, + requiredCapabilities.allSatisfy(response.capabilities.contains) + else { + throw BabelDOCExecutorError.incompatibleRuntime( + "缺少 v1 executor 能力或服务身份不匹配" + ) + } + let expectedParent = + requireCurrentParent + ? Int32(ProcessInfo.processInfo.processIdentifier) + : connection.parentProcessIdentifier + if let expectedParent, response.service.parentPID != expectedParent { + throw BabelDOCExecutorError.incompatibleRuntime("服务父进程身份不匹配") + } + } + + private static func progressEvent( + from payload: JSONValue + ) -> BabelDOCExternalEngine.ProgressWireEvent? { + guard let data = try? JSONEncoder().encode(payload) else { return nil } + return try? JSONDecoder().decode( + BabelDOCExternalEngine.ProgressWireEvent.self, + from: data + ) + } + + private static func performance( + from value: JSONValue? + ) -> BabelDOCExecutorPerformance? { + guard let value, let data = try? JSONEncoder().encode(value) else { + return nil + } + return try? JSONDecoder().decode(BabelDOCExecutorPerformance.self, from: data) + } + + private static func performanceProgress( + from payload: JSONValue + ) -> BabelDOCProgressUpdate? { + guard let performance = performance(from: payload["performance"]), + let phase = BabelDOCTranslationPhase(rawValue: performance.phase) + else { return nil } + return BabelDOCProgressUpdate( + phase: phase, + stageName: payload["stage"]?.stringValue, + overallProgress: + payload["overall_progress"].flatMap { + if case .number(let value) = $0 { value } else { nil } + } ?? 0, + stageCurrent: payload["stage_current"]?.intValue, + stageTotal: payload["stage_total"]?.intValue, + partIndex: payload["part_index"]?.intValue, + totalParts: payload["total_parts"]?.intValue, + elapsedMilliseconds: performance.elapsedMilliseconds, + timings: performance.timings + ) + } + + private static func snapshot( + from value: JSONValue? + ) throws -> BabelDOCExecutorExecutionSnapshot? { + guard let value, value != .null else { return nil } + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode( + BabelDOCExecutorExecutionSnapshot.self, + from: data + ) + } + + private static func preparePrivateDirectory(_ url: URL) throws { + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: url.path + ) + } + + private static func relative(_ url: URL, to root: URL) throws -> String { + let canonicalRoot = root.resolvingSymlinksInPath().standardizedFileURL + let canonicalURL = url.resolvingSymlinksInPath().standardizedFileURL + let rootPath = + canonicalRoot.path.hasSuffix("/") + ? canonicalRoot.path + : canonicalRoot.path + "/" + guard canonicalURL.path.hasPrefix(rootPath) else { + throw BabelDOCExecutorError.invalidResponse("任务路径越过 workroot") + } + return String(canonicalURL.path.dropFirst(rootPath.count)) + } +} diff --git a/Sources/GlossCore/BabelDOCExternalEngine.swift b/Sources/GlossCore/BabelDOCExternalEngine.swift index 0b357b5..e85f4e9 100644 --- a/Sources/GlossCore/BabelDOCExternalEngine.swift +++ b/Sources/GlossCore/BabelDOCExternalEngine.swift @@ -5,10 +5,16 @@ import Foundation public struct BabelDOCRuntimeLaunch: Equatable, Sendable { public let executable: String public let source: String + public let executorExecutable: String? - public init(executable: String, source: String) { + public init( + executable: String, + source: String, + executorExecutable: String? = nil + ) { self.executable = executable self.source = source + self.executorExecutable = executorExecutable } } @@ -91,7 +97,7 @@ public enum BabelDOCExternalEngineError: LocalizedError, Equatable, Sendable { public var errorDescription: String? { switch self { case .runtimeUnavailable: - "未找到 BabelDOC。请先执行:uv tool install --python 3.12 BabelDOC" + "PDF 运行时尚未安装。请在 Gloss 设置的“PDF 运行时”中安装或重试。" case .launchFailed(let reason): "无法启动 BabelDOC:\(reason)" case .processFailed(let status, let log): @@ -152,11 +158,29 @@ public final class BabelDOCExternalEngine: @unchecked Sendable { } } - public init() {} + private let executorManager: (any BabelDOCExecutorManaging)? + private let injectedExecutorConnection: BabelDOCExecutorConnection? + private let legacyFallbackPolicy: BabelDOCLegacyFallbackPolicy + + public init( + executorManager: (any BabelDOCExecutorManaging)? = nil, + executorConnection: BabelDOCExecutorConnection? = nil, + legacyFallbackPolicy: BabelDOCLegacyFallbackPolicy = .unsupportedRuntimeOnly + ) { + self.executorManager = executorManager + self.injectedExecutorConnection = executorConnection + self.legacyFallbackPolicy = legacyFallbackPolicy + } public static func resolveRuntime( environment: [String: String] = ProcessInfo.processInfo.environment ) -> BabelDOCRuntimeLaunch? { + let configuredExecutor = environment["GLOSS_BABELDOC_EXECUTOR_BIN"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let validConfiguredExecutor = + configuredExecutor.flatMap { + FileManager.default.isExecutableFile(atPath: $0) ? $0 : nil + } if let configured = environment["GLOSS_BABELDOC_BIN"]? .trimmingCharacters(in: .whitespacesAndNewlines), !configured.isEmpty, @@ -164,7 +188,10 @@ public final class BabelDOCExternalEngine: @unchecked Sendable { { return BabelDOCRuntimeLaunch( executable: configured, - source: "configured" + source: "configured", + executorExecutable: + validConfiguredExecutor + ?? siblingExecutor(forLegacyExecutable: configured) ) } @@ -184,7 +211,21 @@ public final class BabelDOCExternalEngine: @unchecked Sendable { where seen.insert(candidate).inserted && FileManager.default.isExecutableFile(atPath: candidate) { - return BabelDOCRuntimeLaunch(executable: candidate, source: source) + return BabelDOCRuntimeLaunch( + executable: candidate, + source: source, + executorExecutable: + validConfiguredExecutor + ?? siblingExecutor(forLegacyExecutable: candidate) + ?? resolveExecutor(environment: environment) + ) + } + if let executor = validConfiguredExecutor ?? resolveExecutor(environment: environment) { + return BabelDOCRuntimeLaunch( + executable: executor, + source: validConfiguredExecutor == nil ? "executor" : "configured", + executorExecutable: executor + ) } return nil } @@ -199,6 +240,70 @@ public final class BabelDOCExternalEngine: @unchecked Sendable { throw BabelDOCExternalEngineError.runtimeUnavailable } + if let connection = try await executorConnection( + for: request, + runtime: runtime + ) { + return try await BabelDOCExecutorClient(connection: connection).translate( + request, + onOutput: onOutput, + onProgress: onProgress + ) + } + guard legacyFallbackPolicy == .unsupportedRuntimeOnly else { + throw BabelDOCExecutorError.unsupportedRuntime + } + return try await translateWithLegacyCLI( + request, + runtime: runtime, + onOutput: onOutput, + onProgress: onProgress + ) + } + + private func executorConnection( + for request: BabelDOCTranslationRequest, + runtime: BabelDOCRuntimeLaunch + ) async throws -> BabelDOCExecutorConnection? { + if let injectedExecutorConnection { + return injectedExecutorConnection + } + if let executorManager { + do { + return try await executorManager.executorConnection( + runtime: runtime, + timeout: .seconds(90) + ) + } catch BabelDOCExecutorError.unsupportedRuntime { + guard runtime.executorExecutable == nil else { + throw BabelDOCExecutorError.incompatibleRuntime( + "托管 BabelDOC 运行时未建立 executor 连接" + ) + } + return nil + } + } + if let layoutServiceBaseURL = request.layoutServiceBaseURL, + let connection = await BabelDOCExecutorConnectionRegistry.shared.connection( + layoutServiceBaseURL: layoutServiceBaseURL + ) + { + return connection + } + guard runtime.executorExecutable != nil else { + return nil + } + throw BabelDOCExecutorError.unavailable( + "运行时支持 executor,但当前 PDF 会话尚未建立连接" + ) + } + + private func translateWithLegacyCLI( + _ request: BabelDOCTranslationRequest, + runtime: BabelDOCRuntimeLaunch, + onOutput: (@Sendable (String) -> Void)?, + onProgress: (@Sendable (BabelDOCProgressUpdate) -> Void)? + ) async throws -> BabelDOCTranslationResult { let layoutCacheKey: String? if request.layoutCacheDirectoryURL != nil { let keyTask = Task.detached(priority: .utility) { @@ -337,6 +442,36 @@ public final class BabelDOCExternalEngine: @unchecked Sendable { ) } + private static func siblingExecutor( + forLegacyExecutable executable: String + ) -> String? { + let candidate = URL(fileURLWithPath: executable) + .deletingLastPathComponent() + .appendingPathComponent("gloss-babeldoc").path + return FileManager.default.isExecutableFile(atPath: candidate) + ? candidate + : nil + } + + private static func resolveExecutor( + environment: [String: String] + ) -> String? { + var candidates = (environment["PATH"] ?? "") + .split(separator: ":") + .map { String($0) + "/gloss-babeldoc" } + let home = FileManager.default.homeDirectoryForCurrentUser + candidates.append(contentsOf: [ + "/opt/homebrew/bin/gloss-babeldoc", + "/usr/local/bin/gloss-babeldoc", + home.appendingPathComponent(".local/bin/gloss-babeldoc").path, + ]) + var seen = Set() + return candidates.first { + seen.insert($0).inserted + && FileManager.default.isExecutableFile(atPath: $0) + } + } + public static func hasReliableTextLayer(_ pageTexts: [String?]) -> Bool { guard !pageTexts.isEmpty else { return false } let characterCounts = pageTexts.map { text in diff --git a/Sources/GlossCore/BabelDOCServiceSession.swift b/Sources/GlossCore/BabelDOCServiceSession.swift index 946f0ff..662459f 100644 --- a/Sources/GlossCore/BabelDOCServiceSession.swift +++ b/Sources/GlossCore/BabelDOCServiceSession.swift @@ -3,38 +3,58 @@ import Foundation public enum BabelDOCServiceError: LocalizedError, Equatable, Sendable { case pythonUnavailable + case executorUnavailable case launchFailed(String) case startupTimedOut(String) + case terminationFailed(String) public var errorDescription: String? { switch self { case .pythonUnavailable: "BabelDOC 的 Python 运行环境不可用。" + case .executorUnavailable: + "当前 BabelDOC 运行时不支持常驻执行服务。" case .launchFailed(let reason): "无法启动 PDF 版面服务:\(reason)" case .startupTimedOut(let log): "PDF 版面服务启动超时。\(log.isEmpty ? "" : "\n\(log)")" + case .terminationFailed(let reason): + "无法安全停止 PDF 服务:\(reason)" } } } -/// Keeps BabelDOC's expensive DocLayout model resident while the PDF tool is open. -/// Individual BabelDOC translations remain isolated child processes and reuse this -/// loopback-only inference service through `--rpc-doclayout`. -public actor BabelDOCServiceSession { +/// Owns the persistent DocLayout gateway and authenticated BabelDOC executor used +/// by a PDF module session. The two processes share one private workroot and are +/// tied to the Gloss process identity. +public actor BabelDOCServiceSession: BabelDOCExecutorManaging { + public static let shared = BabelDOCServiceSession() + static let readyPrefix = "__GLOSS_BABELDOC_LAYOUT_READY__" + static let executorReadyPrefix = "__GLOSS_BABELDOC_SERVICE_READY__" static let workingDirectoryPrefix = "Gloss-BabelDOC-Layout-" static let ownerPIDFileName = ".owner-pid" + static let executorReadyFileName = ".executor-workroot-ready" + static let executorTokenFileName = ".executor-token" + static let persistedSessionFileName = "executor-session.json" static let legacyCleanupGraceInterval: TimeInterval = 24 * 60 * 60 private static let layoutCacheDirectoryName = "layout-ir-cache" private final class OutputBuffer: @unchecked Sendable { + private static let maximumBytes = 256 * 1_024 private let lock = NSLock() private var data = Data() func append(_ value: Data) { lock.lock() - data.append(value) + if value.count >= Self.maximumBytes { + data = Data(value.suffix(Self.maximumBytes)) + } else { + data.append(value) + if data.count > Self.maximumBytes { + data.removeFirst(data.count - Self.maximumBytes) + } + } lock.unlock() } @@ -46,25 +66,124 @@ public actor BabelDOCServiceSession { } } + private final class ClientStateForwarder: @unchecked Sendable { + typealias Handler = + @Sendable (UInt64, BabelDOCExecutorClientState) async -> Void + + private let lock = NSLock() + private let handler: Handler + private var tail: Task? + private var nextOrdinal: UInt64 = 0 + + init(handler: @escaping Handler) { + self.handler = handler + } + + func submit(_ state: BabelDOCExecutorClientState) { + lock.lock() + nextOrdinal &+= 1 + let ordinal = nextOrdinal + let previous = tail + let handler = handler + let task = Task { + await previous?.value + await handler(ordinal, state) + } + tail = task + lock.unlock() + } + } + private var process: Process? private var outputPipe: Pipe? + private var executorProcess: Process? + private var executorOutputPipe: Pipe? + private var adoptedLayoutProcessID: Int32? + private var adoptedLayoutProcessStartTime: Double? private var workingDirectory: URL? private var serviceBaseURL: URL? + private var executorConnectionValue: BabelDOCExecutorConnection? + private var activeRuntimeExecutorPath: String? + private var serviceSnapshotValue = BabelDOCExecutorServiceSnapshot( + installed: false, + lifecycleState: .stopped + ) + private var shutdownInProgress = false + private var clientStateGeneration: UInt64 = 0 + private var lastClientStateOrdinal: UInt64 = 0 + private var retiredExecutionIDs: Set = [] + private var snapshotContinuations: [UUID: AsyncStream.Continuation] = [:] + private var lifecycleOperationActive = false + private var lifecycleWaiters: [CheckedContinuation] = [] + private let persistedStateDirectoryURL: URL - public init() {} + private struct PersistedSession: Codable { + let endpoint: URL + let tokenFile: URL + let workroot: URL + let layoutEndpoint: URL + let layoutPID: Int32? + let layoutProcessStartTime: Double? + let instanceID: String + let pid: Int32 + let processStartTime: Double? + let parentPID: Int32? + let runtimeVersion: String + let executorExecutable: String? + } + + private struct PersistedSessionRecord { + let session: PersistedSession + let workrootIsMissing: Bool + } + + private struct ExecutorReady: Decodable { + let serviceID: String + let instanceID: String + let pid: Int32 + let processStartTime: Double? + let endpoint: URL + let parentPID: Int32? + + enum CodingKeys: String, CodingKey { + case serviceID = "service_id" + case instanceID = "instance_id" + case pid + case processStartTime = "process_start_time" + case endpoint + case parentPID = "parent_pid" + } + } + + public init(persistedStateDirectoryURL: URL? = nil) { + self.persistedStateDirectoryURL = + persistedStateDirectoryURL + ?? FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + .appendingPathComponent("Gloss/BabelDOC", isDirectory: true) + } deinit { outputPipe?.fileHandleForReading.readabilityHandler = nil if process?.isRunning == true { process?.terminate() } - if let workingDirectory { - try? FileManager.default.removeItem(at: workingDirectory) + executorOutputPipe?.fileHandleForReading.readabilityHandler = nil + if executorProcess?.isRunning == true { + executorProcess?.terminate() } } public var isRunning: Bool { - process?.isRunning == true && serviceBaseURL != nil + (process?.isRunning == true || adoptedLayoutProcessID != nil) + && serviceBaseURL != nil + && (executorConnectionValue == nil + || executorProcess?.isRunning == true + || executorConnectionValue.map { + Self.processExists($0.processIdentifier) + } == true) } public var layoutCacheDirectoryURL: URL? { @@ -75,21 +194,115 @@ public actor BabelDOCServiceSession { ) } + public func snapshot() -> BabelDOCExecutorServiceSnapshot { + serviceSnapshotValue + } + + public func stateChanges() -> AsyncStream { + let id = UUID() + return AsyncStream { continuation in + continuation.yield(serviceSnapshotValue) + snapshotContinuations[id] = continuation + continuation.onTermination = { _ in + Task { await self.removeSnapshotContinuation(id) } + } + } + } + + public func currentExecution() async throws -> BabelDOCExecutorExecutionSnapshot? { + guard let executorConnectionValue else { return nil } + return try await BabelDOCExecutorClient( + connection: executorConnectionValue + ).currentExecution() + } + + public func latestExecution() async throws -> BabelDOCExecutorExecutionSnapshot? { + guard let executorConnectionValue else { return nil } + return try await BabelDOCExecutorClient( + connection: executorConnectionValue + ).latestExecution() + } + + public func cancelCurrent() async throws { + guard let executorConnectionValue else { return } + try await BabelDOCExecutorClient( + connection: executorConnectionValue + ).cancelCurrent() + } + public func start( runtime: BabelDOCRuntimeLaunch, timeout: Duration = .seconds(90) + ) async throws -> URL { + await acquireLifecycleOperation() + defer { releaseLifecycleOperation() } + try Task.checkCancellation() + return try await startLocked(runtime: runtime, timeout: timeout) + } + + private func startLocked( + runtime: BabelDOCRuntimeLaunch, + timeout: Duration ) async throws -> URL { if let process, process.isRunning, let serviceBaseURL { - return serviceBaseURL + if runtime.executorExecutable == nil { + return serviceBaseURL + } + if let connection = executorConnectionValue, + serviceSnapshotValue.lifecycleState == .ready, + activeRuntimeExecutorPath + == Self.normalizedExecutablePath(runtime.executorExecutable), + executorProcess?.isRunning == true + || Self.processExists(connection.processIdentifier), + (try? await BabelDOCExecutorClient(connection: connection).health()) + == true, + (try? await BabelDOCExecutorClient(connection: connection).runtime()) + != nil + { + return serviceBaseURL + } + try await requireShutdownLocked() + } + if process == nil, executorProcess == nil, + let restored = try? await restorePersistedConnection( + expectedRuntime: runtime + ) + { + executorConnectionValue = restored + serviceBaseURL = restored.layoutServiceBaseURL + workingDirectory = restored.workrootURL + if let persisted = try? Self.readPersistedSession( + at: persistedSessionURL + ) { + adoptedLayoutProcessID = persisted.layoutPID + adoptedLayoutProcessStartTime = + persisted.layoutProcessStartTime + activeRuntimeExecutorPath = persisted.executorExecutable + } + await BabelDOCExecutorConnectionRegistry.shared.register(restored) + updateSnapshot(connection: restored, lifecycle: .ready, error: nil) + return restored.layoutServiceBaseURL } - await stop() + try await cleanupPersistedServiceLocked() + try Task.checkCancellation() + updateSnapshot( + installed: runtime.executorExecutable != nil, + lifecycle: .starting, + error: nil + ) + try await requireShutdownLocked() + updateSnapshot( + installed: runtime.executorExecutable != nil, + lifecycle: .starting, + error: nil + ) Self.cleanupStaleWorkingDirectories() - guard - let interpreter = BabelDOCExternalEngine.pythonInterpreter( - for: runtime.executable - ) - else { + let legacyInterpreter = + runtime.executorExecutable == nil + ? BabelDOCExternalEngine.pythonInterpreter(for: runtime.executable) + : nil + if runtime.executorExecutable == nil, legacyInterpreter == nil { throw BabelDOCServiceError.pythonUnavailable } @@ -110,6 +323,17 @@ public actor BabelDOCServiceSession { withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700] ) + for (name, contents) in [ + (Self.executorReadyFileName, UUID().uuidString + "\n"), + (Self.executorTokenFileName, Self.makeBearerToken() + "\n"), + ] { + let url = directory.appendingPathComponent(name) + try Data(contents.utf8).write(to: url, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) + } let ownerPIDURL = directory.appendingPathComponent(Self.ownerPIDFileName) try Data("\(ProcessInfo.processInfo.processIdentifier)\n".utf8).write( to: ownerPIDURL, @@ -128,22 +352,28 @@ public actor BabelDOCServiceSession { attributes: [.posixPermissions: 0o700] ) let scriptURL = directory.appendingPathComponent("layout_service.py") - try Data(Self.layoutServiceScript.utf8).write(to: scriptURL, options: .atomic) - try FileManager.default.setAttributes( - [.posixPermissions: 0o600], - ofItemAtPath: scriptURL.path - ) + if runtime.executorExecutable == nil { + try Data(Self.layoutServiceScript.utf8).write(to: scriptURL, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: scriptURL.path + ) + } let serviceProcess = Process() let pipe = Pipe() let output = OutputBuffer() - serviceProcess.executableURL = URL(fileURLWithPath: interpreter) - serviceProcess.arguments = [ - scriptURL.path, - "--host", "127.0.0.1", - "--port", "0", - "--parent-pid", String(ProcessInfo.processInfo.processIdentifier), - ] + let layoutLaunch = try Self.layoutLaunch( + runtime: runtime, + legacyInterpreter: legacyInterpreter, + scriptURL: scriptURL, + parentPID: Int32(ProcessInfo.processInfo.processIdentifier), + layoutModel: ProcessInfo.processInfo.environment[ + "GLOSS_BABELDOC_LAYOUT_MODEL" + ] + ) + serviceProcess.executableURL = URL(fileURLWithPath: layoutLaunch.executable) + serviceProcess.arguments = layoutLaunch.arguments var environment = ProcessInfo.processInfo.environment environment["PYTHONUNBUFFERED"] = "1" serviceProcess.environment = environment @@ -154,6 +384,14 @@ public actor BabelDOCServiceSession { guard !data.isEmpty else { return } output.append(data) } + serviceProcess.terminationHandler = { [weak self] terminated in + Task { + await self?.layoutExited( + processIdentifier: terminated.processIdentifier, + status: terminated.terminationStatus + ) + } + } do { try serviceProcess.run() @@ -177,11 +415,35 @@ public actor BabelDOCServiceSession { if let port = Self.readyPort(in: output.string()) { let baseURL = URL(string: "http://127.0.0.1:\(port)")! serviceBaseURL = baseURL + if runtime.executorExecutable != nil { + do { + _ = try await launchExecutor( + runtime: runtime, + layoutServiceBaseURL: baseURL, + workroot: directory, + deadline: deadline + ) + } catch { + updateSnapshot( + installed: true, + lifecycle: .failed, + error: error.localizedDescription + ) + _ = await shutdownLocked(cancelActive: true) + throw error + } + } else { + updateSnapshot( + installed: false, + lifecycle: .ready, + error: nil + ) + } return baseURL } if !serviceProcess.isRunning { let message = Self.tail(of: output.string()) - await stop() + _ = await shutdownLocked(cancelActive: true) throw BabelDOCServiceError.launchFailed( message.isEmpty ? "进程已退出" : message ) @@ -189,34 +451,300 @@ public actor BabelDOCServiceSession { try await Task.sleep(for: .milliseconds(150)) } } catch is CancellationError { - await stop() + _ = await shutdownLocked(cancelActive: true) throw CancellationError() } let message = Self.tail(of: output.string()) - await stop() + _ = await shutdownLocked(cancelActive: true) + updateSnapshot( + installed: runtime.executorExecutable != nil, + lifecycle: .failed, + error: message + ) throw BabelDOCServiceError.startupTimedOut(message) } - public func stop() async { - serviceBaseURL = nil + @discardableResult + public func stop() async -> Bool { + await shutdown(cancelActive: true) + } + + @discardableResult + public func shutdown(cancelActive: Bool = true) async -> Bool { + await acquireLifecycleOperation() + defer { releaseLifecycleOperation() } + return await shutdownLocked(cancelActive: cancelActive) + } + + private func requireShutdownLocked() async throws { + guard await shutdownLocked(cancelActive: true) else { + throw BabelDOCServiceError.terminationFailed( + serviceSnapshotValue.lastError ?? "子进程仍在运行" + ) + } + } + + private func shutdownLocked(cancelActive: Bool) async -> Bool { + let previousInstalled = serviceSnapshotValue.installed + updateSnapshot( + installed: previousInstalled, + lifecycle: .stopping, + error: serviceSnapshotValue.lastError + ) + let connection = executorConnectionValue let runningProcess = process let pipe = outputPipe + let runningExecutor = executorProcess + let executorPipe = executorOutputPipe + let adoptedLayoutPID = adoptedLayoutProcessID + let adoptedLayoutStartTime = adoptedLayoutProcessStartTime let directory = workingDirectory - process = nil - outputPipe = nil - workingDirectory = nil + let ownsSession = + connection != nil || runningProcess != nil || runningExecutor != nil + || adoptedLayoutPID != nil || directory != nil + guard ownsSession else { + updateSnapshot( + installed: previousInstalled, + lifecycle: .stopped, + error: nil + ) + return true + } - pipe?.fileHandleForReading.readabilityHandler = nil + shutdownInProgress = true + invalidateClientStateHandler() + defer { shutdownInProgress = false } + if let connection { + await BabelDOCExecutorConnectionRegistry.shared.unregister( + layoutServiceBaseURL: connection.layoutServiceBaseURL + ) + try? await BabelDOCExecutorClient(connection: connection).shutdown( + cancelActive: cancelActive + ) + } + + let executorExited: Bool + if let runningExecutor, runningExecutor.isRunning { + executorExited = await Self.terminateChildProcess( + runningExecutor, + allowGracefulExit: true + ) + } else if let connection, + Self.processExists(connection.processIdentifier) + { + executorExited = await Self.terminateVerifiedProcess( + connection.processIdentifier, + expectedStartTime: connection.processStartTime, + allowGracefulExit: true + ) + } else { + executorExited = true + } + + let layoutExited: Bool if let runningProcess, runningProcess.isRunning { - runningProcess.terminate() - await Task.detached(priority: .utility) { - runningProcess.waitUntilExit() - }.value + layoutExited = await Self.terminateChildProcess( + runningProcess, + allowGracefulExit: false + ) + } else if let adoptedLayoutPID, + Self.processMatches( + adoptedLayoutPID, + expectedStartTime: adoptedLayoutStartTime + ) + { + layoutExited = await Self.terminateVerifiedProcess( + adoptedLayoutPID, + expectedStartTime: adoptedLayoutStartTime, + allowGracefulExit: false + ) + } else { + layoutExited = + adoptedLayoutPID.map { !Self.processExists($0) } ?? true + } + + guard executorExited, layoutExited else { + let livePIDs = [ + connection?.processIdentifier, + runningExecutor?.processIdentifier, + runningProcess?.processIdentifier, + adoptedLayoutPID, + ] + .compactMap { $0 } + .filter(Self.processExists) + .map(String.init) + .joined(separator: ", ") + let message = + livePIDs.isEmpty + ? "无法确认子进程已经退出" + : "子进程仍在运行(PID \(livePIDs))" + updateSnapshot( + installed: previousInstalled, + lifecycle: .failed, + error: message + ) + return false } + + pipe?.fileHandleForReading.readabilityHandler = nil + executorPipe?.fileHandleForReading.readabilityHandler = nil + process = nil + outputPipe = nil + executorProcess = nil + executorOutputPipe = nil + adoptedLayoutProcessID = nil + adoptedLayoutProcessStartTime = nil + executorConnectionValue = nil + activeRuntimeExecutorPath = nil + workingDirectory = nil + serviceBaseURL = nil + removePersistedSessionIfOwned( + connection: connection, + directory: directory + ) if let directory { try? FileManager.default.removeItem(at: directory) } + updateSnapshot( + installed: previousInstalled, + lifecycle: .stopped, + error: nil + ) + return true + } + + public func executorConnection( + runtime: BabelDOCRuntimeLaunch, + timeout: Duration + ) async throws -> BabelDOCExecutorConnection { + await acquireLifecycleOperation() + defer { releaseLifecycleOperation() } + try Task.checkCancellation() + if let executorConnectionValue, + serviceSnapshotValue.lifecycleState == .ready, + activeRuntimeExecutorPath + == Self.normalizedExecutablePath(runtime.executorExecutable), + executorProcess?.isRunning == true + || Self.processExists(executorConnectionValue.processIdentifier) + { + return executorConnectionValue + } + _ = try await startLocked(runtime: runtime, timeout: timeout) + guard let executorConnectionValue else { + throw BabelDOCExecutorError.unsupportedRuntime + } + return executorConnectionValue + } + + public func reconnect( + runtime: BabelDOCRuntimeLaunch, + force: Bool = false, + timeout: Duration = .seconds(90) + ) async throws -> URL { + await acquireLifecycleOperation() + defer { releaseLifecycleOperation() } + try Task.checkCancellation() + updateSnapshot( + installed: runtime.executorExecutable != nil, + lifecycle: .reconnecting, + error: nil + ) + if force { + try await requireShutdownLocked() + try await cleanupPersistedServiceLocked() + } else if let restored = try await restorePersistedConnection( + expectedRuntime: runtime + ) { + executorConnectionValue = restored + if let persisted = try? Self.readPersistedSession( + at: persistedSessionURL + ) { + activeRuntimeExecutorPath = persisted.executorExecutable + } + await BabelDOCExecutorConnectionRegistry.shared.register(restored) + updateSnapshot( + connection: restored, + lifecycle: .ready, + error: nil + ) + return restored.layoutServiceBaseURL + } + return try await startLocked(runtime: runtime, timeout: timeout) + } + + /// Removes a service left by an earlier app session without starting a new + /// one. A live process is only signalled after its authenticated runtime + /// identity (and the layout health identity) match the private marker. + public func cleanupPersistedService() async throws { + await acquireLifecycleOperation() + defer { releaseLifecycleOperation() } + try await cleanupPersistedServiceLocked() + } + + private func cleanupPersistedServiceLocked() async throws { + guard + let record = try Self.readPersistedSessionRecord( + at: persistedSessionURL, + allowMissingWorkroot: true + ) + else { return } + let persisted = record.session + if record.workrootIsMissing { + guard Self.processIsDefinitelyAbsent(persisted.pid), + persisted.layoutPID.map(Self.processIsDefinitelyAbsent) ?? true + else { + throw BabelDOCExecutorError.incompatibleRuntime( + "旧 PDF 服务进程仍存活或状态无法确认,已保留会话 marker" + ) + } + try FileManager.default.removeItem(at: persistedSessionURL) + return + } + if executorConnectionValue?.processIdentifier == persisted.pid { + guard await shutdownLocked(cancelActive: true) else { + throw BabelDOCExecutorError.unavailable( + serviceSnapshotValue.lastError ?? "当前 PDF 服务仍在运行" + ) + } + return + } + + if Self.processExists(persisted.pid) { + try await terminatePersistedServiceIfVerified() + } + if let layoutPID = persisted.layoutPID, + Self.processExists(layoutPID) + { + guard + Self.processMatches( + layoutPID, + expectedStartTime: persisted.layoutProcessStartTime + ) + else { + throw BabelDOCExecutorError.incompatibleRuntime( + "旧 DocLayout 进程身份无法验证,已拒绝终止" + ) + } + try await Self.verifyLayoutService(persisted.layoutEndpoint) + let exited = await Self.terminateVerifiedProcess( + layoutPID, + expectedStartTime: persisted.layoutProcessStartTime, + allowGracefulExit: false + ) + if !exited { + throw BabelDOCExecutorError.unavailable( + "旧 DocLayout 服务未在超时前退出" + ) + } + } + guard !Self.processExists(persisted.pid), + persisted.layoutPID.map({ !Self.processExists($0) }) ?? true + else { + throw BabelDOCExecutorError.unavailable("旧 PDF 服务仍在运行") + } + try Self.removePrivateWorkroot(persisted.workroot) + try? FileManager.default.removeItem(at: persistedSessionURL) } static func readyPort(in output: String) -> Int? { @@ -229,6 +757,873 @@ public actor BabelDOCServiceSession { return port } + static func layoutLaunch( + runtime: BabelDOCRuntimeLaunch, + legacyInterpreter: String?, + scriptURL: URL, + parentPID: Int32, + layoutModel: String? = nil + ) throws -> (executable: String, arguments: [String]) { + var commonArguments = [ + "--host", "127.0.0.1", + "--port", "0", + "--parent-pid", String(parentPID), + ] + if let layoutModel, !layoutModel.isEmpty { + commonArguments.append(contentsOf: ["--model", layoutModel]) + } + if let executor = runtime.executorExecutable { + return (executor, ["layout-serve"] + commonArguments) + } + guard let legacyInterpreter else { + throw BabelDOCServiceError.pythonUnavailable + } + return (legacyInterpreter, [scriptURL.path] + commonArguments) + } + + private static func executorReady(in output: String) -> ExecutorReady? { + guard let prefix = output.range(of: executorReadyPrefix) else { return nil } + let suffix = output[prefix.upperBound...] + let line = suffix.prefix { !$0.isNewline } + guard let data = String(line).data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(ExecutorReady.self, from: data) + } + + private func launchExecutor( + runtime: BabelDOCRuntimeLaunch, + layoutServiceBaseURL: URL, + workroot: URL, + deadline: ContinuousClock.Instant + ) async throws -> BabelDOCExecutorConnection { + guard let executable = runtime.executorExecutable else { + throw BabelDOCExecutorError.unsupportedRuntime + } + let tokenFile = workroot.appendingPathComponent(Self.executorTokenFileName) + let token = try Self.readPrivateToken(at: tokenFile) + let instanceID = UUID().uuidString.lowercased() + let serviceProcess = Process() + let pipe = Pipe() + let output = OutputBuffer() + serviceProcess.executableURL = URL(fileURLWithPath: executable) + serviceProcess.arguments = [ + "serve", + "--host", "127.0.0.1", + "--port", "0", + "--runner", "babeldoc", + "--token-file", tokenFile.path, + "--work-dir", workroot.path, + "--instance-id", instanceID, + "--parent-pid", String(ProcessInfo.processInfo.processIdentifier), + ] + var environment = ProcessInfo.processInfo.environment + environment["PYTHONUNBUFFERED"] = "1" + serviceProcess.environment = environment + serviceProcess.standardOutput = pipe + serviceProcess.standardError = pipe + pipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + output.append(data) + } + do { + try serviceProcess.run() + } catch { + pipe.fileHandleForReading.readabilityHandler = nil + throw BabelDOCServiceError.launchFailed(error.localizedDescription) + } + executorProcess = serviceProcess + executorOutputPipe = pipe + + let clock = ContinuousClock() + while clock.now < deadline { + try Task.checkCancellation() + if let ready = Self.executorReady(in: output.string()) { + guard ready.serviceID == "gloss-babeldoc", + ready.instanceID == instanceID, + ready.pid == serviceProcess.processIdentifier, + ready.parentPID == Int32(ProcessInfo.processInfo.processIdentifier), + ready.endpoint.host == "127.0.0.1", + ready.endpoint.scheme == "http", + ready.endpoint.port != nil + else { + throw BabelDOCExecutorError.incompatibleRuntime( + "ready handshake 身份不一致" + ) + } + let stateHandler = makeClientStateHandler() + var connection = BabelDOCExecutorConnection( + baseURL: ready.endpoint, + bearerToken: token, + workrootURL: workroot, + layoutServiceBaseURL: layoutServiceBaseURL, + instanceID: instanceID, + processIdentifier: ready.pid, + processStartTime: ready.processStartTime, + runtimeVersion: "unknown", + _stateHandler: stateHandler + ) + let runtimeInfo = try await BabelDOCExecutorClient( + connection: connection + ).runtime() + connection = BabelDOCExecutorConnection( + baseURL: connection.baseURL, + bearerToken: connection.bearerToken, + workrootURL: connection.workrootURL, + layoutServiceBaseURL: connection.layoutServiceBaseURL, + instanceID: connection.instanceID, + processIdentifier: connection.processIdentifier, + processStartTime: connection.processStartTime, + runtimeVersion: runtimeInfo.runtime.version, + _stateHandler: stateHandler + ) + executorConnectionValue = connection + await BabelDOCExecutorConnectionRegistry.shared.register(connection) + activeRuntimeExecutorPath = Self.normalizedExecutablePath( + runtime.executorExecutable + ) + try persist( + connection: connection, + tokenFile: tokenFile, + executorExecutable: activeRuntimeExecutorPath + ) + updateSnapshot(connection: connection, lifecycle: .ready, error: nil) + serviceProcess.terminationHandler = { [weak self] terminated in + Task { + await self?.executorExited( + processIdentifier: terminated.processIdentifier, + status: terminated.terminationStatus + ) + } + } + return connection + } + if !serviceProcess.isRunning { + throw BabelDOCServiceError.launchFailed( + Self.tail(of: output.string()) + ) + } + try await Task.sleep(for: .milliseconds(100)) + } + throw BabelDOCServiceError.startupTimedOut(Self.tail(of: output.string())) + } + + private func persist( + connection: BabelDOCExecutorConnection, + tokenFile: URL, + executorExecutable: String? + ) throws { + try FileManager.default.createDirectory( + at: persistedStateDirectoryURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: persistedStateDirectoryURL.path + ) + let persisted = PersistedSession( + endpoint: connection.baseURL, + tokenFile: tokenFile, + workroot: connection.workrootURL, + layoutEndpoint: connection.layoutServiceBaseURL, + layoutPID: process?.processIdentifier ?? adoptedLayoutProcessID, + layoutProcessStartTime: + process.flatMap { + Self.processStartTime($0.processIdentifier) + } ?? adoptedLayoutProcessStartTime, + instanceID: connection.instanceID, + pid: connection.processIdentifier, + processStartTime: connection.processStartTime, + parentPID: connection.parentProcessIdentifier, + runtimeVersion: connection.runtimeVersion, + executorExecutable: executorExecutable + ) + try Self.writePrivateAtomicFile( + JSONEncoder().encode(persisted), + to: persistedSessionURL + ) + } + + private func restorePersistedConnection( + expectedRuntime: BabelDOCRuntimeLaunch + ) async throws -> BabelDOCExecutorConnection? { + guard let persisted = try Self.readPersistedSession(at: persistedSessionURL), + persisted.parentPID == Int32(ProcessInfo.processInfo.processIdentifier), + persisted.executorExecutable + == Self.normalizedExecutablePath(expectedRuntime.executorExecutable) + else { return nil } + let token = try Self.readPrivateToken(at: persisted.tokenFile) + let connection = connection(from: persisted, token: token) + do { + _ = try await BabelDOCExecutorClient(connection: connection).health() + _ = try await BabelDOCExecutorClient(connection: connection).runtime() + try await Self.verifyLayoutService(persisted.layoutEndpoint) + return connection + } catch { + return nil + } + } + + private func terminatePersistedServiceIfVerified() async throws { + guard let persisted = try Self.readPersistedSession(at: persistedSessionURL) + else { return } + let token = try Self.readPrivateToken(at: persisted.tokenFile) + let connection = connection(from: persisted, token: token) + let client = BabelDOCExecutorClient(connection: connection) + do { + _ = try await client.health(requireCurrentParent: false) + _ = try await client.runtime(requireCurrentParent: false) + } catch { + throw BabelDOCExecutorError.incompatibleRuntime( + "无法验证旧服务身份,已拒绝终止该进程" + ) + } + try await client.shutdown(cancelActive: true) + let exited = await Self.terminateVerifiedProcess( + persisted.pid, + expectedStartTime: persisted.processStartTime, + allowGracefulExit: true + ) + guard exited else { + throw BabelDOCExecutorError.unavailable( + "已验证的旧 executor 未在强制清理后退出" + ) + } + } + + private func connection( + from persisted: PersistedSession, + token: String + ) -> BabelDOCExecutorConnection { + let stateHandler = makeClientStateHandler() + return BabelDOCExecutorConnection( + baseURL: persisted.endpoint, + bearerToken: token, + workrootURL: persisted.workroot, + layoutServiceBaseURL: persisted.layoutEndpoint, + instanceID: persisted.instanceID, + processIdentifier: persisted.pid, + processStartTime: persisted.processStartTime, + parentProcessIdentifier: persisted.parentPID, + runtimeVersion: persisted.runtimeVersion, + _stateHandler: stateHandler + ) + } + + private var persistedSessionURL: URL { + persistedStateDirectoryURL.appendingPathComponent( + Self.persistedSessionFileName + ) + } + + private func makeClientStateHandler() + -> @Sendable (BabelDOCExecutorClientState) -> Void + { + clientStateGeneration &+= 1 + lastClientStateOrdinal = 0 + retiredExecutionIDs.removeAll(keepingCapacity: true) + let generation = clientStateGeneration + let forwarder = ClientStateForwarder { [weak self] ordinal, state in + await self?.receiveClientState( + state, + generation: generation, + ordinal: ordinal + ) + } + return { state in + forwarder.submit(state) + } + } + + private func invalidateClientStateHandler() { + clientStateGeneration &+= 1 + lastClientStateOrdinal = 0 + retiredExecutionIDs.removeAll(keepingCapacity: true) + } + + private func receiveClientState( + _ state: BabelDOCExecutorClientState, + generation: UInt64, + ordinal: UInt64 + ) { + guard generation == clientStateGeneration, + ordinal > lastClientStateOrdinal, + serviceSnapshotValue.lifecycleState == .ready + else { return } + lastClientStateOrdinal = ordinal + + let isSubmitting = state.status == "submitting" + if isSubmitting, let taskID = state.taskID { + if let activeExecutionID = serviceSnapshotValue.activeExecutionID { + retiredExecutionIDs.insert(activeExecutionID) + } + serviceSnapshotValue = BabelDOCExecutorServiceSnapshot( + installed: serviceSnapshotValue.installed, + runtimeVersion: serviceSnapshotValue.runtimeVersion, + endpoint: serviceSnapshotValue.endpoint, + processIdentifier: serviceSnapshotValue.processIdentifier, + processStartTime: serviceSnapshotValue.processStartTime, + instanceID: serviceSnapshotValue.instanceID, + lifecycleState: serviceSnapshotValue.lifecycleState, + activeTaskID: taskID, + activeExecutionID: state.executionID, + activeStatus: state.status, + activeProgress: state.progress, + lastError: nil + ) + publishSnapshot() + return + } + + if let taskID = state.taskID, + let activeTaskID = serviceSnapshotValue.activeTaskID, + taskID != activeTaskID + { + return + } + if let executionID = state.executionID { + guard !retiredExecutionIDs.contains(executionID) else { return } + if let activeExecutionID = serviceSnapshotValue.activeExecutionID, + executionID != activeExecutionID + { + return + } + } + serviceSnapshotValue = BabelDOCExecutorServiceSnapshot( + installed: serviceSnapshotValue.installed, + runtimeVersion: serviceSnapshotValue.runtimeVersion, + endpoint: serviceSnapshotValue.endpoint, + processIdentifier: serviceSnapshotValue.processIdentifier, + processStartTime: serviceSnapshotValue.processStartTime, + instanceID: serviceSnapshotValue.instanceID, + lifecycleState: serviceSnapshotValue.lifecycleState, + activeTaskID: state.taskID ?? serviceSnapshotValue.activeTaskID, + activeExecutionID: + state.executionID ?? serviceSnapshotValue.activeExecutionID, + activeStatus: state.status, + activeProgress: state.progress, + lastError: state.error + ) + publishSnapshot() + } + + private func executorExited( + processIdentifier: Int32, + status: Int32 + ) async { + guard !shutdownInProgress else { return } + guard let connection = executorConnectionValue, + connection.processIdentifier == processIdentifier + else { + return + } + invalidateClientStateHandler() + await BabelDOCExecutorConnectionRegistry.shared.unregister( + layoutServiceBaseURL: connection.layoutServiceBaseURL + ) + executorConnectionValue = nil + executorProcess = nil + executorOutputPipe?.fileHandleForReading.readabilityHandler = nil + executorOutputPipe = nil + updateSnapshot( + installed: serviceSnapshotValue.installed, + lifecycle: serviceSnapshotValue.lifecycleState == .stopping + ? .stopped + : .failed, + error: serviceSnapshotValue.lifecycleState == .stopping + ? nil + : "executor 进程已退出(状态 \(status))" + ) + } + + private func layoutExited( + processIdentifier: Int32, + status: Int32 + ) async { + await acquireLifecycleOperation() + defer { releaseLifecycleOperation() } + guard !shutdownInProgress, + process?.processIdentifier == processIdentifier + else { return } + let message = "DocLayout 进程已退出(状态 \(status))" + invalidateClientStateHandler() + updateSnapshot( + installed: serviceSnapshotValue.installed, + lifecycle: .failed, + error: message + ) + let stopped = await shutdownLocked(cancelActive: true) + if stopped { + updateSnapshot( + installed: serviceSnapshotValue.installed, + lifecycle: .failed, + error: message + ) + } + } + + private func updateSnapshot( + connection: BabelDOCExecutorConnection? = nil, + installed: Bool? = nil, + lifecycle: BabelDOCExecutorLifecycleState, + error: String? + ) { + let connection = connection ?? executorConnectionValue + serviceSnapshotValue = BabelDOCExecutorServiceSnapshot( + installed: installed ?? serviceSnapshotValue.installed, + runtimeVersion: + connection?.runtimeVersion ?? serviceSnapshotValue.runtimeVersion, + endpoint: connection?.baseURL, + processIdentifier: connection?.processIdentifier, + processStartTime: connection?.processStartTime, + instanceID: connection?.instanceID, + lifecycleState: lifecycle, + activeTaskID: + lifecycle == .stopped ? nil : serviceSnapshotValue.activeTaskID, + activeExecutionID: + lifecycle == .stopped ? nil : serviceSnapshotValue.activeExecutionID, + activeStatus: + lifecycle == .stopped ? nil : serviceSnapshotValue.activeStatus, + activeProgress: + lifecycle == .stopped ? nil : serviceSnapshotValue.activeProgress, + lastError: error + ) + publishSnapshot() + } + + private func publishSnapshot() { + for continuation in snapshotContinuations.values { + continuation.yield(serviceSnapshotValue) + } + } + + private func removeSnapshotContinuation(_ id: UUID) { + snapshotContinuations.removeValue(forKey: id) + } + + private func acquireLifecycleOperation() async { + if !lifecycleOperationActive { + lifecycleOperationActive = true + return + } + await withCheckedContinuation { continuation in + lifecycleWaiters.append(continuation) + } + } + + private func releaseLifecycleOperation() { + guard !lifecycleWaiters.isEmpty else { + lifecycleOperationActive = false + return + } + lifecycleWaiters.removeFirst().resume() + } + + private func removePersistedSessionIfOwned( + connection: BabelDOCExecutorConnection?, + directory: URL? + ) { + guard + let persisted = try? Self.readPersistedSession( + at: persistedSessionURL + ) + else { return } + let connectionMatches = + connection.map { + persisted.instanceID == $0.instanceID + && persisted.pid == $0.processIdentifier + } ?? false + let directoryMatches = + directory.map { + persisted.workroot.resolvingSymlinksInPath().standardizedFileURL + == $0.resolvingSymlinksInPath().standardizedFileURL + } ?? false + guard connectionMatches || directoryMatches, + !Self.processExists(persisted.pid), + persisted.layoutPID.map({ !Self.processExists($0) }) ?? true + else { return } + try? FileManager.default.removeItem(at: persistedSessionURL) + } + + private static func makeBearerToken() -> String { + (UUID().uuidString + UUID().uuidString) + .replacingOccurrences(of: "-", with: "") + .lowercased() + } + + private static func normalizedExecutablePath(_ path: String?) -> String? { + guard let path, !path.isEmpty else { return nil } + return URL(fileURLWithPath: path) + .resolvingSymlinksInPath() + .standardizedFileURL.path + } + + private static func readPrivateToken(at url: URL) throws -> String { + var info = stat() + guard lstat(url.path, &info) == 0, + info.st_mode & S_IFMT == S_IFREG, + info.st_uid == getuid(), + info.st_mode & 0o077 == 0 + else { + throw BabelDOCExecutorError.incompatibleRuntime( + "executor token 文件权限不安全" + ) + } + let token = try String(contentsOf: url, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard (32...256).contains(token.count), + token.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-" || $0 == "_") }) + else { + throw BabelDOCExecutorError.incompatibleRuntime("executor token 无效") + } + return token + } + + private static func readPersistedSession(at url: URL) throws -> PersistedSession? { + try readPersistedSessionRecord( + at: url, + allowMissingWorkroot: false + )?.session + } + + private static func readPersistedSessionRecord( + at url: URL, + allowMissingWorkroot: Bool + ) throws -> PersistedSessionRecord? { + var info = stat() + guard lstat(url.path, &info) == 0 else { return nil } + guard info.st_mode & S_IFMT == S_IFREG, + info.st_uid == getuid() + else { + throw BabelDOCExecutorError.incompatibleRuntime("会话 marker 权限不安全") + } + if info.st_mode & 0o077 != 0 { + let permissions = info.st_mode & 0o777 + guard permissions & 0o700 == 0o600, + permissions & 0o111 == 0 + else { + throw BabelDOCExecutorError.incompatibleRuntime( + "会话 marker 权限不安全" + ) + } + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) + guard lstat(url.path, &info) == 0, + info.st_mode & S_IFMT == S_IFREG, + info.st_uid == getuid(), + info.st_mode & 0o777 == 0o600 + else { + throw BabelDOCExecutorError.incompatibleRuntime( + "无法修复会话 marker 权限" + ) + } + } + let persisted = try JSONDecoder().decode( + PersistedSession.self, + from: Data(contentsOf: url) + ) + let canonicalWorkroot = persisted.workroot.resolvingSymlinksInPath() + .standardizedFileURL + let temporaryRoot = FileManager.default.temporaryDirectory + .resolvingSymlinksInPath().standardizedFileURL + let temporaryPrefix = + temporaryRoot.path.hasSuffix("/") + ? temporaryRoot.path + : temporaryRoot.path + "/" + var workrootInfo = stat() + guard canonicalWorkroot.path.hasPrefix(temporaryPrefix), + canonicalWorkroot.lastPathComponent.hasPrefix(workingDirectoryPrefix), + persisted.tokenFile.resolvingSymlinksInPath().standardizedFileURL + == canonicalWorkroot.appendingPathComponent(executorTokenFileName), + persisted.endpoint.scheme == "http", + persisted.endpoint.host == "127.0.0.1", + persisted.endpoint.port != nil, + persisted.layoutEndpoint.scheme == "http", + persisted.layoutEndpoint.host == "127.0.0.1", + persisted.layoutEndpoint.port != nil, + persisted.layoutPID.map({ $0 > 0 }) ?? true, + persisted.layoutProcessStartTime.map({ $0 > 0 }) ?? true, + persisted.executorExecutable.map({ + $0.hasPrefix("/") + && Self.normalizedExecutablePath($0) == $0 + }) ?? true, + persisted.pid > 0 + else { + throw BabelDOCExecutorError.incompatibleRuntime("会话 marker 内容无效") + } + let workrootIsMissing: Bool + if lstat(canonicalWorkroot.path, &workrootInfo) == 0 { + guard workrootInfo.st_mode & S_IFMT == S_IFDIR, + workrootInfo.st_uid == getuid(), + workrootInfo.st_mode & 0o077 == 0 + else { + throw BabelDOCExecutorError.incompatibleRuntime( + "会话 workroot 权限不安全" + ) + } + workrootIsMissing = false + } else { + guard allowMissingWorkroot, errno == ENOENT else { + throw BabelDOCExecutorError.incompatibleRuntime( + "会话 workroot 不可用" + ) + } + workrootIsMissing = true + } + return PersistedSessionRecord( + session: persisted, + workrootIsMissing: workrootIsMissing + ) + } + + static func writePrivateAtomicFile( + _ data: Data, + to destination: URL + ) throws { + let fileManager = FileManager.default + let temporary = destination.deletingLastPathComponent() + .appendingPathComponent( + ".\(destination.lastPathComponent).\(UUID().uuidString).tmp" + ) + var shouldRemoveTemporary = true + defer { + if shouldRemoveTemporary { + try? fileManager.removeItem(at: temporary) + } + } + try data.write(to: temporary, options: .withoutOverwriting) + try fileManager.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: temporary.path + ) + let handle = try FileHandle(forWritingTo: temporary) + try handle.synchronize() + try handle.close() + guard rename(temporary.path, destination.path) == 0 else { + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: [NSFilePathErrorKey: destination.path] + ) + } + shouldRemoveTemporary = false + let directoryDescriptor = open( + destination.deletingLastPathComponent().path, + O_RDONLY + ) + if directoryDescriptor >= 0 { + _ = fsync(directoryDescriptor) + _ = close(directoryDescriptor) + } + } + + private static func verifyLayoutService(_ baseURL: URL) async throws { + struct LayoutHealth: Decodable { + let status: String + let service: String + let schemaVersion: Int + + enum CodingKeys: String, CodingKey { + case status + case service + case schemaVersion = "schema_version" + } + } + let url = baseURL.appendingPathComponent("healthz") + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + request.timeoutInterval = 3 + let (data, response) = try await URLSession.shared.data(for: request) + guard let response = response as? HTTPURLResponse, + response.statusCode == 200, + let health = try? JSONDecoder().decode(LayoutHealth.self, from: data), + health.status == "ok", + health.service == "gloss-babeldoc-layout", + health.schemaVersion == 1 + else { + throw BabelDOCExecutorError.incompatibleRuntime( + "旧 DocLayout 服务身份验证失败" + ) + } + } + + private static func removePrivateWorkroot(_ url: URL) throws { + let canonical = url.resolvingSymlinksInPath().standardizedFileURL + let temporary = FileManager.default.temporaryDirectory + .resolvingSymlinksInPath().standardizedFileURL + let prefix = + temporary.path.hasSuffix("/") + ? temporary.path + : temporary.path + "/" + var info = stat() + guard canonical.path.hasPrefix(prefix), + canonical.lastPathComponent.hasPrefix(workingDirectoryPrefix), + lstat(canonical.path, &info) == 0, + info.st_mode & S_IFMT == S_IFDIR, + info.st_uid == getuid(), + info.st_mode & 0o077 == 0 + else { + throw BabelDOCExecutorError.incompatibleRuntime( + "拒绝清理不安全的旧 workroot" + ) + } + try FileManager.default.removeItem(at: canonical) + } + + private static func waitForExit( + _ process: Process, + timeout: Duration + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while process.isRunning && clock.now < deadline { + try? await Task.sleep(for: .milliseconds(50)) + } + return !process.isRunning + } + + private static func waitForProcessExit( + _ processIdentifier: Int32, + timeout: Duration + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while processExists(processIdentifier) && clock.now < deadline { + try? await Task.sleep(for: .milliseconds(50)) + } + return !processExists(processIdentifier) + } + + static func terminateChildProcess( + _ process: Process, + allowGracefulExit: Bool + ) async -> Bool { + guard process.isRunning else { return true } + if allowGracefulExit, + await waitForExit(process, timeout: .seconds(3)) + { + return true + } + process.terminate() + if await waitForExit(process, timeout: .seconds(3)) { + return true + } + guard + kill(pid_t(process.processIdentifier), SIGKILL) == 0 + || errno == ESRCH + else { + return false + } + return await waitForExit(process, timeout: .seconds(3)) + } + + private static func terminateVerifiedProcess( + _ processIdentifier: Int32, + expectedStartTime: Double?, + allowGracefulExit: Bool + ) async -> Bool { + guard processExists(processIdentifier) else { return true } + if allowGracefulExit, + await waitForVerifiedProcessExit( + processIdentifier, + expectedStartTime: expectedStartTime, + timeout: .seconds(3) + ) + { + return true + } + guard + verifiedProcessStillMatches( + processIdentifier, + expectedStartTime: expectedStartTime + ) + else { + return !processExists(processIdentifier) + || processWasReplaced( + processIdentifier, + expectedStartTime: expectedStartTime + ) + } + guard kill(pid_t(processIdentifier), SIGTERM) == 0 || errno == ESRCH else { + return false + } + if await waitForVerifiedProcessExit( + processIdentifier, + expectedStartTime: expectedStartTime, + timeout: .seconds(3) + ) { + return true + } + guard + verifiedProcessStillMatches( + processIdentifier, + expectedStartTime: expectedStartTime + ) + else { + return !processExists(processIdentifier) + || processWasReplaced( + processIdentifier, + expectedStartTime: expectedStartTime + ) + } + guard kill(pid_t(processIdentifier), SIGKILL) == 0 || errno == ESRCH else { + return false + } + return await waitForVerifiedProcessExit( + processIdentifier, + expectedStartTime: expectedStartTime, + timeout: .seconds(3) + ) + } + + private static func waitForVerifiedProcessExit( + _ processIdentifier: Int32, + expectedStartTime: Double?, + timeout: Duration + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while verifiedProcessStillMatches( + processIdentifier, + expectedStartTime: expectedStartTime + ) && clock.now < deadline { + try? await Task.sleep(for: .milliseconds(50)) + } + return !processExists(processIdentifier) + || processWasReplaced( + processIdentifier, + expectedStartTime: expectedStartTime + ) + } + + private static func verifiedProcessStillMatches( + _ processIdentifier: Int32, + expectedStartTime: Double? + ) -> Bool { + guard processExists(processIdentifier), + let expectedStartTime, + let actualStartTime = processStartTime(processIdentifier) + else { + return false + } + return abs(actualStartTime - expectedStartTime) < 0.01 + } + + private static func processWasReplaced( + _ processIdentifier: Int32, + expectedStartTime: Double? + ) -> Bool { + guard processExists(processIdentifier), + let expectedStartTime, + let actualStartTime = processStartTime(processIdentifier) + else { + return false + } + return abs(actualStartTime - expectedStartTime) >= 0.01 + } + public static func cleanupStaleWorkingDirectories() { cleanupStaleWorkingDirectories(in: FileManager.default.temporaryDirectory) } @@ -294,6 +1689,48 @@ public actor BabelDOCServiceSession { return errno == EPERM } + private static func processIsDefinitelyAbsent( + _ processIdentifier: Int32 + ) -> Bool { + guard processIdentifier > 0 else { return false } + if kill(pid_t(processIdentifier), 0) == 0 { + return false + } + return errno == ESRCH + } + + static func processStartTime(_ processIdentifier: Int32) -> Double? { + guard processIdentifier > 0 else { return nil } + var info = proc_bsdinfo() + let expectedSize = Int32(MemoryLayout.size) + guard + proc_pidinfo( + processIdentifier, + PROC_PIDTBSDINFO, + 0, + &info, + expectedSize + ) == expectedSize + else { + return nil + } + return + Double(info.pbi_start_tvsec) + + Double(info.pbi_start_tvusec) / 1_000_000 + } + + static func processMatches( + _ processIdentifier: Int32, + expectedStartTime: Double? + ) -> Bool { + guard let expectedStartTime, + let actualStartTime = processStartTime(processIdentifier) + else { + return false + } + return abs(actualStartTime - expectedStartTime) < 0.01 + } + private static func tail(of output: String) -> String { output .split(separator: "\n", omittingEmptySubsequences: true) @@ -303,6 +1740,7 @@ public actor BabelDOCServiceSession { static let layoutServiceScript = #""" import argparse + import base64 import json import os import threading @@ -344,7 +1782,11 @@ public actor BabelDOCServiceSession { if self.path != "/healthz": self.send_error(HTTPStatus.NOT_FOUND) return - body = json.dumps({"status": "ok"}).encode("utf-8") + body = json.dumps({ + "status": "ok", + "service": "gloss-babeldoc-layout", + "schema_version": 1, + }).encode("utf-8") self.send_response(HTTPStatus.OK) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) @@ -357,9 +1799,16 @@ public actor BabelDOCServiceSession { return try: length = int(self.headers.get("Content-Length", "0")) - request = msgpack.unpackb(self.rfile.read(length), raw=False) + raw = self.rfile.read(length) + is_json = self.headers.get("Content-Type", "").split(";", 1)[0] == "application/json" + request = json.loads(raw) if is_json else msgpack.unpackb(raw, raw=False) + encoded_images = ( + [base64.b64decode(request["image"])] + if is_json and isinstance(request.get("image"), str) + else request.get("image", []) + ) images = [] - for encoded in request.get("image", []): + for encoded in encoded_images: image = cv2.imdecode( np.frombuffer(encoded, dtype=np.uint8), cv2.IMREAD_COLOR, @@ -374,12 +1823,33 @@ public actor BabelDOCServiceSession { images, imgsz=int(request.get("imgsz", 1024)), ) - body = msgpack.packb( - [result_payload(result) for result in results], - use_bin_type=True, - ) + if is_json: + if len(results) != 1: + raise ValueError("rpc_doclayout8 requires one image") + converted = result_payload(results[0]) + boxes = [] + for box in converted["boxes"]: + class_id = int(box["cls"]) + boxes.append({ + "class_id": class_id, + "label": converted["names"].get(str(class_id), str(class_id)), + "score": float(box["conf"]), + "box": [float(value) for value in box["xyxy"]], + }) + body = json.dumps({ + "schema_version": 1, + "boxes": boxes, + }).encode("utf-8") + else: + body = msgpack.packb( + [result_payload(result) for result in results], + use_bin_type=True, + ) self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "application/msgpack") + self.send_header( + "Content-Type", + "application/json" if is_json else "application/msgpack", + ) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) diff --git a/Tests/GlossCoreTests/BabelDOCExecutorClientTests.swift b/Tests/GlossCoreTests/BabelDOCExecutorClientTests.swift new file mode 100644 index 0000000..35141da --- /dev/null +++ b/Tests/GlossCoreTests/BabelDOCExecutorClientTests.swift @@ -0,0 +1,815 @@ +import Foundation +import XCTest + +@testable import GlossCore + +final class BabelDOCExecutorClientTests: XCTestCase { + override func tearDown() { + StubExecutorURLProtocol.setHandler(nil) + super.tearDown() + } + + func testRuntimeIdentityAndControlRequestsUseBearerAuthentication() async throws { + let fixture = try Fixture() + defer { fixture.remove() } + let requestLog = RequestLog() + StubExecutorURLProtocol.setHandler { request in + requestLog.append(request) + switch (request.httpMethod, request.url?.path) { + case ("GET", "/v1/runtime"): + return .json( + Self.runtimePayload( + connection: fixture.connection, + parentPID: Int32(ProcessInfo.processInfo.processIdentifier) + ) + ) + case ("GET", "/v1/executions/current"): + return .json([ + "execution": Self.executionSnapshot( + executionID: "execution-1", + status: "running" + ) + ]) + case ("GET", "/v1/executions/latest"): + return .json([ + "execution": Self.executionSnapshot( + executionID: "execution-1", + status: "succeeded" + ) + ]) + case ("POST", "/v1/executions/execution-1/cancel"): + return .json( + Self.executionSnapshot( + executionID: "execution-1", + status: "cancelling" + ), + status: 202 + ) + case ("POST", "/v1/shutdown"): + return .json(["status": "stopping"], status: 202) + default: + return .json(["code": "not_found", "message": "not found"], status: 404) + } + } + + let client = fixture.client() + let runtime = try await client.runtime() + let current = try await client.currentExecution() + let latest = try await client.latestExecution() + XCTAssertEqual(runtime.runtime.version, "0.6.4+gloss.2") + XCTAssertEqual(current?.status, "running") + XCTAssertEqual(latest?.status, "succeeded") + try await client.cancelCurrent() + try await client.shutdown() + + let requests = requestLog.snapshot() + XCTAssertEqual(requests.count, 6) + XCTAssertTrue( + requests.allSatisfy { + $0.value(forHTTPHeaderField: "Authorization") + == "Bearer fixture-bearer-token-000000000000" + } + ) + let shutdown = try XCTUnwrap( + requests.first { $0.url?.path == "/v1/shutdown" } + ) + let shutdownBody = try Self.requestBody(shutdown) + XCTAssertEqual( + try JSONSerialization.jsonObject(with: shutdownBody) as? [String: Bool], + ["cancel_active": true] + ) + } + + func testCancelledSubmissionPollsUntilMatchingExecutionRegisters() async throws { + let fixture = try Fixture() + defer { fixture.remove() } + let requestLog = RequestLog() + let currentAttempts = LockedValues() + + StubExecutorURLProtocol.setHandler { request in + requestLog.append(request) + switch (request.httpMethod, request.url?.path) { + case ("GET", "/v1/executions/current"): + currentAttempts.append(1) + if currentAttempts.snapshot().count < 3 { + return .json(["execution": NSNull()]) + } + return .json([ + "execution": Self.executionSnapshot( + executionID: "execution-delayed", + status: "running", + taskID: "task-delayed" + ) + ]) + case ("POST", "/v1/executions/execution-delayed/cancel"): + return .json( + Self.executionSnapshot( + executionID: "execution-delayed", + status: "cancelling", + taskID: "task-delayed" + ), + status: 202 + ) + case ("GET", "/v1/executions/execution-delayed"): + return .json( + Self.executionSnapshot( + executionID: "execution-delayed", + status: "cancelled", + taskID: "task-delayed" + ) + ) + default: + return .json(["code": "not_found", "message": "not found"], status: 404) + } + } + + let reachedTerminal = await fixture.client().waitForCancelledWorker( + executionID: nil, + taskID: "task-delayed", + registrationTimeout: .seconds(1), + registrationPollInterval: .milliseconds(1) + ) + + XCTAssertTrue(reachedTerminal) + XCTAssertEqual(currentAttempts.snapshot().count, 3) + XCTAssertEqual( + requestLog.snapshot().filter { + $0.httpMethod == "POST" + && $0.url?.path == "/v1/executions/execution-delayed/cancel" + }.count, + 1 + ) + } + + func testCancelledSubmissionNeverCancelsUnrelatedCurrentExecution() async throws { + let fixture = try Fixture() + defer { fixture.remove() } + let requestLog = RequestLog() + + StubExecutorURLProtocol.setHandler { request in + requestLog.append(request) + switch (request.httpMethod, request.url?.path) { + case ("GET", "/v1/executions/current"): + return .json([ + "execution": Self.executionSnapshot( + executionID: "execution-unrelated", + status: "running", + taskID: "task-unrelated" + ) + ]) + case ("POST", "/v1/executions/execution-unrelated/cancel"): + return .json( + Self.executionSnapshot( + executionID: "execution-unrelated", + status: "cancelling", + taskID: "task-unrelated" + ), + status: 202 + ) + default: + return .json(["code": "not_found", "message": "not found"], status: 404) + } + } + + let reachedTerminal = await fixture.client().waitForCancelledWorker( + executionID: nil, + taskID: "task-cancelled", + registrationTimeout: .milliseconds(25), + registrationPollInterval: .milliseconds(5) + ) + + XCTAssertFalse(reachedTerminal) + let requests = requestLog.snapshot() + XCTAssertTrue( + requests.contains { + $0.httpMethod == "GET" && $0.url?.path == "/v1/executions/current" + } + ) + XCTAssertFalse( + requests.contains { + $0.httpMethod == "POST" && $0.url?.path.hasSuffix("/cancel") == true + } + ) + } + + func testTranslationStreamsProgressAndMaterializesExecutorResult() async throws { + let fixture = try Fixture() + defer { fixture.remove() } + let input = fixture.root.appendingPathComponent("source.pdf") + try Data("%PDF-1.7\nfixture".utf8).write(to: input) + let destination = fixture.root.appendingPathComponent("destination", isDirectory: true) + let progressValues = LockedValues() + let submittedBody = LockedValues<[String: Any]>() + let requestLog = RequestLog() + + StubExecutorURLProtocol.setHandler { request in + requestLog.append(request) + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/executions"): + let body = try Self.requestBody(request) + let object = try XCTUnwrap( + try JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + submittedBody.append(object) + let taskID = try XCTUnwrap(object["task_id"] as? String) + let paths = try XCTUnwrap(object["paths"] as? [String: Any]) + let relativeOutput = try XCTUnwrap(paths["output_dir"] as? String) + let output = fixture.root + .appendingPathComponent(relativeOutput, isDirectory: true) + .appendingPathComponent("translated_mono.pdf") + try FileManager.default.createDirectory( + at: output.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("%PDF-1.7\ntranslated".utf8).write(to: output) + fixture.setResultPath( + "\(relativeOutput)/translated_mono.pdf", + taskID: taskID + ) + return .json( + [ + "execution_id": "execution-1", + "status": "running", + "initial_sequence": 10, + "replayed": false, + ], status: 201) + case ("GET", "/v1/executions/execution-1/events"): + let resultPath = try XCTUnwrap(fixture.resultPath()) + return .ndjson([ + Self.event( + type: "progress", + sequence: 11, + payload: [ + "type": "progress_update", + "stage": "Translate Paragraphs", + "overall_progress": 42, + "performance": Self.performance( + phase: "translating", + elapsed: 750, + translating: 400, + cache: "hit" + ), + ], + connection: fixture.connection + ), + Self.event( + type: "result", + sequence: 12, + payload: [ + "files": [ + "mono_no_watermark_pdf": resultPath + ], + "metrics": [:], + "performance": Self.performance( + phase: "completed", + elapsed: 1_500, + translating: 800, + cache: "hit" + ), + ], + connection: fixture.connection + ), + ]) + default: + return .json(["code": "not_found", "message": "not found"], status: 404) + } + } + + let result = try await fixture.client().translate( + BabelDOCTranslationRequest( + inputURL: input, + outputDirectory: destination, + sourceLanguageCode: "en", + targetLanguageCode: "zh-CN", + bridgeBaseURL: URL(string: "http://127.0.0.1:8787/v1")!, + bridgeToken: "bridge-secret", + qps: 4, + maximumPagesPerPart: 20, + skipScannedDetection: true, + outputMode: .monolingual, + layoutServiceBaseURL: fixture.connection.layoutServiceBaseURL, + layoutCacheDirectoryURL: fixture.root.appendingPathComponent("cache") + ), + onOutput: nil, + onProgress: { update in progressValues.append(update.overallProgress) } + ) + + let output = try XCTUnwrap(result.monolingualPDF) + XCTAssertTrue(FileManager.default.fileExists(atPath: output.path)) + XCTAssertEqual(result.layoutCacheStatus, "hit") + XCTAssertEqual(result.timings.translatingMilliseconds, 800) + XCTAssertTrue(progressValues.snapshot().contains(42)) + XCTAssertEqual(progressValues.snapshot().last, 100) + let eventRequest = try XCTUnwrap( + requestLog.snapshot().first { + $0.url?.path == "/v1/executions/execution-1/events" + } + ) + XCTAssertEqual(eventRequest.timeoutInterval, 24 * 60 * 60) + + let request = try XCTUnwrap(submittedBody.snapshot().first) + let translation = try XCTUnwrap( + request["translation_config"] as? [String: Any] + ) + XCTAssertEqual(translation["lang_in"] as? String, "en") + XCTAssertEqual(translation["lang_out"] as? String, "zh-CN") + XCTAssertEqual(translation["no_dual"] as? Bool, true) + let assets = try XCTUnwrap(request["assets"] as? [String: Any]) + let cache = try XCTUnwrap(assets["layout_ir_cache"] as? [String: Any]) + XCTAssertEqual(cache["enabled"] as? Bool, true) + let encodedBody = try JSONSerialization.data(withJSONObject: request) + XCTAssertTrue(String(decoding: encodedBody, as: UTF8.self).contains("bridge-secret")) + } + + func testReplayGapRecoversSucceededOutputFromAuthoritativeSnapshot() async throws { + let fixture = try Fixture() + defer { fixture.remove() } + let input = fixture.root.appendingPathComponent("source.pdf") + try Data("%PDF-1.7\nfixture".utf8).write(to: input) + let destination = fixture.root.appendingPathComponent("destination", isDirectory: true) + + StubExecutorURLProtocol.setHandler { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/executions"): + let body = try Self.requestBody(request) + let object = try XCTUnwrap( + try JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + let paths = try XCTUnwrap(object["paths"] as? [String: Any]) + let relativeOutput = try XCTUnwrap(paths["output_dir"] as? String) + let output = fixture.root + .appendingPathComponent(relativeOutput, isDirectory: true) + .appendingPathComponent("translated_mono.pdf") + try FileManager.default.createDirectory( + at: output.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("%PDF-1.7\nrecovered".utf8).write(to: output) + return .json( + [ + "execution_id": "execution-gap", + "status": "running", + "initial_sequence": 20, + "replayed": false, + ], status: 201) + case ("GET", "/v1/executions/execution-gap/events"): + return .json( + [ + "code": "replay_gap", + "message": "history expired", + "snapshot": Self.executionSnapshot( + executionID: "execution-gap", + status: "succeeded", + initialSequence: 20, + firstAvailableSequence: 24, + lastSequence: 24 + ), + ], status: 410) + default: + return .json(["code": "not_found", "message": "not found"], status: 404) + } + } + + let result = try await fixture.client().translate( + BabelDOCTranslationRequest( + inputURL: input, + outputDirectory: destination, + sourceLanguageCode: "en", + targetLanguageCode: "zh-CN", + bridgeBaseURL: URL(string: "http://127.0.0.1:8787/v1")!, + bridgeToken: "bridge-secret" + ), + onOutput: nil, + onProgress: nil + ) + + XCTAssertNotNil(result.monolingualPDF) + XCTAssertTrue( + FileManager.default.fileExists( + atPath: try XCTUnwrap(result.monolingualPDF).path + ) + ) + } + + func testConfiguredExecutorIsResolvedBesideLegacyRuntime() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let legacy = root.appendingPathComponent("babeldoc") + let executor = root.appendingPathComponent("gloss-babeldoc") + for file in [legacy, executor] { + XCTAssertTrue( + FileManager.default.createFile( + atPath: file.path, + contents: Data("#!/bin/sh\n".utf8), + attributes: [.posixPermissions: 0o700] + ) + ) + } + + XCTAssertEqual( + BabelDOCExternalEngine.resolveRuntime( + environment: [ + "GLOSS_BABELDOC_BIN": legacy.path, + "PATH": "", + ] + ), + BabelDOCRuntimeLaunch( + executable: legacy.path, + source: "configured", + executorExecutable: executor.path + ) + ) + } + + func testSupportedExecutorNeverSilentlyFallsBackToPerFileCLI() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let input = root.appendingPathComponent("input.pdf") + try Data("%PDF-1.7\nfixture".utf8).write(to: input) + let request = BabelDOCTranslationRequest( + inputURL: input, + outputDirectory: root.appendingPathComponent("output"), + sourceLanguageCode: "en", + targetLanguageCode: "zh-CN", + bridgeBaseURL: URL(string: "http://127.0.0.1:8787/v1")!, + bridgeToken: "bridge-token" + ) + let runtime = BabelDOCRuntimeLaunch( + executable: "/does/not/run/babeldoc", + source: "test", + executorExecutable: "/does/not/run/gloss-babeldoc" + ) + + do { + _ = try await BabelDOCExternalEngine().translate( + request, + runtime: runtime + ) + XCTFail("Expected the missing executor session to fail") + } catch let error as BabelDOCExecutorError { + guard case .unavailable = error else { + return XCTFail("Unexpected error: \(error)") + } + } + } + + func testManagedRuntimeDoesNotFallbackWhenManagerReportsUnsupported() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let input = root.appendingPathComponent("input.pdf") + try Data("%PDF-1.7\nfixture".utf8).write(to: input) + let request = BabelDOCTranslationRequest( + inputURL: input, + outputDirectory: root.appendingPathComponent("output"), + sourceLanguageCode: "en", + targetLanguageCode: "zh-CN", + bridgeBaseURL: URL(string: "http://127.0.0.1:8787/v1")!, + bridgeToken: "bridge-token" + ) + + do { + _ = try await BabelDOCExternalEngine( + executorManager: UnsupportedExecutorManager() + ).translate( + request, + runtime: BabelDOCRuntimeLaunch( + executable: "/does/not/run/gloss-babeldoc", + source: "managed", + executorExecutable: "/does/not/run/gloss-babeldoc" + ) + ) + XCTFail("Expected managed runtime incompatibility") + } catch let error as BabelDOCExecutorError { + guard case .incompatibleRuntime = error else { + return XCTFail("Unexpected error: \(error)") + } + } + } + + func testNeverFallbackPolicyRejectsLegacyOnlyRuntimeBeforeLaunchingCLI() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let input = root.appendingPathComponent("input.pdf") + try Data("%PDF-1.7\nfixture".utf8).write(to: input) + let request = BabelDOCTranslationRequest( + inputURL: input, + outputDirectory: root.appendingPathComponent("output"), + sourceLanguageCode: "en", + targetLanguageCode: "zh-CN", + bridgeBaseURL: URL(string: "http://127.0.0.1:8787/v1")!, + bridgeToken: "bridge-token" + ) + + do { + _ = try await BabelDOCExternalEngine( + legacyFallbackPolicy: .never + ).translate( + request, + runtime: BabelDOCRuntimeLaunch( + executable: "/does/not/run/babeldoc", + source: "legacy" + ) + ) + XCTFail("Expected the explicit no-fallback policy to fail") + } catch let error as BabelDOCExecutorError { + XCTAssertEqual(error, .unsupportedRuntime) + } + } + + private static func runtimePayload( + connection: BabelDOCExecutorConnection, + parentPID: Int32 + ) -> [String: Any] { + [ + "schema_version": 1, + "runtime_api_version": 1, + "runtime": [ + "name": "gloss-babeldoc", + "version": "0.6.4+gloss.2", + ], + "upstream": [ + "name": "BabelDOC", + "repository": "https://example.invalid", + "version": "0.6.4", + "commit": "fixture", + ], + "capabilities": [ + "executor.events.ndjson.v1", + "executor.http.v1", + "layout.rpc-doclayout8.v1", + "runtime-info.v1", + ], + "service": [ + "schema_version": 1, + "protocol_version": 1, + "service_id": "gloss-babeldoc", + "instance_id": connection.instanceID, + "pid": connection.processIdentifier, + "process_start_time": + connection.processStartTime.map { $0 as Any } + ?? (NSNull() as Any), + "endpoint": connection.baseURL.absoluteString, + "started_at": 1_000.0, + "runner": "babeldoc", + "parent_pid": parentPID, + "parent_start_time": 999.0, + ], + ] + } + + private static func executionSnapshot( + executionID: String, + status: String, + taskID: String = "task-1", + initialSequence: Int = 10, + firstAvailableSequence: Int? = 11, + lastSequence: Int = 12 + ) -> [String: Any] { + [ + "execution_id": executionID, + "task_id": taskID, + "status": status, + "initial_sequence": initialSequence, + "first_available_sequence": + firstAvailableSequence.map { $0 as Any } + ?? (NSNull() as Any), + "last_sequence": lastSequence, + "worker_finished": status != "running" && status != "cancelling", + "created_at": 1_000.0, + "finished_at": status == "running" ? NSNull() : 1_001.0, + ] + } + + private static func event( + type: String, + sequence: Int, + payload: [String: Any], + connection: BabelDOCExecutorConnection + ) -> [String: Any] { + [ + "schema_version": 1, + "service_id": "gloss-babeldoc", + "instance_id": connection.instanceID, + "type": type, + "execution_id": "execution-1", + "sequence": sequence, + "emitted_at": 1_000.0, + "payload": payload, + ] + } + + private static func performance( + phase: String, + elapsed: Int, + translating: Int, + cache: String + ) -> [String: Any] { + [ + "schema_version": 1, + "phase": phase, + "elapsed_milliseconds": elapsed, + "phase_timings_milliseconds": [ + "launching": 100, + "parsing": 200, + "translating": translating, + "typesetting": 200, + "saving": 100, + "finalizing": 100, + ], + "layout_ir_cache_status": cache, + ] + } + + private static func requestBody(_ request: URLRequest) throws -> Data { + if let body = request.httpBody { + return body + } + let stream = try XCTUnwrap(request.httpBodyStream) + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4_096) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count < 0 { + throw try XCTUnwrap(stream.streamError) + } + if count == 0 { break } + data.append(buffer, count: count) + } + return data + } +} + +private actor UnsupportedExecutorManager: BabelDOCExecutorManaging { + func executorConnection( + runtime: BabelDOCRuntimeLaunch, + timeout: Duration + ) async throws -> BabelDOCExecutorConnection { + throw BabelDOCExecutorError.unsupportedRuntime + } +} + +private final class Fixture: @unchecked Sendable { + let root: URL + let connection: BabelDOCExecutorConnection + private let lock = NSLock() + private var storedResultPath: String? + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + connection = BabelDOCExecutorConnection( + baseURL: URL(string: "http://127.0.0.1:49231")!, + bearerToken: "fixture-bearer-token-000000000000", + workrootURL: root, + layoutServiceBaseURL: URL(string: "http://127.0.0.1:49232")!, + instanceID: "fixture-instance", + processIdentifier: 42, + processStartTime: 900, + runtimeVersion: "0.6.4+gloss.2" + ) + } + + func client() -> BabelDOCExecutorClient { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubExecutorURLProtocol.self] + return BabelDOCExecutorClient( + connection: connection, + sessionConfiguration: configuration + ) + } + + func setResultPath(_ path: String, taskID _: String) { + lock.lock() + storedResultPath = path + lock.unlock() + } + + func resultPath() -> String? { + lock.lock() + defer { lock.unlock() } + return storedResultPath + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } +} + +private final class RequestLog: @unchecked Sendable { + private let lock = NSLock() + private var requests: [URLRequest] = [] + + func append(_ request: URLRequest) { + lock.lock() + requests.append(request) + lock.unlock() + } + + func snapshot() -> [URLRequest] { + lock.lock() + defer { lock.unlock() } + return requests + } +} + +private final class LockedValues: @unchecked Sendable { + private let lock = NSLock() + private var values: [Value] = [] + + func append(_ value: Value) { + lock.lock() + values.append(value) + lock.unlock() + } + + func snapshot() -> [Value] { + lock.lock() + defer { lock.unlock() } + return values + } +} + +private final class StubExecutorURLProtocol: URLProtocol, @unchecked Sendable { + typealias Handler = @Sendable (URLRequest) throws -> StubResponse + + nonisolated(unsafe) private static var handler: Handler? + private static let lock = NSLock() + + static func setHandler(_ value: Handler?) { + lock.lock() + handler = value + lock.unlock() + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.lock.lock() + let handler = Self.handler + Self.lock.unlock() + do { + let result = try XCTUnwrap(handler)(request) + let response = try XCTUnwrap( + HTTPURLResponse( + url: request.url!, + statusCode: result.status, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": result.contentType, + "Content-Length": "\(result.data.count)", + ] + ) + ) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: result.data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +private struct StubResponse: Sendable { + let status: Int + let contentType: String + let data: Data + + static func json(_ object: [String: Any], status: Int = 200) -> Self { + Self( + status: status, + contentType: "application/json", + data: try! JSONSerialization.data(withJSONObject: object) + ) + } + + static func ndjson(_ objects: [[String: Any]]) -> Self { + let lines = + objects.map { + String( + decoding: try! JSONSerialization.data(withJSONObject: $0), + as: UTF8.self + ) + }.joined(separator: "\n") + "\n" + return Self( + status: 200, + contentType: "application/x-ndjson", + data: Data(lines.utf8) + ) + } +} diff --git a/Tests/GlossCoreTests/BabelDOCExternalEngineTests.swift b/Tests/GlossCoreTests/BabelDOCExternalEngineTests.swift index fda2d1d..74dc162 100644 --- a/Tests/GlossCoreTests/BabelDOCExternalEngineTests.swift +++ b/Tests/GlossCoreTests/BabelDOCExternalEngineTests.swift @@ -4,6 +4,16 @@ import XCTest @testable import GlossCore final class BabelDOCExternalEngineTests: XCTestCase { + func testRuntimeUnavailablePointsToGlossManagedInstallation() { + let message = BabelDOCExternalEngineError.runtimeUnavailable.errorDescription + + XCTAssertEqual( + message, + "PDF 运行时尚未安装。请在 Gloss 设置的“PDF 运行时”中安装或重试。" + ) + XCTAssertFalse(message?.contains("uv tool install") == true) + } + func testPersistentLayoutServiceSmokeWhenRequested() async throws { guard ProcessInfo.processInfo.environment["GLOSS_RUN_BABELDOC_SERVICE_SMOKE"] == "1" else { diff --git a/Tests/GlossCoreTests/BabelDOCServiceSessionTests.swift b/Tests/GlossCoreTests/BabelDOCServiceSessionTests.swift index 37fbe9e..3622831 100644 --- a/Tests/GlossCoreTests/BabelDOCServiceSessionTests.swift +++ b/Tests/GlossCoreTests/BabelDOCServiceSessionTests.swift @@ -4,6 +4,334 @@ import XCTest @testable import GlossCore final class BabelDOCServiceSessionTests: XCTestCase { + func testProcessIdentityRequiresMatchingStartTime() throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sleep") + process.arguments = ["10"] + try process.run() + defer { + if process.isRunning { + process.terminate() + } + process.waitUntilExit() + } + + let startTime = try XCTUnwrap( + BabelDOCServiceSession.processStartTime( + process.processIdentifier + ) + ) + XCTAssertTrue( + BabelDOCServiceSession.processMatches( + process.processIdentifier, + expectedStartTime: startTime + ) + ) + XCTAssertFalse( + BabelDOCServiceSession.processMatches( + process.processIdentifier, + expectedStartTime: startTime + 1 + ) + ) + XCTAssertFalse( + BabelDOCServiceSession.processMatches( + process.processIdentifier, + expectedStartTime: nil + ) + ) + } + + func testLiveExecutorAndLayoutSessionWhenRequested() async throws { + guard + ProcessInfo.processInfo.environment[ + "GLOSS_RUN_BABELDOC_EXECUTOR_SMOKE" + ] == "1" + else { + throw XCTSkip( + "Set GLOSS_RUN_BABELDOC_EXECUTOR_SMOKE=1 with a v1 gloss-babeldoc runtime." + ) + } + let runtime = try XCTUnwrap(BabelDOCExternalEngine.resolveRuntime()) + XCTAssertNotNil(runtime.executorExecutable) + let stateDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: stateDirectory) } + let session = BabelDOCServiceSession( + persistedStateDirectoryURL: stateDirectory + ) + do { + let layoutURL = try await session.start( + runtime: runtime, + timeout: .seconds(120) + ) + let snapshot = await session.snapshot() + XCTAssertEqual(snapshot.lifecycleState, .ready) + XCTAssertTrue(snapshot.runtimeVersion?.hasPrefix("0.6.4+gloss.") == true) + XCTAssertNotNil(snapshot.endpoint) + XCTAssertNotNil(snapshot.processIdentifier) + XCTAssertNotNil(snapshot.instanceID) + let connection = try await session.executorConnection( + runtime: runtime, + timeout: .seconds(5) + ) + XCTAssertEqual(connection.layoutServiceBaseURL, layoutURL) + let healthy = try await BabelDOCExecutorClient( + connection: connection + ).health() + XCTAssertTrue(healthy) + await session.shutdown() + let stopped = await session.snapshot() + XCTAssertEqual(stopped.lifecycleState, .stopped) + } catch { + await session.shutdown() + throw error + } + } + + func testCleanupPersistedServiceIsIdempotentWithoutMarker() async throws { + let stateDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: stateDirectory) } + let session = BabelDOCServiceSession( + persistedStateDirectoryURL: stateDirectory + ) + + try await session.cleanupPersistedService() + try await session.cleanupPersistedService() + } + + func testCleanupPersistedServiceRemovesDeadPrivateWorkrootAndMarker() async throws { + let temporary = FileManager.default.temporaryDirectory + let stateDirectory = + temporary + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let workroot = temporary.appendingPathComponent( + "\(BabelDOCServiceSession.workingDirectoryPrefix)\(UUID().uuidString)", + isDirectory: true + ) + defer { + try? FileManager.default.removeItem(at: stateDirectory) + try? FileManager.default.removeItem(at: workroot) + } + try FileManager.default.createDirectory( + at: stateDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.createDirectory( + at: workroot, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let token = workroot.appendingPathComponent( + BabelDOCServiceSession.executorTokenFileName + ) + XCTAssertTrue( + FileManager.default.createFile( + atPath: token.path, + contents: Data(String(repeating: "a", count: 40).utf8), + attributes: [.posixPermissions: 0o600] + ) + ) + let marker = stateDirectory.appendingPathComponent( + BabelDOCServiceSession.persistedSessionFileName + ) + let payload: [String: Any] = [ + "endpoint": "http://127.0.0.1:49231", + "tokenFile": token.absoluteString, + "workroot": workroot.absoluteString, + "layoutEndpoint": "http://127.0.0.1:49232", + "layoutPID": NSNull(), + "instanceID": "dead-instance", + "pid": Int32.max, + "processStartTime": 100.0, + "parentPID": Int32.max, + "runtimeVersion": "0.6.4+gloss.2", + ] + XCTAssertTrue( + FileManager.default.createFile( + atPath: marker.path, + contents: try JSONSerialization.data(withJSONObject: payload), + attributes: [.posixPermissions: 0o644] + ) + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: marker.path + ) + let session = BabelDOCServiceSession( + persistedStateDirectoryURL: stateDirectory + ) + + try await session.cleanupPersistedService() + + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: workroot.path)) + } + + func testCleanupPersistedServiceRemovesMarkerAfterTemporaryWorkrootIsGone() async throws { + let temporary = FileManager.default.temporaryDirectory + let stateDirectory = temporary.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + let missingWorkroot = temporary.appendingPathComponent( + "\(BabelDOCServiceSession.workingDirectoryPrefix)\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: stateDirectory) } + let marker = try writePersistedSessionMarker( + in: stateDirectory, + workroot: missingWorkroot, + executorPID: Int32.max, + layoutPID: Int32.max - 1 + ) + let session = BabelDOCServiceSession( + persistedStateDirectoryURL: stateDirectory + ) + + try await session.cleanupPersistedService() + + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: missingWorkroot.path)) + } + + func testCleanupPersistedServicePreservesMissingWorkrootMarkerForLivePID() async throws { + let temporary = FileManager.default.temporaryDirectory + let stateDirectory = temporary.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + let missingWorkroot = temporary.appendingPathComponent( + "\(BabelDOCServiceSession.workingDirectoryPrefix)\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: stateDirectory) } + let marker = try writePersistedSessionMarker( + in: stateDirectory, + workroot: missingWorkroot, + executorPID: Int32(ProcessInfo.processInfo.processIdentifier), + layoutPID: Int32.max + ) + let session = BabelDOCServiceSession( + persistedStateDirectoryURL: stateDirectory + ) + + do { + try await session.cleanupPersistedService() + XCTFail("Expected cleanup to fail closed for a live recorded PID") + } catch { + XCTAssertTrue(error is BabelDOCExecutorError) + } + + XCTAssertTrue(FileManager.default.fileExists(atPath: marker.path)) + } + + func testIdleShutdownPreservesARecoverableForeignSessionMarker() async throws { + let stateDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: stateDirectory) } + try FileManager.default.createDirectory( + at: stateDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let marker = stateDirectory.appendingPathComponent( + BabelDOCServiceSession.persistedSessionFileName + ) + try Data("foreign-session".utf8).write(to: marker) + + let session = BabelDOCServiceSession( + persistedStateDirectoryURL: stateDirectory + ) + let stopped = await session.shutdown() + XCTAssertTrue(stopped) + XCTAssertTrue(FileManager.default.fileExists(atPath: marker.path)) + } + + func testPrivateAtomicWriterPublishesOnlyMode0600() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let destination = directory.appendingPathComponent("state.json") + try Data("old".utf8).write(to: destination) + + try BabelDOCServiceSession.writePrivateAtomicFile( + Data("new".utf8), + to: destination + ) + + XCTAssertEqual(try Data(contentsOf: destination), Data("new".utf8)) + let attributes = try FileManager.default.attributesOfItem( + atPath: destination.path + ) + XCTAssertEqual( + (attributes[.posixPermissions] as? NSNumber)?.intValue, + 0o600 + ) + } + + func testTerminationEscalatesForChildIgnoringTerm() async throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", "trap '' TERM; while :; do sleep 1; done"] + try process.run() + try await Task.sleep(for: .milliseconds(100)) + + let exited = await BabelDOCServiceSession.terminateChildProcess( + process, + allowGracefulExit: false + ) + + XCTAssertTrue(exited) + XCTAssertFalse(process.isRunning) + } + + func testManagedRuntimeLaunchesSelfContainedLayoutService() throws { + let launch = try BabelDOCServiceSession.layoutLaunch( + runtime: BabelDOCRuntimeLaunch( + executable: "/managed/gloss-babeldoc", + source: "managed", + executorExecutable: "/managed/gloss-babeldoc" + ), + legacyInterpreter: nil, + scriptURL: URL(fileURLWithPath: "/tmp/layout_service.py"), + parentPID: 42 + ) + + XCTAssertEqual(launch.executable, "/managed/gloss-babeldoc") + XCTAssertEqual( + launch.arguments, + [ + "layout-serve", + "--host", "127.0.0.1", + "--port", "0", + "--parent-pid", "42", + ] + ) + } + + func testLegacyRuntimeUsesPrivatePythonLayoutScript() throws { + let launch = try BabelDOCServiceSession.layoutLaunch( + runtime: BabelDOCRuntimeLaunch( + executable: "/legacy/babeldoc", + source: "legacy" + ), + legacyInterpreter: "/legacy/python", + scriptURL: URL(fileURLWithPath: "/tmp/layout_service.py"), + parentPID: 24 + ) + + XCTAssertEqual(launch.executable, "/legacy/python") + XCTAssertEqual(launch.arguments.first, "/tmp/layout_service.py") + XCTAssertEqual(Array(launch.arguments.suffix(2)), ["--parent-pid", "24"]) + } + func testCleansOnlySafeStaleDirectories() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -90,4 +418,45 @@ final class BabelDOCServiceSessionTests: XCTestCase { ) } } + + private func writePersistedSessionMarker( + in stateDirectory: URL, + workroot: URL, + executorPID: Int32, + layoutPID: Int32? + ) throws -> URL { + try FileManager.default.createDirectory( + at: stateDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let token = workroot.appendingPathComponent( + BabelDOCServiceSession.executorTokenFileName + ) + let marker = stateDirectory.appendingPathComponent( + BabelDOCServiceSession.persistedSessionFileName + ) + let payload: [String: Any] = [ + "endpoint": "http://127.0.0.1:49231", + "tokenFile": token.absoluteString, + "workroot": workroot.absoluteString, + "layoutEndpoint": "http://127.0.0.1:49232", + "layoutPID": layoutPID.map { $0 as Any } ?? NSNull(), + "layoutProcessStartTime": 100.0, + "instanceID": "missing-workroot-instance", + "pid": executorPID, + "processStartTime": 100.0, + "parentPID": Int32.max, + "runtimeVersion": "0.6.4+gloss.3", + ] + try JSONSerialization.data(withJSONObject: payload).write( + to: marker, + options: .atomic + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: marker.path + ) + return marker + } } From c35b6f30bcbdbbdc26063582a6d8926654f32b7b Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Thu, 23 Jul 2026 01:23:31 -0700 Subject: [PATCH 2/8] feat: manage signed BabelDOC runtime updates --- .../BabelDOCRuntimeDistribution.swift | 1522 +++++++++++++++++ .../BabelDOCRuntimeDistributionTests.swift | 1109 ++++++++++++ 2 files changed, 2631 insertions(+) create mode 100644 Sources/GlossCore/BabelDOCRuntimeDistribution.swift create mode 100644 Tests/GlossCoreTests/BabelDOCRuntimeDistributionTests.swift diff --git a/Sources/GlossCore/BabelDOCRuntimeDistribution.swift b/Sources/GlossCore/BabelDOCRuntimeDistribution.swift new file mode 100644 index 0000000..a54f275 --- /dev/null +++ b/Sources/GlossCore/BabelDOCRuntimeDistribution.swift @@ -0,0 +1,1522 @@ +import CryptoKit +import Darwin +import Foundation + +public enum BabelDOCRuntimeChannel: String, Codable, CaseIterable, Sendable { + case stable + case beta + case nightly + + // The downstream release workflow currently publishes only stable. + // Keep the reserved cases decodable so future manifests remain source + // compatible, but do not expose dead update controls in the app. + public static let allCases: [Self] = [.stable] +} + +public enum BabelDOCRuntimeArchiveFormat: String, Codable, Sendable { + case raw + case tarGzip = "tar.gz" + case zip +} + +public struct BabelDOCRuntimePlatform: Codable, Equatable, Hashable, Sendable { + public let operatingSystem: String + public let architecture: String + + public init(operatingSystem: String, architecture: String) { + self.operatingSystem = operatingSystem + self.architecture = architecture + } + + public static var current: Self { + #if os(macOS) + let operatingSystem = "macos" + #elseif os(Linux) + let operatingSystem = "linux" + #else + let operatingSystem = "unsupported" + #endif + + #if arch(arm64) + let architecture = "arm64" + #elseif arch(x86_64) + let architecture = "x86_64" + #else + let architecture = "unsupported" + #endif + + return Self(operatingSystem: operatingSystem, architecture: architecture) + } +} + +public struct BabelDOCRuntimeManifest: Codable, Equatable, Sendable { + public struct Asset: Codable, Equatable, Sendable { + public let operatingSystem: String + public let architecture: String + public let url: URL + public let sha256: String + public let size: Int64? + public let archiveFormat: BabelDOCRuntimeArchiveFormat + public let executablePath: String + + public init( + operatingSystem: String, + architecture: String, + url: URL, + sha256: String, + size: Int64? = nil, + archiveFormat: BabelDOCRuntimeArchiveFormat, + executablePath: String = "gloss-babeldoc" + ) { + self.operatingSystem = operatingSystem + self.architecture = architecture + self.url = url + self.sha256 = sha256 + self.size = size + self.archiveFormat = archiveFormat + self.executablePath = executablePath + } + + public var platform: BabelDOCRuntimePlatform { + BabelDOCRuntimePlatform( + operatingSystem: operatingSystem, + architecture: architecture + ) + } + } + + public let schemaVersion: Int + public let channel: BabelDOCRuntimeChannel + public let version: String + public let releaseTag: String + public let publishedAt: String + public let minimumGlossVersion: String? + public let releaseNotesURL: URL? + public let assets: [Asset] + + public init( + schemaVersion: Int = 1, + channel: BabelDOCRuntimeChannel, + version: String, + releaseTag: String, + publishedAt: String, + minimumGlossVersion: String? = nil, + releaseNotesURL: URL? = nil, + assets: [Asset] + ) { + self.schemaVersion = schemaVersion + self.channel = channel + self.version = version + self.releaseTag = releaseTag + self.publishedAt = publishedAt + self.minimumGlossVersion = minimumGlossVersion + self.releaseNotesURL = releaseNotesURL + self.assets = assets + } + + public func asset(for platform: BabelDOCRuntimePlatform) -> Asset? { + assets.first { $0.platform == platform } + } +} + +public struct BabelDOCRuntimeReleaseEndpoint: Equatable, Sendable { + public let repositoryURL: URL + public let manifestAssetName: String + + public init( + repositoryURL: URL = URL(string: "https://github.com/SunChJ/BabelDOC")!, + manifestAssetName: String = "gloss-runtime-manifest.json" + ) { + self.repositoryURL = repositoryURL + self.manifestAssetName = manifestAssetName + } + + public func manifestURL(for channel: BabelDOCRuntimeChannel) -> URL { + if channel == .stable { + return + repositoryURL + .appendingPathComponent("releases") + .appendingPathComponent("latest") + .appendingPathComponent("download") + .appendingPathComponent(manifestAssetName) + } + return + repositoryURL + .appendingPathComponent("releases") + .appendingPathComponent("download") + .appendingPathComponent(channel.rawValue) + .appendingPathComponent(manifestAssetName) + } + + public func signatureURL(forManifestURL manifestURL: URL) -> URL { + guard + var components = URLComponents( + url: manifestURL, + resolvingAgainstBaseURL: false + ) + else { + return manifestURL.appendingPathExtension("sig") + } + components.path += ".sig" + return components.url ?? manifestURL.appendingPathExtension("sig") + } +} + +public struct BabelDOCRuntimeTransport: Sendable { + public typealias FetchData = @Sendable (URL) async throws -> Data + public typealias Download = @Sendable (URL, URL) async throws -> Void + + private let fetchDataImplementation: FetchData + private let downloadImplementation: Download + + public init( + fetchData: @escaping FetchData, + download: @escaping Download + ) { + fetchDataImplementation = fetchData + downloadImplementation = download + } + + public func fetchData(from url: URL) async throws -> Data { + try await fetchDataImplementation(url) + } + + public func download(from url: URL, to destination: URL) async throws { + try await downloadImplementation(url, destination) + } + + public static let live = ephemeral() + + public static func ephemeral( + policy: BabelDOCRuntimeNetworkPolicy = BabelDOCRuntimeNetworkPolicy() + ) -> Self { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = policy.requestTimeout + configuration.timeoutIntervalForResource = policy.resourceTimeout + configuration.waitsForConnectivity = policy.waitsForConnectivity + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + let session = URLSession(configuration: configuration) + + return Self( + fetchData: { url in + let (data, response) = try await session.data(from: url) + try validateHTTPResponse(response, for: url) + return data + }, + download: { url, destination in + let (temporaryURL, response) = try await session.download(from: url) + try validateHTTPResponse(response, for: url) + + let fileManager = FileManager.default + if fileManager.fileExists(atPath: destination.path) { + try fileManager.removeItem(at: destination) + } + try fileManager.moveItem(at: temporaryURL, to: destination) + } + ) + } + + private static func validateHTTPResponse(_ response: URLResponse, for url: URL) throws { + guard let response = response as? HTTPURLResponse else { + return + } + guard (200..<300).contains(response.statusCode) else { + throw BabelDOCRuntimeDistributionError.httpFailure( + url: url, + statusCode: response.statusCode + ) + } + } +} + +public struct BabelDOCRuntimeNetworkPolicy: Equatable, Sendable { + public let requestTimeout: TimeInterval + public let resourceTimeout: TimeInterval + public let waitsForConnectivity: Bool + + public init( + requestTimeout: TimeInterval = 12, + resourceTimeout: TimeInterval = 60, + waitsForConnectivity: Bool = false + ) { + self.requestTimeout = requestTimeout + self.resourceTimeout = resourceTimeout + self.waitsForConnectivity = waitsForConnectivity + } +} + +public enum BabelDOCRuntimeDistributionError: LocalizedError, Equatable, Sendable { + case httpFailure(url: URL, statusCode: Int) + case invalidManifest(String) + case invalidManifestSigningKey + case manifestSignatureInvalid + case currentGlossVersionUnavailable(minimum: String) + case minimumGlossVersionNotMet(current: String, minimum: String) + case unsupportedPlatform(BabelDOCRuntimePlatform) + case channelUnavailable(BabelDOCRuntimeChannel) + case channelMismatch(expected: BabelDOCRuntimeChannel, actual: BabelDOCRuntimeChannel) + case pinnedVersionMismatch(expected: String, actual: String) + case payloadSizeMismatch(expected: Int64, actual: Int64) + case checksumMismatch(expected: String, actual: String) + case invalidArchiveEntry(String) + case archiveContainsLink(String) + case archiveExtractionFailed(String) + case executableMissing(String) + case executableIsLink(String) + case executablePermissionFailed(String) + case noUpdateAvailable + case rollbackUnavailable + case corruptInstallationState + + public var errorDescription: String? { + switch self { + case .httpFailure(let url, let statusCode): + "下载 \(url.absoluteString) 失败(HTTP \(statusCode))。" + case .invalidManifest(let reason): + "BabelDOC runtime manifest 无效:\(reason)" + case .invalidManifestSigningKey: + "Gloss 内置的 BabelDOC manifest 签名公钥无效。" + case .manifestSignatureInvalid: + "BabelDOC runtime manifest 的 Ed25519 签名无效。" + case .currentGlossVersionUnavailable(let minimum): + "无法确认 Gloss 版本,不能安装要求 Gloss \(minimum) 或更高版本的 runtime。" + case .minimumGlossVersionNotMet(let current, let minimum): + "BabelDOC runtime 要求 Gloss \(minimum) 或更高版本,当前是 \(current)。" + case .unsupportedPlatform(let platform): + "当前平台没有可用的 BabelDOC runtime:\(platform.operatingSystem)/\(platform.architecture)" + case .channelUnavailable(let channel): + "BabelDOC runtime 更新通道尚未发布:\(channel.rawValue)。" + case .channelMismatch(let expected, let actual): + "Runtime 更新通道不匹配:需要 \(expected.rawValue),收到 \(actual.rawValue)。" + case .pinnedVersionMismatch(let expected, let actual): + "Runtime 已固定为 \(expected),manifest 提供的是 \(actual)。" + case .payloadSizeMismatch(let expected, let actual): + "BabelDOC runtime 文件大小不匹配:需要 \(expected) bytes,实际 \(actual) bytes。" + case .checksumMismatch(let expected, let actual): + "BabelDOC runtime SHA-256 校验失败:需要 \(expected),实际 \(actual)。" + case .invalidArchiveEntry(let entry): + "BabelDOC runtime 压缩包包含不安全路径:\(entry)" + case .archiveContainsLink(let entry): + "BabelDOC runtime 压缩包包含不允许的链接:\(entry)" + case .archiveExtractionFailed(let message): + "无法解压 BabelDOC runtime:\(message)" + case .executableMissing(let path): + "BabelDOC runtime 中没有 gloss-babeldoc:\(path)" + case .executableIsLink(let path): + "BabelDOC runtime 的可执行文件不能是符号链接:\(path)" + case .executablePermissionFailed(let path): + "无法验证 BabelDOC runtime 的执行权限:\(path)" + case .noUpdateAvailable: + "当前没有可安装的 BabelDOC runtime 更新。" + case .rollbackUnavailable: + "没有可回滚的 BabelDOC runtime 版本。" + case .corruptInstallationState: + "BabelDOC runtime 安装状态已损坏。" + } + } +} + +public enum BabelDOCRuntimeOperation: String, Codable, Sendable { + case idle + case checking + case downloading + case verifying + case extracting + case installing + case rollingBack + case ready + case failed +} + +public struct BabelDOCRuntimeProgress: Equatable, Sendable { + public let operation: BabelDOCRuntimeOperation + public let version: String? + public let detail: String? + + public init( + operation: BabelDOCRuntimeOperation, + version: String? = nil, + detail: String? = nil + ) { + self.operation = operation + self.version = version + self.detail = detail + } +} + +public struct BabelDOCRuntimeSnapshot: Equatable, Sendable { + public let channel: BabelDOCRuntimeChannel + public let pinnedVersion: String? + public let currentVersion: String? + public let previousVersion: String? + public let availableVersion: String? + public let currentExecutableURL: URL? + public let updateAvailable: Bool + public let operation: BabelDOCRuntimeOperation + public let lastError: String? + + public init( + channel: BabelDOCRuntimeChannel, + pinnedVersion: String?, + currentVersion: String?, + previousVersion: String?, + availableVersion: String?, + currentExecutableURL: URL?, + updateAvailable: Bool, + operation: BabelDOCRuntimeOperation, + lastError: String? + ) { + self.channel = channel + self.pinnedVersion = pinnedVersion + self.currentVersion = currentVersion + self.previousVersion = previousVersion + self.availableVersion = availableVersion + self.currentExecutableURL = currentExecutableURL + self.updateAvailable = updateAvailable + self.operation = operation + self.lastError = lastError + } +} + +public actor BabelDOCRuntimeManager { + public typealias ProgressHandler = @Sendable (BabelDOCRuntimeProgress) -> Void + + private struct Installation: Codable, Equatable, Sendable { + let version: String + let directoryName: String + let executablePath: String + let sha256: String + } + + private struct PersistedState: Codable, Equatable, Sendable { + var schemaVersion = 1 + var channel: BabelDOCRuntimeChannel + var pinnedVersion: String? + var current: Installation? + var previous: Installation? + } + + public static let defaultDirectoryURL = + FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + )[0] + .appendingPathComponent("Gloss", isDirectory: true) + .appendingPathComponent("BabelDOCRuntime", isDirectory: true) + + public static let pinnedManifestSigningPublicKey = Data( + base64Encoded: "0lgbX+CkmBjf4BnH9JO66I7Krd1DYM8lTOjIt+7zWEE=" + )! + + public static var detectedGlossVersion: String? { + Bundle.main.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String + } + + private let rootDirectory: URL + private let versionsDirectory: URL + private let stateURL: URL + private let endpoint: BabelDOCRuntimeReleaseEndpoint + private let transport: BabelDOCRuntimeTransport + private let platform: BabelDOCRuntimePlatform + private let fileManager: FileManager + private let manifestSigningPublicKey: Curve25519.Signing.PublicKey + private let currentGlossVersion: String? + + private var persistedState: PersistedState + private var availableManifest: BabelDOCRuntimeManifest? + private var operation: BabelDOCRuntimeOperation = .idle + private var lastError: String? + private var observers: [UUID: AsyncStream.Continuation] = [:] + + public init( + rootDirectory: URL = BabelDOCRuntimeManager.defaultDirectoryURL, + endpoint: BabelDOCRuntimeReleaseEndpoint = BabelDOCRuntimeReleaseEndpoint(), + channel: BabelDOCRuntimeChannel = .stable, + platform: BabelDOCRuntimePlatform = .current, + transport: BabelDOCRuntimeTransport = .live, + manifestSigningPublicKey: Data = BabelDOCRuntimeManager.pinnedManifestSigningPublicKey, + currentGlossVersion: String? = BabelDOCRuntimeManager.detectedGlossVersion, + fileManager: FileManager = .default + ) throws { + self.rootDirectory = rootDirectory + versionsDirectory = rootDirectory.appendingPathComponent("versions", isDirectory: true) + stateURL = rootDirectory.appendingPathComponent("state.json") + self.endpoint = endpoint + self.transport = transport + self.platform = platform + self.fileManager = fileManager + self.currentGlossVersion = currentGlossVersion + do { + self.manifestSigningPublicKey = try Curve25519.Signing.PublicKey( + rawRepresentation: manifestSigningPublicKey + ) + } catch { + throw BabelDOCRuntimeDistributionError.invalidManifestSigningKey + } + + try Self.prepareDirectory(rootDirectory, fileManager: fileManager) + try Self.prepareDirectory(versionsDirectory, fileManager: fileManager) + let loadedState = try Self.loadState( + from: stateURL, + defaultChannel: channel, + fileManager: fileManager + ) + let restoredState = try Self.validate( + loadedState, + versionsDirectory: versionsDirectory, + fileManager: fileManager + ) + persistedState = restoredState + } + + public func snapshot() -> BabelDOCRuntimeSnapshot { + makeSnapshot() + } + + public var currentVersion: String? { + persistedState.current?.version + } + + public var currentExecutableURL: URL? { + persistedState.current.map(executableURL(for:)) + } + + public var availableVersion: String? { + availableManifest?.version + } + + public var updateAvailable: Bool { + makeSnapshot().updateAvailable + } + + public func snapshots() -> AsyncStream { + let identifier = UUID() + return AsyncStream { continuation in + observers[identifier] = continuation + continuation.yield(makeSnapshot()) + continuation.onTermination = { [weak self] _ in + Task { + await self?.removeObserver(identifier) + } + } + } + } + + @discardableResult + public func setChannel(_ channel: BabelDOCRuntimeChannel) throws -> BabelDOCRuntimeSnapshot { + guard BabelDOCRuntimeChannel.allCases.contains(channel) else { + throw BabelDOCRuntimeDistributionError.channelUnavailable(channel) + } + let previousState = persistedState + persistedState.channel = channel + persistedState.pinnedVersion = nil + availableManifest = nil + lastError = nil + do { + try persist() + } catch { + persistedState = previousState + throw error + } + return publishSnapshot() + } + + @discardableResult + public func pin(version: String?) throws -> BabelDOCRuntimeSnapshot { + if let version { + try Self.validateVersion(version) + } + let previousState = persistedState + persistedState.pinnedVersion = version + availableManifest = nil + lastError = nil + do { + try persist() + } catch { + persistedState = previousState + throw error + } + return publishSnapshot() + } + + @discardableResult + public func checkForUpdates( + manifestURL: URL? = nil, + signatureURL: URL? = nil, + progress: ProgressHandler? = nil + ) async throws -> BabelDOCRuntimeSnapshot { + let url = manifestURL ?? endpoint.manifestURL(for: persistedState.channel) + let detachedSignatureURL = + signatureURL ?? endpoint.signatureURL(forManifestURL: url) + emit(.init(operation: .checking, detail: url.absoluteString), progress: progress) + + do { + async let manifestDownload = transport.fetchData(from: url) + async let signatureDownload = transport.fetchData(from: detachedSignatureURL) + let (data, encodedSignature) = try await ( + manifestDownload, + signatureDownload + ) + let signature = try Self.decodeSignature(encodedSignature) + guard manifestSigningPublicKey.isValidSignature(signature, for: data) else { + throw BabelDOCRuntimeDistributionError.manifestSignatureInvalid + } + let manifest = try JSONDecoder().decode(BabelDOCRuntimeManifest.self, from: data) + try validate(manifest) + guard manifest.asset(for: platform) != nil else { + throw BabelDOCRuntimeDistributionError.unsupportedPlatform(platform) + } + availableManifest = manifest + operation = .ready + lastError = nil + return publishSnapshot() + } catch { + record(error, progress: progress) + throw error + } + } + + @discardableResult + public func update( + manifestURL: URL? = nil, + signatureURL: URL? = nil, + progress: ProgressHandler? = nil + ) async throws -> BabelDOCRuntimeSnapshot { + let checked = try await checkForUpdates( + manifestURL: manifestURL, + signatureURL: signatureURL, + progress: progress + ) + guard checked.updateAvailable, let manifest = availableManifest else { + throw BabelDOCRuntimeDistributionError.noUpdateAvailable + } + return try await install(manifest, progress: progress) + } + + @discardableResult + public func install( + _ manifest: BabelDOCRuntimeManifest, + progress: ProgressHandler? = nil + ) async throws -> BabelDOCRuntimeSnapshot { + let stateBeforeInstall = persistedState + let manifestBeforeInstall = availableManifest + do { + try validate(manifest) + guard let asset = manifest.asset(for: platform) else { + throw BabelDOCRuntimeDistributionError.unsupportedPlatform(platform) + } + try Self.validate(asset) + + let stagingDirectory = rootDirectory.appendingPathComponent( + ".staging-\(UUID().uuidString)", + isDirectory: true + ) + try Self.prepareDirectory(stagingDirectory, fileManager: fileManager) + defer { + try? fileManager.removeItem(at: stagingDirectory) + } + + let archiveURL = stagingDirectory.appendingPathComponent("payload") + emit( + .init( + operation: .downloading, + version: manifest.version, + detail: asset.url.absoluteString + ), + progress: progress + ) + try await transport.download(from: asset.url, to: archiveURL) + + emit( + .init(operation: .verifying, version: manifest.version), + progress: progress + ) + if let expectedSize = asset.size { + let attributes = try fileManager.attributesOfItem( + atPath: archiveURL.path + ) + let actualSize = (attributes[.size] as? NSNumber)?.int64Value ?? -1 + guard actualSize == expectedSize else { + throw BabelDOCRuntimeDistributionError.payloadSizeMismatch( + expected: expectedSize, + actual: actualSize + ) + } + } + let actualSHA256 = try Self.sha256(of: archiveURL) + guard Self.normalizedSHA256(actualSHA256) == Self.normalizedSHA256(asset.sha256) else { + throw BabelDOCRuntimeDistributionError.checksumMismatch( + expected: asset.sha256, + actual: actualSHA256 + ) + } + + let contentDirectory = stagingDirectory.appendingPathComponent( + "content", + isDirectory: true + ) + try Self.prepareDirectory(contentDirectory, fileManager: fileManager) + emit( + .init(operation: .extracting, version: manifest.version), + progress: progress + ) + try await extract( + archiveURL, + format: asset.archiveFormat, + executablePath: asset.executablePath, + into: contentDirectory + ) + + let executableURL = contentDirectory.appendingPathComponent(asset.executablePath) + try Self.validateExecutable( + executableURL, + inside: contentDirectory, + fileManager: fileManager + ) + + emit( + .init(operation: .installing, version: manifest.version), + progress: progress + ) + let directoryName = Self.installationDirectoryName( + version: manifest.version, + sha256: actualSHA256 + ) + let installedDirectory = versionsDirectory.appendingPathComponent( + directoryName, + isDirectory: true + ) + if fileManager.fileExists(atPath: installedDirectory.path) { + try Self.validateExecutable( + installedDirectory.appendingPathComponent(asset.executablePath), + inside: installedDirectory, + fileManager: fileManager + ) + } else { + try fileManager.moveItem(at: contentDirectory, to: installedDirectory) + } + + let installation = Installation( + version: manifest.version, + directoryName: directoryName, + executablePath: asset.executablePath, + sha256: actualSHA256 + ) + if persistedState.current != installation { + persistedState.previous = persistedState.current + persistedState.current = installation + } + availableManifest = manifest + operation = .ready + lastError = nil + try persist() + removeUnreferencedInstallations() + emit(.init(operation: .ready, version: manifest.version), progress: progress) + return publishSnapshot() + } catch { + persistedState = stateBeforeInstall + availableManifest = manifestBeforeInstall + record(error, progress: progress) + throw error + } + } + + @discardableResult + public func rollback( + progress: ProgressHandler? = nil + ) throws -> BabelDOCRuntimeSnapshot { + guard let previous = persistedState.previous else { + throw BabelDOCRuntimeDistributionError.rollbackUnavailable + } + let previousExecutable = executableURL(for: previous) + try Self.validateExecutable( + previousExecutable, + inside: versionsDirectory.appendingPathComponent( + previous.directoryName, + isDirectory: true + ), + repairPermissions: false, + fileManager: fileManager + ) + + emit(.init(operation: .rollingBack, version: previous.version), progress: progress) + let previousState = persistedState + let current = persistedState.current + persistedState.current = previous + persistedState.previous = current + availableManifest = nil + operation = .ready + lastError = nil + do { + try persist() + } catch { + persistedState = previousState + record(error, progress: progress) + throw error + } + emit(.init(operation: .ready, version: previous.version), progress: progress) + return publishSnapshot() + } + + private func validate(_ manifest: BabelDOCRuntimeManifest) throws { + guard manifest.schemaVersion == 1 else { + throw BabelDOCRuntimeDistributionError.invalidManifest( + "不支持 schemaVersion \(manifest.schemaVersion)" + ) + } + try Self.validateVersion(manifest.version) + guard !manifest.releaseTag.isEmpty else { + throw BabelDOCRuntimeDistributionError.invalidManifest("releaseTag 不能为空") + } + guard ISO8601DateFormatter().date(from: manifest.publishedAt) != nil else { + throw BabelDOCRuntimeDistributionError.invalidManifest( + "publishedAt 必须是 ISO-8601 时间" + ) + } + guard manifest.channel == persistedState.channel else { + throw BabelDOCRuntimeDistributionError.channelMismatch( + expected: persistedState.channel, + actual: manifest.channel + ) + } + if let pinnedVersion = persistedState.pinnedVersion, + pinnedVersion != manifest.version + { + throw BabelDOCRuntimeDistributionError.pinnedVersionMismatch( + expected: pinnedVersion, + actual: manifest.version + ) + } + guard !manifest.assets.isEmpty else { + throw BabelDOCRuntimeDistributionError.invalidManifest("assets 不能为空") + } + if let releaseNotesURL = manifest.releaseNotesURL, + releaseNotesURL.scheme?.lowercased() != "https" + { + throw BabelDOCRuntimeDistributionError.invalidManifest( + "releaseNotesURL 必须使用 HTTPS" + ) + } + if let minimumGlossVersion = manifest.minimumGlossVersion { + try Self.validateVersion(minimumGlossVersion) + guard let currentGlossVersion else { + throw BabelDOCRuntimeDistributionError.currentGlossVersionUnavailable( + minimum: minimumGlossVersion + ) + } + try Self.validateVersion(currentGlossVersion) + if Self.naturalCompare( + currentGlossVersion, + minimumGlossVersion + ) == .orderedAscending { + throw BabelDOCRuntimeDistributionError.minimumGlossVersionNotMet( + current: currentGlossVersion, + minimum: minimumGlossVersion + ) + } + } + } + + private static func validate(_ asset: BabelDOCRuntimeManifest.Asset) throws { + guard asset.sha256.range(of: "^[0-9a-fA-F]{64}$", options: .regularExpression) != nil else { + throw BabelDOCRuntimeDistributionError.invalidManifest("asset.sha256 必须是 64 位十六进制") + } + guard asset.url.scheme == "https" || asset.url.isFileURL else { + throw BabelDOCRuntimeDistributionError.invalidManifest("asset URL 必须使用 HTTPS") + } + try validateRelativePath(asset.executablePath) + guard URL(fileURLWithPath: asset.executablePath).lastPathComponent == "gloss-babeldoc" else { + throw BabelDOCRuntimeDistributionError.invalidManifest( + "asset executablePath 必须指向 gloss-babeldoc" + ) + } + if let size = asset.size, size <= 0 { + throw BabelDOCRuntimeDistributionError.invalidManifest("asset.size 必须大于 0") + } + } + + private func extract( + _ archiveURL: URL, + format: BabelDOCRuntimeArchiveFormat, + executablePath: String, + into destination: URL + ) async throws { + switch format { + case .raw: + let executableURL = destination.appendingPathComponent(executablePath) + try fileManager.createDirectory( + at: executableURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try fileManager.copyItem(at: archiveURL, to: executableURL) + case .tarGzip: + let entries = try await Self.run( + executable: "/usr/bin/tar", + arguments: ["-tzf", archiveURL.path] + ) + try Self.validateArchiveEntries(entries) + let listing = try await Self.run( + executable: "/usr/bin/tar", + arguments: ["-tvzf", archiveURL.path] + ) + try Self.rejectTarLinks(listing) + _ = try await Self.run( + executable: "/usr/bin/tar", + arguments: [ + "-xzf", archiveURL.path, + "-C", destination.path, + "--no-same-owner", + "--no-same-permissions", + ] + ) + case .zip: + let entries = try await Self.run( + executable: "/usr/bin/unzip", + arguments: ["-Z1", archiveURL.path] + ) + try Self.validateArchiveEntries(entries) + let listing = try await Self.run( + executable: "/usr/bin/zipinfo", + arguments: ["-l", archiveURL.path] + ) + try Self.rejectZipLinks(listing) + _ = try await Self.run( + executable: "/usr/bin/ditto", + arguments: ["-x", "-k", archiveURL.path, destination.path] + ) + } + try Self.rejectExtractedLinks(in: destination, fileManager: fileManager) + } + + private func makeSnapshot() -> BabelDOCRuntimeSnapshot { + let current = persistedState.current + let availableVersion = availableManifest?.version + return BabelDOCRuntimeSnapshot( + channel: persistedState.channel, + pinnedVersion: persistedState.pinnedVersion, + currentVersion: current?.version, + previousVersion: persistedState.previous?.version, + availableVersion: availableVersion, + currentExecutableURL: current.map(executableURL(for:)), + updateAvailable: Self.isUpdateAvailable( + currentVersion: current?.version, + availableVersion: availableVersion, + pinnedVersion: persistedState.pinnedVersion + ), + operation: operation, + lastError: lastError + ) + } + + private func executableURL(for installation: Installation) -> URL { + versionsDirectory + .appendingPathComponent(installation.directoryName, isDirectory: true) + .appendingPathComponent(installation.executablePath) + } + + private func emit( + _ update: BabelDOCRuntimeProgress, + progress: ProgressHandler? + ) { + operation = update.operation + progress?(update) + _ = publishSnapshot() + } + + private func record(_ error: Error, progress: ProgressHandler?) { + operation = .failed + lastError = error.localizedDescription + progress?( + .init( + operation: .failed, + detail: error.localizedDescription + ) + ) + _ = publishSnapshot() + } + + @discardableResult + private func publishSnapshot() -> BabelDOCRuntimeSnapshot { + let value = makeSnapshot() + for continuation in observers.values { + continuation.yield(value) + } + return value + } + + private func removeObserver(_ identifier: UUID) { + observers.removeValue(forKey: identifier) + } + + private func persist() throws { + let data = try JSONEncoder.pretty.encode(persistedState) + try Self.writeStateAtomically(data, to: stateURL) + } + + static func writeStateAtomically( + _ data: Data, + to stateURL: URL, + afterSecuringTemporaryFile: ((URL) throws -> Void)? = nil + ) throws { + let temporaryURL = stateURL.deletingLastPathComponent() + .appendingPathComponent( + ".\(stateURL.lastPathComponent).\(UUID().uuidString).tmp" + ) + let descriptor = temporaryURL.path.withCString { + open( + $0, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, + mode_t(0o600) + ) + } + guard descriptor >= 0 else { + throw posixError(errno, path: temporaryURL.path) + } + + var descriptorIsOpen = true + var temporaryFileExists = true + defer { + if descriptorIsOpen { + close(descriptor) + } + if temporaryFileExists { + temporaryURL.path.withCString { _ = unlink($0) } + } + } + + guard fchmod(descriptor, mode_t(0o600)) == 0 else { + throw posixError(errno, path: temporaryURL.path) + } + var descriptorStatus = stat() + guard + fstat(descriptor, &descriptorStatus) == 0, + descriptorStatus.st_mode & S_IFMT == S_IFREG, + descriptorStatus.st_uid == getuid(), + descriptorStatus.st_mode & 0o777 == 0o600 + else { + throw BabelDOCRuntimeDistributionError.corruptInstallationState + } + try afterSecuringTemporaryFile?(temporaryURL) + + try data.withUnsafeBytes { bytes in + var offset = 0 + while offset < bytes.count { + guard let baseAddress = bytes.baseAddress else { + throw posixError(EIO, path: temporaryURL.path) + } + let written = Darwin.write( + descriptor, + baseAddress.advanced(by: offset), + bytes.count - offset + ) + if written < 0 { + if errno == EINTR { + continue + } + throw posixError(errno, path: temporaryURL.path) + } + guard written > 0 else { + throw posixError(EIO, path: temporaryURL.path) + } + offset += written + } + } + guard fsync(descriptor) == 0 else { + throw posixError(errno, path: temporaryURL.path) + } + + let closeResult = close(descriptor) + descriptorIsOpen = false + guard closeResult == 0 else { + throw posixError(errno, path: temporaryURL.path) + } + guard + let status = try fileStatus(at: temporaryURL), + status.st_mode & S_IFMT == S_IFREG, + status.st_uid == getuid(), + status.st_mode & 0o777 == 0o600 + else { + throw BabelDOCRuntimeDistributionError.corruptInstallationState + } + + let renameResult = temporaryURL.path.withCString { sourcePath in + stateURL.path.withCString { destinationPath in + Darwin.rename(sourcePath, destinationPath) + } + } + guard renameResult == 0 else { + throw posixError(errno, path: stateURL.path) + } + temporaryFileExists = false + } + + private func removeUnreferencedInstallations() { + let retained = Set( + [persistedState.current?.directoryName, persistedState.previous?.directoryName] + .compactMap { $0 } + ) + guard + let contents = try? fileManager.contentsOfDirectory( + at: versionsDirectory, + includingPropertiesForKeys: nil + ) + else { + return + } + for url in contents where !retained.contains(url.lastPathComponent) { + try? fileManager.removeItem(at: url) + } + } + + private static func prepareDirectory( + _ url: URL, + fileManager: FileManager + ) throws { + if let status = try fileStatus(at: url), + status.st_mode & S_IFMT != S_IFDIR + || status.st_uid != getuid() + { + throw BabelDOCRuntimeDistributionError.corruptInstallationState + } + try fileManager.createDirectory( + at: url, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + guard + let status = try fileStatus(at: url), + status.st_mode & S_IFMT == S_IFDIR, + status.st_uid == getuid() + else { + throw BabelDOCRuntimeDistributionError.corruptInstallationState + } + try fileManager.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: url.path + ) + } + + private static func loadState( + from url: URL, + defaultChannel: BabelDOCRuntimeChannel, + fileManager: FileManager + ) throws -> PersistedState { + guard let status = try fileStatus(at: url) else { + return PersistedState(channel: defaultChannel) + } + do { + guard + status.st_mode & S_IFMT == S_IFREG, + status.st_uid == getuid(), + status.st_mode & 0o777 == 0o600 + else { + throw BabelDOCRuntimeDistributionError.corruptInstallationState + } + let state = try JSONDecoder().decode( + PersistedState.self, + from: Data(contentsOf: url) + ) + guard state.schemaVersion == 1 else { + throw BabelDOCRuntimeDistributionError.corruptInstallationState + } + return state + } catch let error as BabelDOCRuntimeDistributionError { + throw error + } catch { + throw BabelDOCRuntimeDistributionError.corruptInstallationState + } + } + + private static func validate( + _ state: PersistedState, + versionsDirectory: URL, + fileManager: FileManager + ) throws -> PersistedState { + do { + guard BabelDOCRuntimeChannel.allCases.contains(state.channel) else { + throw BabelDOCRuntimeDistributionError.channelUnavailable( + state.channel + ) + } + if let pinnedVersion = state.pinnedVersion { + try validateVersion(pinnedVersion) + } + + if let current = state.current { + do { + try validate( + current, + versionsDirectory: versionsDirectory, + fileManager: fileManager + ) + } catch { + guard let previous = state.previous else { + throw BabelDOCRuntimeDistributionError.corruptInstallationState + } + try validate( + previous, + versionsDirectory: versionsDirectory, + fileManager: fileManager + ) + var recoveredState = state + recoveredState.current = nil + return recoveredState + } + } + if let previous = state.previous { + try validate( + previous, + versionsDirectory: versionsDirectory, + fileManager: fileManager + ) + } + return state + } catch { + throw BabelDOCRuntimeDistributionError.corruptInstallationState + } + } + + private static func validate( + _ installation: Installation, + versionsDirectory: URL, + fileManager: FileManager + ) throws { + try validateVersion(installation.version) + guard + installation.sha256 + .range( + of: "^[0-9a-f]{64}$", + options: .regularExpression + ) != nil, + installation.directoryName + == installationDirectoryName( + version: installation.version, + sha256: installation.sha256 + ), + installation.directoryName + == URL(fileURLWithPath: installation.directoryName) + .lastPathComponent + else { + throw BabelDOCRuntimeDistributionError.corruptInstallationState + } + try validateRelativePath(installation.executablePath) + guard + URL(fileURLWithPath: installation.executablePath) + .lastPathComponent == "gloss-babeldoc" + else { + throw BabelDOCRuntimeDistributionError.corruptInstallationState + } + + let installationDirectory = versionsDirectory.appendingPathComponent( + installation.directoryName, + isDirectory: true + ) + guard + let status = try fileStatus(at: installationDirectory), + status.st_mode & S_IFMT == S_IFDIR, + status.st_uid == getuid() + else { + throw BabelDOCRuntimeDistributionError.corruptInstallationState + } + try rejectSymlinkComponents( + relativePath: installation.executablePath, + inside: installationDirectory + ) + try validateExecutable( + installationDirectory.appendingPathComponent( + installation.executablePath + ), + inside: installationDirectory, + repairPermissions: false, + fileManager: fileManager + ) + } + + private static func posixError(_ code: Int32, path: String) -> NSError { + NSError( + domain: NSPOSIXErrorDomain, + code: Int(code), + userInfo: [NSFilePathErrorKey: path] + ) + } + + private static func fileStatus(at url: URL) throws -> stat? { + var status = stat() + let result = url.path.withCString { path in + lstat(path, &status) + } + if result == 0 { + return status + } + if errno == ENOENT { + return nil + } + throw BabelDOCRuntimeDistributionError.corruptInstallationState + } + + private static func rejectSymlinkComponents( + relativePath: String, + inside root: URL + ) throws { + var current = root + for component in relativePath.split(separator: "/") { + current.appendPathComponent(String(component)) + guard let status = try fileStatus(at: current) else { + throw BabelDOCRuntimeDistributionError.corruptInstallationState + } + if status.st_mode & S_IFMT == S_IFLNK { + throw BabelDOCRuntimeDistributionError.corruptInstallationState + } + } + } + + private static func validateVersion(_ version: String) throws { + guard !version.isEmpty, + version.range(of: "^[0-9A-Za-z][0-9A-Za-z.+_-]{0,127}$", options: .regularExpression) != nil + else { + throw BabelDOCRuntimeDistributionError.invalidManifest( + "version 包含不允许的字符" + ) + } + } + + private static func validateRelativePath(_ path: String) throws { + guard !path.isEmpty, !path.contains("\0") else { + throw BabelDOCRuntimeDistributionError.invalidArchiveEntry(path) + } + let normalized = path.replacingOccurrences(of: "\\", with: "/") + guard !normalized.hasPrefix("/"), + !normalized.hasPrefix("~"), + normalized.range(of: "^[A-Za-z]:") == nil + else { + throw BabelDOCRuntimeDistributionError.invalidArchiveEntry(path) + } + let components = normalized.split(separator: "/", omittingEmptySubsequences: false) + guard !components.contains(where: { $0.isEmpty || $0 == "." || $0 == ".." }) else { + throw BabelDOCRuntimeDistributionError.invalidArchiveEntry(path) + } + } + + static func validateArchiveEntries(_ listing: String) throws { + for entry in listing.split(whereSeparator: \.isNewline) { + let value = String(entry) + var normalized = value.hasSuffix("/") ? String(value.dropLast()) : value + while normalized.hasPrefix("./") { + normalized.removeFirst(2) + } + if normalized == "." { + continue + } + guard !normalized.isEmpty else { + continue + } + try validateRelativePath(normalized) + } + } + + private static func rejectTarLinks(_ listing: String) throws { + for line in listing.split(whereSeparator: \.isNewline) { + guard let type = line.first else { + continue + } + if type == "l" || type == "h" { + throw BabelDOCRuntimeDistributionError.archiveContainsLink(String(line)) + } + } + } + + static func rejectZipLinks(_ listing: String) throws { + for line in listing.split(whereSeparator: \.isNewline) { + let value = line.drop(while: \.isWhitespace) + guard value.count >= 10 else { + continue + } + let permissions = value.prefix(10) + guard + permissions.dropFirst().allSatisfy({ + $0 == "r" || $0 == "w" || $0 == "x" || $0 == "-" + || $0 == "s" || $0 == "S" || $0 == "t" || $0 == "T" + }) + else { + continue + } + guard permissions.first == "-" || permissions.first == "d" else { + throw BabelDOCRuntimeDistributionError.archiveContainsLink( + String(line) + ) + } + } + } + + private static func rejectExtractedLinks( + in root: URL, + fileManager: FileManager + ) throws { + guard + let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.isSymbolicLinkKey] + ) + else { + return + } + for case let url as URL in enumerator { + let values = try url.resourceValues(forKeys: [.isSymbolicLinkKey]) + if values.isSymbolicLink == true { + throw BabelDOCRuntimeDistributionError.archiveContainsLink(url.path) + } + } + } + + private static func validateExecutable( + _ executableURL: URL, + inside root: URL, + repairPermissions: Bool = true, + fileManager: FileManager + ) throws { + let rootPath = root.resolvingSymlinksInPath().standardizedFileURL.path + let executablePath = executableURL.resolvingSymlinksInPath().standardizedFileURL.path + let prefix = rootPath.hasSuffix("/") ? rootPath : "\(rootPath)/" + guard executablePath.hasPrefix(prefix) else { + throw BabelDOCRuntimeDistributionError.executableIsLink(executableURL.path) + } + + guard let status = try fileStatus(at: executableURL) else { + throw BabelDOCRuntimeDistributionError.executableMissing(executableURL.path) + } + guard status.st_mode & S_IFMT == S_IFREG else { + throw BabelDOCRuntimeDistributionError.executableIsLink(executableURL.path) + } + guard status.st_uid == getuid() else { + throw BabelDOCRuntimeDistributionError.executablePermissionFailed( + executableURL.path + ) + } + + if repairPermissions { + try fileManager.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: executableURL.path + ) + } + guard fileManager.isExecutableFile(atPath: executableURL.path) else { + throw BabelDOCRuntimeDistributionError.executablePermissionFailed( + executableURL.path + ) + } + } + + private static func sha256(of url: URL) throws -> String { + let file = try FileHandle(forReadingFrom: url) + defer { + try? file.close() + } + var hasher = SHA256() + while let data = try file.read(upToCount: 1_048_576), !data.isEmpty { + hasher.update(data: data) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + + private static func normalizedSHA256(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private static func decodeSignature(_ data: Data) throws -> Data { + if data.count == 64 { + return data + } + guard + let encoded = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + let decoded = Data(base64Encoded: encoded), + decoded.count == 64 + else { + throw BabelDOCRuntimeDistributionError.manifestSignatureInvalid + } + return decoded + } + + private static func installationDirectoryName( + version: String, + sha256: String + ) -> String { + "\(version)-\(sha256.prefix(12))" + } + + private static func isUpdateAvailable( + currentVersion: String?, + availableVersion: String?, + pinnedVersion: String? + ) -> Bool { + guard let availableVersion else { + return false + } + if let pinnedVersion { + return availableVersion == pinnedVersion && currentVersion != pinnedVersion + } + guard let currentVersion else { + return true + } + return naturalCompare(availableVersion, currentVersion) == .orderedDescending + } + + private static func naturalCompare(_ lhs: String, _ rhs: String) -> ComparisonResult { + lhs.compare( + rhs, + options: [.numeric, .caseInsensitive], + range: nil, + locale: Locale(identifier: "en_US_POSIX") + ) + } + + private static func run( + executable: String, + arguments: [String] + ) async throws -> String { + let process = Process() + let stdout = Pipe() + let stderr = Pipe() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.standardOutput = stdout + process.standardError = stderr + try process.run() + + // Drain both pipes while the child is running. Waiting first can + // deadlock once a large archive listing fills either pipe buffer. + let stdoutTask = Task.detached { + stdout.fileHandleForReading.readDataToEndOfFile() + } + let stderrTask = Task.detached { + stderr.fileHandleForReading.readDataToEndOfFile() + } + process.waitUntilExit() + + let output = await stdoutTask.value + let error = await stderrTask.value + guard process.terminationStatus == 0 else { + let detail = String(data: error, encoding: .utf8) ?? "status \(process.terminationStatus)" + throw BabelDOCRuntimeDistributionError.archiveExtractionFailed( + detail.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + guard let text = String(data: output, encoding: .utf8) else { + throw BabelDOCRuntimeDistributionError.archiveExtractionFailed( + "archive listing is not valid UTF-8" + ) + } + return text + } +} + +extension JSONEncoder { + fileprivate static var pretty: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + return encoder + } +} diff --git a/Tests/GlossCoreTests/BabelDOCRuntimeDistributionTests.swift b/Tests/GlossCoreTests/BabelDOCRuntimeDistributionTests.swift new file mode 100644 index 0000000..d1d5c88 --- /dev/null +++ b/Tests/GlossCoreTests/BabelDOCRuntimeDistributionTests.swift @@ -0,0 +1,1109 @@ +import CryptoKit +import Foundation +import Testing + +@testable import GlossCore + +@Suite("BabelDOC runtime distribution") +struct BabelDOCRuntimeDistributionTests { + private let platform = BabelDOCRuntimePlatform( + operatingSystem: "macos", + architecture: "arm64" + ) + + @Test("manifest selects the exact platform asset") + func manifestSelectsPlatformAsset() throws { + let armAsset = asset( + url: try #require(URL(string: "https://example.com/arm64")), + payload: Data("arm".utf8) + ) + let intelAsset = BabelDOCRuntimeManifest.Asset( + operatingSystem: "macos", + architecture: "x86_64", + url: try #require(URL(string: "https://example.com/x86_64")), + sha256: sha256(Data("intel".utf8)), + archiveFormat: .raw + ) + let manifest = manifest(version: "0.6.4+gloss.2", assets: [intelAsset, armAsset]) + + #expect(manifest.asset(for: platform) == armAsset) + #expect( + manifest.asset( + for: BabelDOCRuntimePlatform( + operatingSystem: "linux", + architecture: "arm64" + ) + ) == nil + ) + } + + @Test("only published channels are exposed while reserved URLs stay deterministic") + func defaultReleaseEndpoint() { + let endpoint = BabelDOCRuntimeReleaseEndpoint() + + #expect(BabelDOCRuntimeChannel.allCases == [.stable]) + #expect( + endpoint.manifestURL(for: .stable).absoluteString + == "https://github.com/SunChJ/BabelDOC/releases/latest/download/gloss-runtime-manifest.json" + ) + #expect( + endpoint.manifestURL(for: .beta).absoluteString + == "https://github.com/SunChJ/BabelDOC/releases/download/beta/gloss-runtime-manifest.json" + ) + #expect( + endpoint.manifestURL(for: .nightly).absoluteString + == "https://github.com/SunChJ/BabelDOC/releases/download/nightly/gloss-runtime-manifest.json" + ) + let manifestURL = endpoint.manifestURL(for: .stable) + #expect( + endpoint.signatureURL(forManifestURL: manifestURL).absoluteString + == "\(manifestURL.absoluteString).sig" + ) + let injectedURL = URL( + string: "https://updates.example.test/runtime.json?token=test" + )! + #expect( + endpoint.signatureURL(forManifestURL: injectedURL).absoluteString + == "https://updates.example.test/runtime.json.sig?token=test" + ) + } + + @Test("live transport uses bounded offline timeouts") + func liveNetworkPolicyIsBounded() { + let policy = BabelDOCRuntimeNetworkPolicy() + + #expect(policy.requestTimeout == 12) + #expect(policy.resourceTimeout == 60) + #expect(!policy.waitsForConnectivity) + } + + @Test("raw runtime install verifies SHA, permissions, and persisted state") + func installsAndPersistsRawRuntime() async throws { + let root = try temporaryDirectory() + let payload = Data("#!/bin/sh\necho gloss-babeldoc\n".utf8) + let assetURL = try #require(URL(string: "https://example.com/gloss-babeldoc")) + let runtimeManifest = manifest( + version: "0.6.4+gloss.2", + assets: [asset(url: assetURL, payload: payload)] + ) + let manager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport(payloads: [assetURL: payload]), + manifestSigningPublicKey: signingPublicKey + ) + + let installed = try await manager.install(runtimeManifest) + + #expect(installed.currentVersion == "0.6.4+gloss.2") + #expect(installed.previousVersion == nil) + #expect(installed.currentExecutableURL != nil) + let executable = try #require(installed.currentExecutableURL) + #expect(FileManager.default.isExecutableFile(atPath: executable.path)) + #expect(try Data(contentsOf: executable) == payload) + #expect(await manager.currentVersion == "0.6.4+gloss.2") + #expect(await manager.currentExecutableURL == executable) + #expect(await manager.availableVersion == "0.6.4+gloss.2") + #expect(!(await manager.updateAvailable)) + + let restoredManager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport(payloads: [:]), + manifestSigningPublicKey: signingPublicKey + ) + let restored = await restoredManager.snapshot() + #expect(restored.currentVersion == installed.currentVersion) + #expect(restored.currentExecutableURL == installed.currentExecutableURL) + + let stateURL = root.appendingPathComponent("state.json") + let permissions = try #require( + FileManager.default.attributesOfItem(atPath: stateURL.path)[.posixPermissions] + as? NSNumber + ) + #expect(permissions.intValue & 0o777 == 0o600) + } + + @Test("persisted state cannot escape the managed versions directory") + func rejectsEscapingPersistedState() throws { + let root = try temporaryDirectory() + _ = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport(payloads: [:]), + manifestSigningPublicKey: signingPublicKey + ) + let stateURL = root.appendingPathComponent("state.json") + let state = """ + { + "schemaVersion": 1, + "channel": "stable", + "current": { + "version": "1.0.0", + "directoryName": "../../../../../tmp/evil", + "executablePath": "gloss-babeldoc", + "sha256": "\(String(repeating: "0", count: 64))" + } + } + """ + try Data(state.utf8).write(to: stateURL) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: stateURL.path + ) + + #expect(throws: BabelDOCRuntimeDistributionError.corruptInstallationState) { + _ = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport(payloads: [:]), + manifestSigningPublicKey: signingPublicKey + ) + } + } + + @Test("state replacement secures its temporary file before atomic rename") + func stateTemporaryFileIsPrivateBeforeRename() throws { + let root = try temporaryDirectory() + let stateURL = root.appendingPathComponent("state.json") + let oldState = Data(#"{"schemaVersion":1,"channel":"stable"}"#.utf8) + let newState = Data( + #"{"schemaVersion":1,"channel":"stable","pinnedVersion":"1.0.0"}"#.utf8 + ) + try oldState.write(to: stateURL) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: stateURL.path + ) + var temporaryURL: URL? + + try BabelDOCRuntimeManager.writeStateAtomically( + newState, + to: stateURL + ) { stagedURL in + temporaryURL = stagedURL + let permissions = try #require( + FileManager.default.attributesOfItem( + atPath: stagedURL.path + )[.posixPermissions] as? NSNumber + ) + #expect(permissions.intValue & 0o777 == 0o600) + #expect(try Data(contentsOf: stagedURL).isEmpty) + #expect(try Data(contentsOf: stateURL) == oldState) + } + + let stagedURL = try #require(temporaryURL) + #expect(!FileManager.default.fileExists(atPath: stagedURL.path)) + #expect(try Data(contentsOf: stateURL) == newState) + let committedPermissions = try #require( + FileManager.default.attributesOfItem( + atPath: stateURL.path + )[.posixPermissions] as? NSNumber + ) + #expect(committedPermissions.intValue & 0o777 == 0o600) + } + + @Test("persisted state must be an owned 0600 regular file") + func rejectsInsecureOrLinkedState() throws { + let insecureRoot = try temporaryDirectory() + _ = try BabelDOCRuntimeManager( + rootDirectory: insecureRoot, + platform: platform, + transport: transport(payloads: [:]), + manifestSigningPublicKey: signingPublicKey + ) + let insecureState = insecureRoot.appendingPathComponent("state.json") + try Data(#"{"schemaVersion":1,"channel":"stable"}"#.utf8) + .write(to: insecureState) + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: insecureState.path + ) + + #expect(throws: BabelDOCRuntimeDistributionError.corruptInstallationState) { + _ = try BabelDOCRuntimeManager( + rootDirectory: insecureRoot, + platform: platform, + transport: transport(payloads: [:]), + manifestSigningPublicKey: signingPublicKey + ) + } + + let linkedRoot = try temporaryDirectory() + _ = try BabelDOCRuntimeManager( + rootDirectory: linkedRoot, + platform: platform, + transport: transport(payloads: [:]), + manifestSigningPublicKey: signingPublicKey + ) + let linkedState = linkedRoot.appendingPathComponent("state.json") + let target = linkedRoot.appendingPathComponent("state-target.json") + try Data(#"{"schemaVersion":1,"channel":"stable"}"#.utf8) + .write(to: target) + try FileManager.default.createSymbolicLink( + at: linkedState, + withDestinationURL: target + ) + + #expect(throws: BabelDOCRuntimeDistributionError.corruptInstallationState) { + _ = try BabelDOCRuntimeManager( + rootDirectory: linkedRoot, + platform: platform, + transport: transport(payloads: [:]), + manifestSigningPublicKey: signingPublicKey + ) + } + } + + @Test("restoring state rejects a symlinked executable") + func rejectsSymlinkedInstalledExecutable() async throws { + let root = try temporaryDirectory() + let payload = Data("runtime".utf8) + let assetURL = try #require(URL(string: "https://example.com/runtime")) + let manager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport(payloads: [assetURL: payload]), + manifestSigningPublicKey: signingPublicKey + ) + let installed = try await manager.install( + manifest( + version: "1.0.0", + assets: [asset(url: assetURL, payload: payload)] + ) + ) + let executable = try #require(installed.currentExecutableURL) + try FileManager.default.removeItem(at: executable) + try FileManager.default.createSymbolicLink( + at: executable, + withDestinationURL: URL(fileURLWithPath: "/bin/sh") + ) + + #expect(throws: BabelDOCRuntimeDistributionError.corruptInstallationState) { + _ = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport(payloads: [:]), + manifestSigningPublicKey: signingPublicKey + ) + } + } + + @Test("a safe previous runtime remains rollbackable when current is damaged") + func restoresPreviousWhenCurrentIsDamaged() async throws { + let root = try temporaryDirectory() + let firstPayload = Data("first".utf8) + let secondPayload = Data("second".utf8) + let firstURL = try #require(URL(string: "https://example.com/first")) + let secondURL = try #require(URL(string: "https://example.com/second")) + let manager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport( + payloads: [ + firstURL: firstPayload, + secondURL: secondPayload, + ] + ), + manifestSigningPublicKey: signingPublicKey + ) + let first = try await manager.install( + manifest( + version: "1.0.0", + assets: [asset(url: firstURL, payload: firstPayload)] + ) + ) + let firstExecutable = try #require(first.currentExecutableURL) + let second = try await manager.install( + manifest( + version: "1.1.0", + assets: [asset(url: secondURL, payload: secondPayload)] + ) + ) + let damagedExecutable = try #require(second.currentExecutableURL) + try FileManager.default.removeItem(at: damagedExecutable) + + let restoredManager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport(payloads: [:]), + manifestSigningPublicKey: signingPublicKey + ) + let degraded = await restoredManager.snapshot() + #expect(degraded.currentVersion == nil) + #expect(degraded.currentExecutableURL == nil) + #expect(degraded.previousVersion == "1.0.0") + + let rolledBack = try await restoredManager.rollback() + #expect(rolledBack.currentVersion == "1.0.0") + #expect(rolledBack.previousVersion == nil) + #expect(rolledBack.currentExecutableURL == firstExecutable) + #expect(try Data(contentsOf: firstExecutable) == firstPayload) + + let reloadedManager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport(payloads: [:]), + manifestSigningPublicKey: signingPublicKey + ) + let reloaded = await reloadedManager.snapshot() + #expect(reloaded.currentVersion == "1.0.0") + #expect(reloaded.previousVersion == nil) + } + + @Test("recovery rejects an unsafe previous runtime") + func recoveryRejectsUnsafePrevious() async throws { + let root = try temporaryDirectory() + let firstPayload = Data("first".utf8) + let secondPayload = Data("second".utf8) + let firstURL = try #require(URL(string: "https://example.com/first")) + let secondURL = try #require(URL(string: "https://example.com/second")) + let manager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport( + payloads: [ + firstURL: firstPayload, + secondURL: secondPayload, + ] + ), + manifestSigningPublicKey: signingPublicKey + ) + let first = try await manager.install( + manifest( + version: "1.0.0", + assets: [asset(url: firstURL, payload: firstPayload)] + ) + ) + let previousExecutable = try #require(first.currentExecutableURL) + let second = try await manager.install( + manifest( + version: "1.1.0", + assets: [asset(url: secondURL, payload: secondPayload)] + ) + ) + let currentExecutable = try #require(second.currentExecutableURL) + try FileManager.default.removeItem(at: currentExecutable) + try FileManager.default.removeItem(at: previousExecutable) + try FileManager.default.createSymbolicLink( + at: previousExecutable, + withDestinationURL: URL(fileURLWithPath: "/bin/sh") + ) + + #expect(throws: BabelDOCRuntimeDistributionError.corruptInstallationState) { + _ = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport(payloads: [:]), + manifestSigningPublicKey: signingPublicKey + ) + } + } + + @Test("checksum failure preserves the active runtime") + func checksumFailurePreservesCurrentRuntime() async throws { + let root = try temporaryDirectory() + let firstPayload = Data("first".utf8) + let secondPayload = Data("second".utf8) + let firstURL = try #require(URL(string: "https://example.com/first")) + let secondURL = try #require(URL(string: "https://example.com/second")) + let manager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport( + payloads: [ + firstURL: firstPayload, + secondURL: secondPayload, + ] + ), + manifestSigningPublicKey: signingPublicKey + ) + + _ = try await manager.install( + manifest( + version: "1.0.0", + assets: [asset(url: firstURL, payload: firstPayload)] + ) + ) + let invalidAsset = BabelDOCRuntimeManifest.Asset( + operatingSystem: platform.operatingSystem, + architecture: platform.architecture, + url: secondURL, + sha256: String(repeating: "0", count: 64), + archiveFormat: .raw + ) + + await #expect(throws: BabelDOCRuntimeDistributionError.self) { + try await manager.install( + manifest(version: "1.1.0", assets: [invalidAsset]) + ) + } + + let snapshot = await manager.snapshot() + #expect(snapshot.currentVersion == "1.0.0") + let executable = try #require(snapshot.currentExecutableURL) + #expect(try Data(contentsOf: executable) == firstPayload) + } + + @Test("install keeps one rollback version and rollback swaps them") + func updateAndRollback() async throws { + let root = try temporaryDirectory() + let firstPayload = Data("first".utf8) + let secondPayload = Data("second".utf8) + let firstURL = try #require(URL(string: "https://example.com/first")) + let secondURL = try #require(URL(string: "https://example.com/second")) + let manager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport( + payloads: [ + firstURL: firstPayload, + secondURL: secondPayload, + ] + ), + manifestSigningPublicKey: signingPublicKey + ) + + _ = try await manager.install( + manifest( + version: "1.0.0", + assets: [asset(url: firstURL, payload: firstPayload)] + ) + ) + let second = try await manager.install( + manifest( + version: "1.1.0", + assets: [asset(url: secondURL, payload: secondPayload)] + ) + ) + #expect(second.currentVersion == "1.1.0") + #expect(second.previousVersion == "1.0.0") + + let rolledBack = try await manager.rollback() + #expect(rolledBack.currentVersion == "1.0.0") + #expect(rolledBack.previousVersion == "1.1.0") + let executable = try #require(rolledBack.currentExecutableURL) + #expect(try Data(contentsOf: executable) == firstPayload) + } + + @Test("update check uses an injected URL and transport") + func updateCheckAndInstallUseInjectedURL() async throws { + let root = try temporaryDirectory() + let payload = Data("runtime".utf8) + let manifestURL = try #require(URL(string: "https://updates.example.test/custom.json")) + let assetURL = try #require(URL(string: "https://updates.example.test/runtime")) + let runtimeManifest = manifest( + version: "2.0.0", + assets: [asset(url: assetURL, payload: payload)] + ) + let manifestData = try JSONEncoder().encode(runtimeManifest) + let recorder = URLRecorder() + let manager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport( + payloads: [assetURL: payload], + manifestData: manifestData, + recorder: recorder + ), + manifestSigningPublicKey: signingPublicKey + ) + + let checked = try await manager.checkForUpdates(manifestURL: manifestURL) + #expect(checked.availableVersion == "2.0.0") + #expect(checked.updateAvailable) + #expect( + Set(await recorder.fetchedURLs) + == Set([manifestURL, signatureURL(for: manifestURL)]) + ) + + let installed = try await manager.update(manifestURL: manifestURL) + #expect(installed.currentVersion == "2.0.0") + #expect(!installed.updateAvailable) + #expect(await recorder.downloadedURLs == [assetURL]) + } + + @Test("pin and channel reject manifests outside policy") + func pinAndChannelPolicy() async throws { + let root = try temporaryDirectory() + let payload = Data("runtime".utf8) + let assetURL = try #require(URL(string: "https://example.com/runtime")) + let stable = manifest( + version: "2.0.0", + assets: [asset(url: assetURL, payload: payload)] + ) + let stableData = try JSONEncoder().encode(stable) + let manager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport(payloads: [:], manifestData: stableData), + manifestSigningPublicKey: signingPublicKey + ) + + _ = try await manager.pin(version: "1.9.0") + await #expect(throws: BabelDOCRuntimeDistributionError.self) { + try await manager.checkForUpdates() + } + + await #expect( + throws: BabelDOCRuntimeDistributionError.channelUnavailable(.beta) + ) { + try await manager.setChannel(.beta) + } + let channelSnapshot = await manager.snapshot() + #expect(channelSnapshot.channel == .stable) + #expect(channelSnapshot.pinnedVersion == "1.9.0") + } + + @Test("update check fails closed when the manifest is modified") + func rejectsModifiedManifest() async throws { + let root = try temporaryDirectory() + let assetURL = try #require(URL(string: "https://example.com/runtime")) + let original = try JSONEncoder().encode( + manifest( + version: "1.0.0", + assets: [asset(url: assetURL, payload: Data("runtime".utf8))] + ) + ) + let modified = Data( + String(decoding: original, as: UTF8.self) + .replacingOccurrences(of: "1.0.0", with: "9.0.0") + .utf8 + ) + let manager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport( + payloads: [:], + manifestData: modified, + signatureData: signature(for: original) + ), + manifestSigningPublicKey: signingPublicKey + ) + + await #expect( + throws: BabelDOCRuntimeDistributionError.manifestSignatureInvalid + ) { + try await manager.checkForUpdates() + } + #expect(await manager.snapshot().availableVersion == nil) + } + + @Test("update check fails closed when the detached signature is modified") + func rejectsModifiedSignature() async throws { + let root = try temporaryDirectory() + let assetURL = try #require(URL(string: "https://example.com/runtime")) + let data = try JSONEncoder().encode( + manifest( + version: "1.0.0", + assets: [asset(url: assetURL, payload: Data("runtime".utf8))] + ) + ) + var invalidSignature = signature(for: data) + invalidSignature[invalidSignature.startIndex] ^= 0xff + let manager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport( + payloads: [:], + manifestData: data, + signatureData: invalidSignature + ), + manifestSigningPublicKey: signingPublicKey + ) + + await #expect( + throws: BabelDOCRuntimeDistributionError.manifestSignatureInvalid + ) { + try await manager.checkForUpdates() + } + } + + @Test("update check accepts a Base64 detached signature") + func acceptsBase64Signature() async throws { + let root = try temporaryDirectory() + let assetURL = try #require(URL(string: "https://example.com/runtime")) + let data = try JSONEncoder().encode( + manifest( + version: "1.0.0", + assets: [asset(url: assetURL, payload: Data("runtime".utf8))] + ) + ) + let manager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport( + payloads: [:], + manifestData: data, + signatureData: signature(for: data).base64EncodedData() + ), + manifestSigningPublicKey: signingPublicKey + ) + + let checked = try await manager.checkForUpdates() + #expect(checked.availableVersion == "1.0.0") + #expect(checked.updateAvailable) + } + + @Test("offline update check preserves an installed runtime") + func offlineCheckPreservesInstalledRuntime() async throws { + let root = try temporaryDirectory() + let payload = Data("runtime".utf8) + let assetURL = try #require(URL(string: "https://example.com/runtime")) + let installer = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport(payloads: [assetURL: payload]), + manifestSigningPublicKey: signingPublicKey + ) + _ = try await installer.install( + manifest( + version: "1.0.0", + assets: [asset(url: assetURL, payload: payload)] + ) + ) + let offlineTransport = BabelDOCRuntimeTransport( + fetchData: { _ in throw TestError.offline }, + download: { _, _ in throw TestError.offline } + ) + let offlineManager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: offlineTransport, + manifestSigningPublicKey: signingPublicKey + ) + + await #expect(throws: TestError.offline) { + try await offlineManager.checkForUpdates() + } + let snapshot = await offlineManager.snapshot() + #expect(snapshot.currentVersion == "1.0.0") + #expect(snapshot.currentExecutableURL != nil) + #expect(snapshot.operation == .failed) + } + + @Test("minimum Gloss version and release notes URL fail closed") + func validatesGlossCompatibilityAndReleaseNotesURL() async throws { + let root = try temporaryDirectory() + let payload = Data("runtime".utf8) + let assetURL = try #require(URL(string: "https://example.com/runtime")) + let runtimeAsset = asset(url: assetURL, payload: payload) + let incompatible = BabelDOCRuntimeManifest( + channel: .stable, + version: "1.0.0", + releaseTag: "v1.0.0", + publishedAt: "2026-07-22T00:00:00Z", + minimumGlossVersion: "0.8.0", + assets: [runtimeAsset] + ) + let oldManager = try BabelDOCRuntimeManager( + rootDirectory: root.appendingPathComponent("old"), + platform: platform, + transport: transport(payloads: [assetURL: payload]), + manifestSigningPublicKey: signingPublicKey, + currentGlossVersion: "0.7.9" + ) + + await #expect( + throws: BabelDOCRuntimeDistributionError.minimumGlossVersionNotMet( + current: "0.7.9", + minimum: "0.8.0" + ) + ) { + try await oldManager.install(incompatible) + } + + let currentManager = try BabelDOCRuntimeManager( + rootDirectory: root.appendingPathComponent("current"), + platform: platform, + transport: transport(payloads: [assetURL: payload]), + manifestSigningPublicKey: signingPublicKey, + currentGlossVersion: "0.8.0" + ) + let installed = try await currentManager.install(incompatible) + #expect(installed.currentVersion == "1.0.0") + + let insecureNotes = BabelDOCRuntimeManifest( + channel: .stable, + version: "1.0.1", + releaseTag: "v1.0.1", + publishedAt: "2026-07-22T00:00:00Z", + releaseNotesURL: URL(string: "http://example.com/notes"), + assets: [runtimeAsset] + ) + await #expect(throws: BabelDOCRuntimeDistributionError.self) { + try await currentManager.install(insecureNotes) + } + } + + @Test("declared asset size is verified before activation") + func verifiesAssetSize() async throws { + let root = try temporaryDirectory() + let payload = Data("runtime".utf8) + let assetURL = try #require(URL(string: "https://example.com/runtime")) + let wrongSize = BabelDOCRuntimeManifest.Asset( + operatingSystem: platform.operatingSystem, + architecture: platform.architecture, + url: assetURL, + sha256: sha256(payload), + size: Int64(payload.count + 1), + archiveFormat: .raw + ) + let manager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport(payloads: [assetURL: payload]), + manifestSigningPublicKey: signingPublicKey + ) + + await #expect( + throws: BabelDOCRuntimeDistributionError.payloadSizeMismatch( + expected: Int64(payload.count + 1), + actual: Int64(payload.count) + ) + ) { + try await manager.install( + manifest(version: "1.0.0", assets: [wrongSize]) + ) + } + #expect(await manager.snapshot().currentVersion == nil) + } + + @Test("snapshot stream reports operation changes") + func snapshotStreamReportsOperations() async throws { + let root = try temporaryDirectory() + let payload = Data("runtime".utf8) + let assetURL = try #require(URL(string: "https://example.com/runtime")) + let manager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport(payloads: [assetURL: payload]), + manifestSigningPublicKey: signingPublicKey + ) + let stream = await manager.snapshots() + let collector = SnapshotCollector() + let task = Task { + for await snapshot in stream { + await collector.append(snapshot) + if snapshot.currentVersion == "1.0.0" { + break + } + } + } + + _ = try await manager.install( + manifest( + version: "1.0.0", + assets: [asset(url: assetURL, payload: payload)] + ) + ) + await task.value + + let operations = await collector.snapshots.map(\.operation) + #expect(operations.first == .idle) + #expect(operations.contains(.downloading)) + #expect(operations.contains(.verifying)) + #expect(operations.contains(.extracting)) + #expect(operations.contains(.installing)) + #expect(operations.last == .ready) + } + + @Test("archive entry validation blocks traversal and absolute paths") + func archivePathValidation() { + #expect(throws: BabelDOCRuntimeDistributionError.self) { + try BabelDOCRuntimeManager.validateArchiveEntries( + "gloss-babeldoc\n../outside\n" + ) + } + #expect(throws: BabelDOCRuntimeDistributionError.self) { + try BabelDOCRuntimeManager.validateArchiveEntries( + "/tmp/gloss-babeldoc\n" + ) + } + #expect(throws: Never.self) { + try BabelDOCRuntimeManager.validateArchiveEntries( + "runtime/\nruntime/bin/\nruntime/bin/gloss-babeldoc\n" + ) + } + } + + @Test("tar archives containing symlinks are rejected before install") + func rejectsTarSymlink() async throws { + guard FileManager.default.isExecutableFile(atPath: "/usr/bin/tar") else { + return + } + let root = try temporaryDirectory() + let source = root.appendingPathComponent("archive-source", isDirectory: true) + try FileManager.default.createDirectory( + at: source, + withIntermediateDirectories: true + ) + let executable = source.appendingPathComponent("gloss-babeldoc") + try Data("runtime".utf8).write(to: executable) + try FileManager.default.createSymbolicLink( + at: source.appendingPathComponent("escape"), + withDestinationURL: URL(fileURLWithPath: "/tmp") + ) + let archive = root.appendingPathComponent("runtime.tar.gz") + try run( + "/usr/bin/tar", + ["-czf", archive.path, "-C", source.path, "."] + ) + + let assetURL = try #require(URL(string: "https://example.com/runtime.tar.gz")) + let payload = try Data(contentsOf: archive) + let tarAsset = BabelDOCRuntimeManifest.Asset( + operatingSystem: platform.operatingSystem, + architecture: platform.architecture, + url: assetURL, + sha256: sha256(payload), + archiveFormat: .tarGzip + ) + let manager = try BabelDOCRuntimeManager( + rootDirectory: root.appendingPathComponent("install", isDirectory: true), + platform: platform, + transport: transport(payloads: [assetURL: payload]), + manifestSigningPublicKey: signingPublicKey + ) + + await #expect(throws: BabelDOCRuntimeDistributionError.self) { + try await manager.install( + manifest(version: "1.0.0", assets: [tarAsset]) + ) + } + #expect(await manager.snapshot().currentVersion == nil) + } + + @Test( + "large archive listings are drained without blocking", + .timeLimit(.minutes(1)) + ) + func installsTarWithLargeListing() async throws { + guard FileManager.default.isExecutableFile(atPath: "/usr/bin/tar") else { + return + } + let root = try temporaryDirectory() + let source = root.appendingPathComponent("large-archive-source", isDirectory: true) + try FileManager.default.createDirectory( + at: source, + withIntermediateDirectories: true + ) + try Data("runtime".utf8).write( + to: source.appendingPathComponent("gloss-babeldoc") + ) + let suffix = String(repeating: "x", count: 72) + for index in 0..<2_600 { + let name = String(format: "payload-%04d-%@", index, suffix) + try Data().write(to: source.appendingPathComponent(name)) + } + + let archive = root.appendingPathComponent("large-runtime.tar.gz") + try run( + "/usr/bin/tar", + ["-czf", archive.path, "-C", source.path, "."] + ) + let payload = try Data(contentsOf: archive) + let assetURL = try #require( + URL(string: "https://example.com/large-runtime.tar.gz") + ) + let tarAsset = BabelDOCRuntimeManifest.Asset( + operatingSystem: platform.operatingSystem, + architecture: platform.architecture, + url: assetURL, + sha256: sha256(payload), + archiveFormat: .tarGzip + ) + let manager = try BabelDOCRuntimeManager( + rootDirectory: root.appendingPathComponent("install", isDirectory: true), + platform: platform, + transport: transport(payloads: [assetURL: payload]), + manifestSigningPublicKey: signingPublicKey + ) + + let installed = try await manager.install( + manifest(version: "1.0.0", assets: [tarAsset]) + ) + + #expect(installed.currentVersion == "1.0.0") + #expect(installed.currentExecutableURL != nil) + } + + @Test("zip symlink is rejected before a child path can escape") + func rejectsZipSymlinkBeforeExtraction() async throws { + guard + FileManager.default.isExecutableFile(atPath: "/usr/bin/python3"), + FileManager.default.isExecutableFile(atPath: "/usr/bin/zipinfo") + else { + return + } + let root = try temporaryDirectory() + let archive = root.appendingPathComponent("runtime.zip") + let escaped = root.appendingPathComponent("escaped", isDirectory: true) + try FileManager.default.createDirectory( + at: escaped, + withIntermediateDirectories: true + ) + let escapedChild = escaped.appendingPathComponent("child") + let python = """ + import stat + import sys + import zipfile + + with zipfile.ZipFile(sys.argv[1], "w") as archive: + link = zipfile.ZipInfo("link") + link.create_system = 3 + link.external_attr = (stat.S_IFLNK | 0o777) << 16 + archive.writestr(link, sys.argv[2]) + archive.writestr("link/child", "escaped") + archive.writestr("gloss-babeldoc", "runtime") + """ + try run( + "/usr/bin/python3", + ["-c", python, archive.path, escaped.path] + ) + + let assetURL = try #require(URL(string: "https://example.com/runtime.zip")) + let payload = try Data(contentsOf: archive) + let zipAsset = BabelDOCRuntimeManifest.Asset( + operatingSystem: platform.operatingSystem, + architecture: platform.architecture, + url: assetURL, + sha256: sha256(payload), + archiveFormat: .zip + ) + let manager = try BabelDOCRuntimeManager( + rootDirectory: root.appendingPathComponent("install", isDirectory: true), + platform: platform, + transport: transport(payloads: [assetURL: payload]), + manifestSigningPublicKey: signingPublicKey + ) + + await #expect(throws: BabelDOCRuntimeDistributionError.self) { + try await manager.install( + manifest(version: "1.0.0", assets: [zipAsset]) + ) + } + #expect(!FileManager.default.fileExists(atPath: escapedChild.path)) + } + + private func manifest( + version: String, + channel: BabelDOCRuntimeChannel = .stable, + assets: [BabelDOCRuntimeManifest.Asset] + ) -> BabelDOCRuntimeManifest { + BabelDOCRuntimeManifest( + channel: channel, + version: version, + releaseTag: "v\(version.replacingOccurrences(of: "+", with: "-"))", + publishedAt: "2026-07-22T00:00:00Z", + assets: assets + ) + } + + private func asset( + url: URL, + payload: Data + ) -> BabelDOCRuntimeManifest.Asset { + BabelDOCRuntimeManifest.Asset( + operatingSystem: platform.operatingSystem, + architecture: platform.architecture, + url: url, + sha256: sha256(payload), + archiveFormat: .raw + ) + } + + private func transport( + payloads: [URL: Data], + manifestData: Data = Data(), + signatureData: Data? = nil, + recorder: URLRecorder? = nil + ) -> BabelDOCRuntimeTransport { + let detachedSignature = signatureData ?? signature(for: manifestData) + return BabelDOCRuntimeTransport( + fetchData: { url in + await recorder?.recordFetch(url) + if url.absoluteString.hasSuffix(".sig") { + return detachedSignature + } + return manifestData + }, + download: { url, destination in + await recorder?.recordDownload(url) + guard let payload = payloads[url] else { + throw TestError.missingPayload(url) + } + try payload.write(to: destination, options: .atomic) + } + ) + } + + private func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent( + "GlossRuntimeDistributionTests-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: true + ) + return url + } + + private func sha256(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private var signingPrivateKey: Curve25519.Signing.PrivateKey { + try! Curve25519.Signing.PrivateKey( + rawRepresentation: Data(repeating: 0x2a, count: 32) + ) + } + + private var signingPublicKey: Data { + signingPrivateKey.publicKey.rawRepresentation + } + + private func signature(for data: Data) -> Data { + try! signingPrivateKey.signature(for: data) + } + + private func signatureURL(for manifestURL: URL) -> URL { + URL(string: "\(manifestURL.absoluteString).sig")! + } + + private func run(_ executable: String, _ arguments: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + try process.run() + process.waitUntilExit() + #expect(process.terminationStatus == 0) + } +} + +private enum TestError: Error, Equatable { + case missingPayload(URL) + case offline +} + +private actor URLRecorder { + private(set) var fetchedURLs: [URL] = [] + private(set) var downloadedURLs: [URL] = [] + + func recordFetch(_ url: URL) { + fetchedURLs.append(url) + } + + func recordDownload(_ url: URL) { + downloadedURLs.append(url) + } +} + +private actor SnapshotCollector { + private(set) var snapshots: [BabelDOCRuntimeSnapshot] = [] + + func append(_ snapshot: BabelDOCRuntimeSnapshot) { + snapshots.append(snapshot) + } +} From 3379db3b8315142d9e7654f458cb1e11984b64fa Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Thu, 23 Jul 2026 01:23:38 -0700 Subject: [PATCH 3/8] feat: integrate PDF runtime lifecycle and dashboard --- Sources/Gloss/GlossAppDelegate.swift | 70 +- Sources/Gloss/PDFRuntimeController.swift | 731 ++++++++++++++++++ Sources/Gloss/PDFRuntimeDashboardState.swift | 270 +++++++ .../PDFTranslationWindowController.swift | 392 ++++++++-- Sources/Gloss/SettingsWindowController.swift | 188 ++++- .../PDFRuntimeDashboardStateTests.swift | 204 +++++ .../PDFRuntimeLifecycleTests.swift | 171 ++++ 7 files changed, 1945 insertions(+), 81 deletions(-) create mode 100644 Sources/Gloss/PDFRuntimeController.swift create mode 100644 Sources/Gloss/PDFRuntimeDashboardState.swift create mode 100644 Tests/GlossAppTests/PDFRuntimeDashboardStateTests.swift create mode 100644 Tests/GlossAppTests/PDFRuntimeLifecycleTests.swift diff --git a/Sources/Gloss/GlossAppDelegate.swift b/Sources/Gloss/GlossAppDelegate.swift index a103b4d..7093b80 100644 --- a/Sources/Gloss/GlossAppDelegate.swift +++ b/Sources/Gloss/GlossAppDelegate.swift @@ -50,6 +50,10 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var glossaryWindowController: GlossaryWindowController? private var appExclusionsWindowController: AppExclusionsWindowController? private var pdfTranslationWindowController: PDFTranslationWindowController? + private var pdfRuntimeObservationTask: Task? + private var pdfRuntimeActionTask: Task? + private var pdfRuntimeActionID: UUID? + private lazy var pdfRuntimeController = PDFRuntimeController() private var selectionMonitor: SelectionMonitor? private var currentSelection: SelectionSnapshot? private var activeTranslationID: UUID? @@ -148,6 +152,9 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { controller.onBridgeAction = { [weak self] in self?.performBridgeDashboardAction() } + controller.onPDFRuntimeAction = { [weak self] action in + self?.performPDFRuntimeAction(action) + } controller.onRevealLogs = { [weak self] in self?.revealLogs() } @@ -161,6 +168,7 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { self?.setGlobalShortcut(shortcut) } controller.showBridgeState(bridgeDashboardState) + controller.showPDFRuntimeState(pdfRuntimeController.dashboardState) controller.showBrowserExtensionStatus( browserExtensionStatus.message, succeeded: browserExtensionStatus.succeeded @@ -214,7 +222,8 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { guard self?.bridgeDashboardState.isReady == true else { return nil } return self?.pairingToken }, - translationDispatchState: dispatchState + translationDispatchState: dispatchState, + runtimeController: pdfRuntimeController ) pdfTranslationWindowController = controller return controller @@ -331,9 +340,8 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { func applicationDidFinishLaunching(_ notification: Notification) { try? runtimeLog.prepare() runtimeLog.write("app", "started version=\(applicationVersion)") - Task.detached(priority: .utility) { - BabelDOCServiceSession.cleanupStaleWorkingDirectories() - } + startObservingPDFRuntime() + pdfRuntimeController.prepareAtLaunch() NSApp.setActivationPolicy(.accessory) DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { [weak self] in self?.configureStatusItem() @@ -432,7 +440,8 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { bridgeListenerAttempt = nil bridgeRecoveryTask?.cancel() activeOCRTask?.cancel() - pdfTranslationWindowController?.stop() + pdfRuntimeObservationTask?.cancel() + pdfRuntimeActionTask?.cancel() stopAccessibilityPolling() selectionMonitor?.stop() NSWorkspace.shared.notificationCenter.removeObserver(self) @@ -471,9 +480,16 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { bridgeGeneration = UUID() bridgeListenerAttempt = nil bridgeRecoveryTask?.cancel() + let pendingPDFRuntimeAction = pdfRuntimeActionTask + pendingPDFRuntimeAction?.cancel() + pdfRuntimeActionTask = nil + pdfRuntimeActionID = nil let pdfWindow = pdfTranslationWindowController - Task { [codex, llama, pdfWindow] in + let pdfRuntime = pdfRuntimeController + Task { [codex, llama, pendingPDFRuntimeAction, pdfWindow, pdfRuntime] in + await pendingPDFRuntimeAction?.value await pdfWindow?.stopAndWait() + await pdfRuntime.shutdown() await codex.stop() await llama.stop() NSApp.reply(toApplicationShouldTerminate: true) @@ -1825,6 +1841,48 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { return try await bridgePortManager.inspect(token: token) } + private func startObservingPDFRuntime() { + guard pdfRuntimeObservationTask == nil else { return } + let controller = pdfRuntimeController + pdfRuntimeObservationTask = Task { @MainActor [weak self, controller] in + for await state in controller.stateChanges() { + guard let self, !Task.isCancelled else { return } + settingsWindowController?.showPDFRuntimeState(state) + } + } + } + + private func performPDFRuntimeAction(_ action: PDFRuntimeDashboardAction) { + let previousTask = pdfRuntimeActionTask + previousTask?.cancel() + let actionID = UUID() + pdfRuntimeActionID = actionID + pdfRuntimeActionTask = Task { [weak self] in + await previousTask?.value + guard let self, !Task.isCancelled else { return } + if action == .cancel, + let pdfTranslationWindowController + { + await pdfTranslationWindowController.prepareForRuntimeMaintenance( + releaseWhenComplete: true + ) + guard !Task.isCancelled else { return } + if pdfRuntimeActionID == actionID { + pdfRuntimeActionTask = nil + pdfRuntimeActionID = nil + } + return + } + await pdfTranslationWindowController?.prepareForRuntimeMaintenance() + guard !Task.isCancelled else { return } + pdfRuntimeController.perform(action) + if pdfRuntimeActionID == actionID { + pdfRuntimeActionTask = nil + pdfRuntimeActionID = nil + } + } + } + private func performBridgeDashboardAction() { guard let occupant = bridgeDashboardState.occupant, !occupant.canAutomaticallyTerminate diff --git a/Sources/Gloss/PDFRuntimeController.swift b/Sources/Gloss/PDFRuntimeController.swift new file mode 100644 index 0000000..5c58e93 --- /dev/null +++ b/Sources/Gloss/PDFRuntimeController.swift @@ -0,0 +1,731 @@ +import Foundation +import GlossCore + +@MainActor +final class PDFRuntimeController { + struct PreparedRuntime { + let launch: BabelDOCRuntimeLaunch + let layoutServiceBaseURL: URL + let layoutCacheDirectoryURL: URL? + } + + private enum ControllerError: LocalizedError { + case runtimeManagerUnavailable + case runtimeUnavailable + + var errorDescription: String? { + switch self { + case .runtimeManagerUnavailable: + "无法初始化 BabelDOC 运行时管理器。" + case .runtimeUnavailable: + "没有可用的 BabelDOC 运行时。" + } + } + } + + let service: BabelDOCServiceSession + + private let runtimeManager: BabelDOCRuntimeManager? + private var stateContinuations: [UUID: AsyncStream.Continuation] = [:] + private var runtimeSnapshot: BabelDOCRuntimeSnapshot? + private var serviceSnapshot = BabelDOCExecutorServiceSnapshot( + installed: false, + lifecycleState: .stopped + ) + private var runtimeObservationTask: Task? + private var serviceObservationTask: Task? + private var launchPreparationTask: Task? + private var launchPreparationID: UUID? + private var launchPreparationCompleted = false + private var launchPreparationError: Error? + private var backgroundUpdateTask: Task? + private var backgroundUpdateID: UUID? + private var modulePreparationTask: Task? + private var modulePreparationID: UUID? + private var moduleCloseTask: Task? + private var moduleCloseID: UUID? + private var actionTask: Task? + private var actionID: UUID? + private var activeDocumentName: String? + + private(set) var dashboardState: PDFRuntimeDashboardState = .checking { + didSet { + guard dashboardState != oldValue else { return } + for continuation in stateContinuations.values { + continuation.yield(dashboardState) + } + } + } + + init( + service: BabelDOCServiceSession = .shared, + runtimeManager: BabelDOCRuntimeManager? = try? BabelDOCRuntimeManager() + ) { + self.service = service + self.runtimeManager = runtimeManager + startObserving() + if runtimeManager == nil { + dashboardState = .failed( + message: ControllerError.runtimeManagerUnavailable.localizedDescription, + installedVersion: nil, + canRollback: false + ) + } + } + + deinit { + runtimeObservationTask?.cancel() + serviceObservationTask?.cancel() + backgroundUpdateTask?.cancel() + modulePreparationTask?.cancel() + moduleCloseTask?.cancel() + actionTask?.cancel() + } + + func stateChanges() -> AsyncStream { + let observationID = UUID() + return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + stateContinuations[observationID] = continuation + continuation.yield(dashboardState) + continuation.onTermination = { [weak self] _ in + Task { @MainActor [weak self] in + self?.stateContinuations.removeValue(forKey: observationID) + } + } + } + } + + var currentRuntimeLaunch: BabelDOCRuntimeLaunch? { + if let executable = runtimeSnapshot?.currentExecutableURL { + return Self.managedLaunch( + executable: executable, + version: runtimeSnapshot?.currentVersion + ) + } + return BabelDOCExternalEngine.resolveRuntime() + } + + func prepareAtLaunch() { + guard !launchPreparationCompleted, + launchPreparationTask == nil + else { return } + let preparationID = UUID() + launchPreparationID = preparationID + launchPreparationTask = Task { [weak self] in + guard let self else { return } + + do { + try await service.cleanupPersistedService() + } catch { + guard launchPreparationID == preparationID else { return } + launchPreparationError = error + launchPreparationCompleted = true + launchPreparationTask = nil + launchPreparationID = nil + dashboardState = .failed( + message: error.localizedDescription, + installedVersion: runtimeSnapshot?.currentVersion, + canRollback: false + ) + return + } + await Task.detached(priority: .utility) { + BabelDOCServiceSession.cleanupStaleWorkingDirectories() + }.value + + guard let runtimeManager else { + guard launchPreparationID == preparationID else { return } + launchPreparationCompleted = true + launchPreparationTask = nil + launchPreparationID = nil + refreshDashboard() + return + } + let snapshot = await runtimeManager.snapshot() + guard launchPreparationID == preparationID else { return } + runtimeSnapshot = snapshot + launchPreparationCompleted = true + launchPreparationTask = nil + launchPreparationID = nil + refreshDashboard() + startBackgroundUpdateCheckIfNeeded() + } + } + + func prepareModule() async throws -> PreparedRuntime { + if let modulePreparationTask { + return try await modulePreparationTask.value + } + + let pendingActionTask = actionTask + let pendingCloseTask = moduleCloseTask + let pendingCloseID = moduleCloseID + let task = Task { [weak self] in + guard let self else { + throw CancellationError() + } + await pendingActionTask?.value + try Task.checkCancellation() + try await waitForModuleClose(pendingCloseTask, id: pendingCloseID) + return try await installUpdateAndStart() + } + let preparationID = UUID() + modulePreparationTask = task + modulePreparationID = preparationID + do { + let prepared = try await task.value + if modulePreparationID == preparationID { + modulePreparationTask = nil + modulePreparationID = nil + } + return prepared + } catch is CancellationError { + if modulePreparationID == preparationID { + modulePreparationTask = nil + modulePreparationID = nil + } + throw CancellationError() + } catch { + if modulePreparationID == preparationID { + modulePreparationTask = nil + modulePreparationID = nil + } + recordFailure(error) + throw error + } + } + + func closeModule() { + let preparationTask = takeAndCancelModulePreparation() + activeDocumentName = nil + let previousCloseTask = moduleCloseTask + let pendingAction = actionTask + let closeID = UUID() + let closeTask = Task { [service] in + await previousCloseTask?.value + await pendingAction?.value + _ = try? await preparationTask?.value + _ = await service.stop() + } + moduleCloseID = closeID + moduleCloseTask = closeTask + } + + func closeModuleAndWait() async { + let preparationTask = takeAndCancelModulePreparation() + activeDocumentName = nil + let previousCloseTask = moduleCloseTask + let pendingAction = actionTask + let closeID = UUID() + let closeTask = Task { [service] in + await previousCloseTask?.value + await pendingAction?.value + _ = try? await preparationTask?.value + _ = await service.stop() + } + moduleCloseID = closeID + moduleCloseTask = closeTask + await closeTask.value + if moduleCloseID == closeID { + moduleCloseTask = nil + moduleCloseID = nil + } + } + + func cancelModulePreparationAndWait() async { + let preparationTask = takeAndCancelModulePreparation() + _ = try? await preparationTask?.value + } + + func shutdown() async { + prepareAtLaunch() + let mandatoryLaunchPreparation = launchPreparationTask + await mandatoryLaunchPreparation?.value + await cancelBackgroundUpdateCheck() + let pendingAction = actionTask + pendingAction?.cancel() + actionTask = nil + actionID = nil + await pendingAction?.value + await closeModuleAndWait() + } + + func translationDidStart(fileName: String) { + activeDocumentName = fileName + refreshDashboard() + } + + func translationDidFinish(fileName: String) { + if activeDocumentName == fileName { + activeDocumentName = nil + } + refreshDashboard() + } + + func perform(_ action: PDFRuntimeDashboardAction) { + if action == .cancel { + Task { [service] in + try? await service.cancelCurrent() + } + return + } + + let previousAction = actionTask + previousAction?.cancel() + let preparationTask = takeAndCancelModulePreparation() + if action == .retry, launchPreparationError != nil { + resetLaunchPreparation() + } + let pendingCloseTask = moduleCloseTask + let pendingCloseID = moduleCloseID + let runtimeVersionBeforeAction = runtimeSnapshot?.currentVersion + let operationID = UUID() + let task = Task { [weak self] in + guard let self else { return } + await previousAction?.value + do { + _ = try? await preparationTask?.value + try Task.checkCancellation() + try await waitForLaunchPreparation() + try await waitForModuleClose(pendingCloseTask, id: pendingCloseID) + if action != .start, action != .retry { + await cancelBackgroundUpdateCheck() + } + switch action { + case .install: + _ = try await installUpdateAndStart(forceUpdateCheck: true) + case .start, .retry: + _ = try await installUpdateAndStart() + case .update: + _ = try await updateAndRestart() + case .reconnect: + _ = try await reconnect() + case .rollback: + _ = try await rollbackAndRestart() + case .cancel: + break + } + } catch is CancellationError { + // A superseding action or shutdown owns the next state transition. + } catch { + recordFailure( + error, + canRollback: canOfferRollback( + after: action, + error: error, + versionBeforeAction: runtimeVersionBeforeAction + ) + ) + } + if actionID == operationID { + actionTask = nil + actionID = nil + } + } + actionID = operationID + actionTask = task + } + + private func startObserving() { + if let runtimeManager { + runtimeObservationTask = Task { [weak self, runtimeManager] in + for await snapshot in await runtimeManager.snapshots() { + guard let self, !Task.isCancelled else { return } + runtimeSnapshot = snapshot + refreshDashboard() + } + } + } + serviceObservationTask = Task { [weak self, service] in + for await snapshot in await service.stateChanges() { + guard let self, !Task.isCancelled else { return } + serviceSnapshot = snapshot + refreshDashboard() + } + } + } + + private func installUpdateAndStart( + forceUpdateCheck: Bool = false + ) async throws -> PreparedRuntime { + try await waitForLaunchPreparation() + try Task.checkCancellation() + var changedManagedVersion = false + if let runtimeManager { + var snapshot = await runtimeManager.snapshot() + try Task.checkCancellation() + runtimeSnapshot = snapshot + + if snapshot.currentExecutableURL == nil { + do { + snapshot = try await runtimeManager.update() + try Task.checkCancellation() + changedManagedVersion = true + } catch is CancellationError { + throw CancellationError() + } catch { + runtimeSnapshot = await runtimeManager.snapshot() + if BabelDOCExternalEngine.resolveRuntime() == nil { + throw error + } + logNonfatalUpdateFailure(error) + } + } else if forceUpdateCheck { + do { + snapshot = try await runtimeManager.checkForUpdates() + try Task.checkCancellation() + } catch is CancellationError { + throw CancellationError() + } catch { + snapshot = await runtimeManager.snapshot() + logNonfatalUpdateFailure(error) + } + } + + if forceUpdateCheck, snapshot.updateAvailable { + try await stopServiceForRuntimeReplacement() + try Task.checkCancellation() + do { + snapshot = try await runtimeManager.update() + try Task.checkCancellation() + changedManagedVersion = true + } catch is CancellationError { + throw CancellationError() + } catch { + snapshot = await runtimeManager.snapshot() + guard snapshot.currentExecutableURL != nil else { + throw error + } + logNonfatalUpdateFailure(error) + } + } + runtimeSnapshot = snapshot + } + + guard let launch = currentRuntimeLaunch else { + throw ControllerError.runtimeUnavailable + } + do { + return try await start(launch) + } catch is CancellationError { + throw CancellationError() + } catch { + guard changedManagedVersion, + let runtimeManager, + (await runtimeManager.snapshot()).previousVersion != nil + else { + throw error + } + try await stopServiceForRuntimeReplacement() + try Task.checkCancellation() + runtimeSnapshot = try await runtimeManager.rollback() + try Task.checkCancellation() + guard let rollbackLaunch = currentRuntimeLaunch else { + throw ControllerError.runtimeUnavailable + } + return try await start(rollbackLaunch) + } + } + + private func updateAndRestart() async throws -> PreparedRuntime { + guard let runtimeManager else { + throw ControllerError.runtimeManagerUnavailable + } + let versionBeforeUpdate = (await runtimeManager.snapshot()).currentVersion + try Task.checkCancellation() + var changedManagedVersion = false + try await stopServiceForRuntimeReplacement() + try Task.checkCancellation() + do { + runtimeSnapshot = try await runtimeManager.update() + try Task.checkCancellation() + changedManagedVersion = + runtimeSnapshot?.currentVersion != versionBeforeUpdate + guard let launch = currentRuntimeLaunch else { + throw ControllerError.runtimeUnavailable + } + return try await start(launch) + } catch is CancellationError { + throw CancellationError() + } catch { + let snapshot = await runtimeManager.snapshot() + if changedManagedVersion, snapshot.previousVersion != nil { + try await stopServiceForRuntimeReplacement() + runtimeSnapshot = try await runtimeManager.rollback() + try Task.checkCancellation() + } else { + runtimeSnapshot = snapshot + logNonfatalUpdateFailure(error) + } + guard let launch = currentRuntimeLaunch else { + throw error + } + return try await start(launch) + } + } + + private func rollbackAndRestart() async throws -> PreparedRuntime { + guard let runtimeManager else { + throw ControllerError.runtimeManagerUnavailable + } + try await stopServiceForRuntimeReplacement() + try Task.checkCancellation() + runtimeSnapshot = try await runtimeManager.rollback() + try Task.checkCancellation() + guard let launch = currentRuntimeLaunch else { + throw ControllerError.runtimeUnavailable + } + return try await start(launch) + } + + private func reconnect() async throws -> PreparedRuntime { + guard let launch = currentRuntimeLaunch else { + throw ControllerError.runtimeUnavailable + } + let baseURL = try await service.reconnect(runtime: launch, force: true) + let cacheURL = await service.layoutCacheDirectoryURL + return PreparedRuntime( + launch: launch, + layoutServiceBaseURL: baseURL, + layoutCacheDirectoryURL: cacheURL + ) + } + + private func stopServiceForRuntimeReplacement() async throws { + guard await service.stop() else { + throw BabelDOCServiceError.terminationFailed( + "BabelDOC 子进程仍在运行,已中止运行时切换。" + ) + } + } + + private func waitForLaunchPreparation() async throws { + if !launchPreparationCompleted { + prepareAtLaunch() + await launchPreparationTask?.value + } + if let launchPreparationError { + throw launchPreparationError + } + } + + private func resetLaunchPreparation() { + guard launchPreparationTask == nil else { return } + launchPreparationID = nil + launchPreparationCompleted = false + launchPreparationError = nil + } + + private func startBackgroundUpdateCheckIfNeeded() { + guard backgroundUpdateTask == nil, + runtimeSnapshot?.currentExecutableURL != nil, + let runtimeManager + else { return } + let updateID = UUID() + backgroundUpdateID = updateID + backgroundUpdateTask = Task { [weak self, runtimeManager] in + let checkedSnapshot: BabelDOCRuntimeSnapshot + do { + checkedSnapshot = try await runtimeManager.checkForUpdates() + } catch is CancellationError { + return + } catch { + checkedSnapshot = await runtimeManager.snapshot() + } + guard let self, + !Task.isCancelled, + backgroundUpdateID == updateID + else { return } + runtimeSnapshot = checkedSnapshot + backgroundUpdateTask = nil + backgroundUpdateID = nil + refreshDashboard() + } + } + + private func cancelBackgroundUpdateCheck() async { + let task = backgroundUpdateTask + task?.cancel() + await task?.value + backgroundUpdateTask = nil + backgroundUpdateID = nil + } + + private func takeAndCancelModulePreparation() -> Task? { + let task = modulePreparationTask + task?.cancel() + modulePreparationTask = nil + modulePreparationID = nil + return task + } + + func waitForModuleClose( + _ closeTask: Task?, + id closeID: UUID? + ) async throws { + await closeTask?.value + try Task.checkCancellation() + if moduleCloseID == closeID { + moduleCloseTask = nil + moduleCloseID = nil + } + } + + private func logNonfatalUpdateFailure(_ error: Error) { + GlossRuntimeLog.shared.write( + "pdf-runtime", + "continuing_with_current_runtime update_error=\(error.localizedDescription)" + ) + } + + private func start(_ launch: BabelDOCRuntimeLaunch) async throws -> PreparedRuntime { + let baseURL = try await service.start(runtime: launch) + let cacheURL = await service.layoutCacheDirectoryURL + return PreparedRuntime( + launch: launch, + layoutServiceBaseURL: baseURL, + layoutCacheDirectoryURL: cacheURL + ) + } + + private func recordFailure( + _ error: Error, + canRollback: Bool = false + ) { + let installedVersion = runtimeSnapshot?.currentVersion + dashboardState = .failed( + message: error.localizedDescription, + installedVersion: installedVersion, + canRollback: canRollback + ) + } + + private func canOfferRollback( + after action: PDFRuntimeDashboardAction, + error: Error, + versionBeforeAction: String? + ) -> Bool { + guard action == .install || action == .update, + let versionBeforeAction, + runtimeSnapshot?.currentVersion != versionBeforeAction, + runtimeSnapshot?.previousVersion == versionBeforeAction + else { return false } + if let serviceError = error as? BabelDOCServiceError, + case .terminationFailed = serviceError + { + return false + } + return true + } + + private func refreshDashboard() { + dashboardState = Self.dashboardState( + runtime: runtimeSnapshot, + service: serviceSnapshot, + activeDocumentName: activeDocumentName + ) + } + + nonisolated static func dashboardState( + runtime: BabelDOCRuntimeSnapshot?, + service: BabelDOCExecutorServiceSnapshot, + activeDocumentName: String?, + fallbackRuntimeAvailable: Bool = + BabelDOCExternalEngine.resolveRuntime() != nil + ) -> PDFRuntimeDashboardState { + if service.lifecycleState != .ready, let runtime { + switch runtime.operation { + case .checking: + return .checking + case .downloading, .verifying, .extracting, .installing, .rollingBack: + return .installing( + version: runtime.availableVersion ?? runtime.currentVersion, + progress: nil + ) + case .failed where runtime.currentVersion == nil: + return .failed( + message: runtime.lastError ?? "BabelDOC 运行时操作失败。", + installedVersion: nil, + canRollback: runtime.previousVersion != nil + ) + case .idle, .ready, .failed: + break + } + } + + let installedVersion = runtime?.currentVersion ?? service.runtimeVersion + let canRollback = runtime?.previousVersion != nil + switch service.lifecycleState { + case .starting: + return .starting(version: installedVersion ?? "未知版本") + case .reconnecting: + return .reconnecting(previousProcessIdentifier: service.processIdentifier) + case .failed: + return .failed( + message: service.lastError ?? runtime?.lastError ?? "PDF 服务启动失败。", + installedVersion: installedVersion, + canRollback: false + ) + case .ready: + guard let endpoint = service.endpoint, + let processIdentifier = service.processIdentifier + else { + return .failed( + message: "PDF 服务缺少连接身份。", + installedVersion: installedVersion, + canRollback: false + ) + } + let info = PDFRuntimeReadyInfo( + endpoint: endpoint.absoluteString, + processIdentifier: processIdentifier, + version: service.runtimeVersion ?? installedVersion ?? "未知版本", + executablePath: runtime?.currentExecutableURL?.path + ) + let activeStatus = service.activeStatus?.lowercased() + let isActive = + service.activeTaskID != nil + && activeStatus != "succeeded" + && activeStatus != "failed" + && activeStatus != "cancelled" + if isActive { + return .translating( + info, + fileName: activeDocumentName ?? service.activeTaskID ?? "PDF", + progress: service.activeProgress.map { + Int(max(0, min(100, $0)).rounded()) + } + ) + } + if let availableVersion = runtime?.availableVersion, + runtime?.updateAvailable == true + { + return .updateAvailable(info, availableVersion: availableVersion) + } + return .ready(info) + case .stopping: + return .stopping(previousProcessIdentifier: service.processIdentifier) + case .stopped: + if installedVersion == nil, + !fallbackRuntimeAvailable + { + return .notInstalled + } + return .stopped( + installedVersion: installedVersion, + canRollback: canRollback + ) + } + } + + private static func managedLaunch( + executable: URL, + version: String? + ) -> BabelDOCRuntimeLaunch { + BabelDOCRuntimeLaunch( + executable: executable.path, + source: version.map { "Gloss runtime \($0)" } ?? "Gloss runtime", + executorExecutable: executable.path + ) + } +} diff --git a/Sources/Gloss/PDFRuntimeDashboardState.swift b/Sources/Gloss/PDFRuntimeDashboardState.swift new file mode 100644 index 0000000..e3ead03 --- /dev/null +++ b/Sources/Gloss/PDFRuntimeDashboardState.swift @@ -0,0 +1,270 @@ +import Foundation + +struct PDFRuntimeReadyInfo: Equatable { + let endpoint: String + let processIdentifier: Int32 + let version: String + let executablePath: String? +} + +enum PDFRuntimeDashboardAction: Equatable { + case install + case start + case update + case cancel + case reconnect + case retry + case rollback +} + +enum PDFRuntimeDashboardState: Equatable { + case checking + case notInstalled + case installing(version: String?, progress: Int?) + case starting(version: String) + case ready(PDFRuntimeReadyInfo) + case translating(PDFRuntimeReadyInfo, fileName: String, progress: Int?) + case updateAvailable(PDFRuntimeReadyInfo, availableVersion: String) + case reconnecting(previousProcessIdentifier: Int32?) + case stopping(previousProcessIdentifier: Int32?) + case failed(message: String, installedVersion: String?, canRollback: Bool) + case stopped(installedVersion: String?, canRollback: Bool) + + var isReady: Bool { + switch self { + case .ready, .translating, .updateAvailable: + true + default: + false + } + } + + var action: PDFRuntimeDashboardAction? { + switch self { + case .checking, .installing, .starting, .reconnecting, .stopping: + nil + case .notInstalled: + .install + case .ready: + .reconnect + case .translating: + .cancel + case .updateAvailable: + .update + case .failed(_, let installedVersion, let canRollback): + if canRollback { + .rollback + } else if installedVersion != nil { + .reconnect + } else { + .retry + } + case .stopped(let installedVersion, let canRollback): + if installedVersion != nil { + .start + } else if canRollback { + .rollback + } else { + .install + } + } + } + + var presentation: PDFRuntimeDashboardPresentation { + switch self { + case .checking: + return PDFRuntimeDashboardPresentation( + headline: "正在检查 PDF 运行时…", + detail: "正在验证已安装版本与残留进程", + path: nil, + tone: .neutral, + actionTitle: "正在检查…", + actionEnabled: false, + actionIsDestructive: false, + showsProgress: true + ) + case .notInstalled: + return PDFRuntimeDashboardPresentation( + headline: "尚未安装 PDF 运行时", + detail: "Gloss 可以自动安装经过校验的 BabelDOC 运行时", + path: nil, + tone: .warning, + actionTitle: "安装", + actionEnabled: true, + actionIsDestructive: false, + showsProgress: false + ) + case .installing(let version, let progress): + let versionText = version.map { " \($0)" } ?? "" + let progressText = progress.map { " · \($0)%" } ?? "" + return PDFRuntimeDashboardPresentation( + headline: "正在安装\(versionText)…", + detail: "下载、校验并原子切换运行时\(progressText)", + path: nil, + tone: .neutral, + actionTitle: "正在安装…", + actionEnabled: false, + actionIsDestructive: false, + showsProgress: true + ) + case .starting(let version): + return PDFRuntimeDashboardPresentation( + headline: "正在启动 PDF 服务…", + detail: "BabelDOC \(version) · 正在验证身份与健康状态", + path: nil, + tone: .neutral, + actionTitle: "正在连接…", + actionEnabled: false, + actionIsDestructive: false, + showsProgress: true + ) + case .ready(let info): + return readyPresentation( + info, + headline: "PDF 服务已就绪", + detailSuffix: nil, + tone: .positive, + actionTitle: "重新连接", + actionIsDestructive: false, + showsProgress: false + ) + case .translating(let info, let fileName, let progress): + let progressText = progress.map { " · \($0)%" } ?? "" + return readyPresentation( + info, + headline: "正在翻译 \(fileName)", + detailSuffix: "任务运行中\(progressText)", + tone: .positive, + actionTitle: "停止任务", + actionIsDestructive: true, + showsProgress: true + ) + case .updateAvailable(let info, let availableVersion): + return readyPresentation( + info, + headline: "PDF 运行时可更新", + detailSuffix: "可升级到 \(availableVersion)", + tone: .warning, + actionTitle: "更新", + actionIsDestructive: false, + showsProgress: false + ) + case .reconnecting(let previousProcessIdentifier): + return PDFRuntimeDashboardPresentation( + headline: "正在重新连接 PDF 服务…", + detail: previousProcessIdentifier.map { + "正在验证并清理旧进程 PID \($0)" + } ?? "正在验证残留进程并重新启动", + path: nil, + tone: .warning, + actionTitle: "正在重连…", + actionEnabled: false, + actionIsDestructive: false, + showsProgress: true + ) + case .stopping(let previousProcessIdentifier): + return PDFRuntimeDashboardPresentation( + headline: "正在停止 PDF 服务…", + detail: previousProcessIdentifier.map { + "正在等待 PID \($0) 安全退出" + } ?? "正在取消任务并释放运行时资源", + path: nil, + tone: .neutral, + actionTitle: "正在停止…", + actionEnabled: false, + actionIsDestructive: false, + showsProgress: true + ) + case .failed(let message, let installedVersion, let canRollback): + let versionText = installedVersion.map { " · 已安装 \($0)" } ?? "" + let actionTitle = + if canRollback { + "回滚" + } else if installedVersion != nil { + "重新连接" + } else { + "重试" + } + return PDFRuntimeDashboardPresentation( + headline: "PDF 服务不可用", + detail: "\(message)\(versionText)", + path: nil, + tone: .negative, + actionTitle: actionTitle, + actionEnabled: true, + actionIsDestructive: false, + showsProgress: false + ) + case .stopped(let installedVersion, let canRollback): + let detail: String + let actionTitle: String + if let installedVersion { + detail = "已安装 BabelDOC \(installedVersion)" + actionTitle = "启动" + } else if canRollback { + detail = "当前版本不可用,可以恢复上一版本" + actionTitle = "回滚" + } else { + detail = "安装经过校验的运行时后即可翻译 PDF" + actionTitle = "安装" + } + return PDFRuntimeDashboardPresentation( + headline: "PDF 服务未启动", + detail: detail, + path: nil, + tone: .neutral, + actionTitle: actionTitle, + actionEnabled: true, + actionIsDestructive: false, + showsProgress: false + ) + } + } + + private func readyPresentation( + _ info: PDFRuntimeReadyInfo, + headline: String, + detailSuffix: String?, + tone: PDFRuntimeDashboardPresentation.Tone, + actionTitle: String, + actionIsDestructive: Bool, + showsProgress: Bool + ) -> PDFRuntimeDashboardPresentation { + var parts = [ + info.endpoint, + "PID \(info.processIdentifier)", + "BabelDOC \(info.version)", + ] + if let detailSuffix { + parts.append(detailSuffix) + } + return PDFRuntimeDashboardPresentation( + headline: headline, + detail: parts.joined(separator: " · "), + path: info.executablePath, + tone: tone, + actionTitle: actionTitle, + actionEnabled: true, + actionIsDestructive: actionIsDestructive, + showsProgress: showsProgress + ) + } +} + +struct PDFRuntimeDashboardPresentation: Equatable { + enum Tone: Equatable { + case neutral + case positive + case warning + case negative + } + + let headline: String + let detail: String + let path: String? + let tone: Tone + let actionTitle: String + let actionEnabled: Bool + let actionIsDestructive: Bool + let showsProgress: Bool +} diff --git a/Sources/Gloss/PDFTranslationWindowController.swift b/Sources/Gloss/PDFTranslationWindowController.swift index 0991384..0ad12c1 100644 --- a/Sources/Gloss/PDFTranslationWindowController.swift +++ b/Sources/Gloss/PDFTranslationWindowController.swift @@ -342,6 +342,91 @@ private final class PDFQueueCellView: NSTableCellView { } } +@MainActor +final class PDFBatchTaskCoordinator { + private var activeTask: Task? + private var activeID: UUID? + private var terminalTask: Task? + private var terminalID: UUID? + private var isDraining = false + + var onChange: (() -> Void)? + + var isActive: Bool { + activeTask != nil + } + + var preventsStarting: Bool { + activeTask != nil || isDraining + } + + @discardableResult + func start(_ operation: @escaping @MainActor () async -> Void) -> Bool { + guard activeTask == nil, !isDraining else { return false } + let previousTask = terminalTask + let operationID = UUID() + let task = Task { [weak self] in + await previousTask?.value + guard let self else { return } + if !Task.isCancelled { + await operation() + } + finish(operationID) + } + activeID = operationID + activeTask = task + terminalID = operationID + terminalTask = task + onChange?() + return true + } + + func cancel() { + activeTask?.cancel() + } + + func cancelAndDetach() { + activeTask?.cancel() + activeTask = nil + activeID = nil + onChange?() + } + + func cancelAndWait() async { + if isDraining { + await terminalTask?.value + return + } + isDraining = true + let task = terminalTask + activeTask?.cancel() + onChange?() + await task?.value + isDraining = false + onChange?() + } + + func waitForTerminal() async { + await terminalTask?.value + } + + private func finish(_ operationID: UUID) { + var changed = false + if activeID == operationID { + activeTask = nil + activeID = nil + changed = true + } + if terminalID == operationID { + terminalTask = nil + terminalID = nil + } + if changed { + onChange?() + } + } +} + @MainActor final class PDFTranslationWindowController: NSObject, NSWindowDelegate { private enum QueueRow { @@ -359,8 +444,9 @@ final class PDFTranslationWindowController: NSObject, NSWindowDelegate { private let targetLanguage: () -> String private let bridgeToken: () -> String? private let translationDispatchState: TranslationDispatchState - private let babelDOCExternalEngine = BabelDOCExternalEngine() - private let babelDOCService = BabelDOCServiceSession() + private let runtimeController: PDFRuntimeController + private let babelDOCExternalEngine: BabelDOCExternalEngine + private let babelDOCService: BabelDOCServiceSession private let window: NSWindow private let queueTableView = NSTableView() @@ -412,10 +498,15 @@ final class PDFTranslationWindowController: NSObject, NSWindowDelegate { private var selectedItemID: UUID? private var defaultOutputDirectoryURL = PDFTranslationWindowController.defaultOutputDirectory() - private var batchTranslationTask: Task? + private let batchCoordinator = PDFBatchTaskCoordinator() private var progressRefreshTask: Task? private var serviceStartupTask: Task? + private var serviceStartupGeneration = 0 private var serviceState: ServiceState = .stopped + private var runtimeState = PDFRuntimeDashboardState.checking + private var runtimeObservationTask: Task? + private var runtimeMaintenancePending = false + private var runtimeMaintenancePreparationInFlight = false private var layoutServiceBaseURL: URL? private var layoutCacheDirectoryURL: URL? private var activePerformanceRunID: UUID? @@ -424,11 +515,17 @@ final class PDFTranslationWindowController: NSObject, NSWindowDelegate { init( targetLanguage: @escaping () -> String, bridgeToken: @escaping () -> String?, - translationDispatchState: TranslationDispatchState + translationDispatchState: TranslationDispatchState, + runtimeController: PDFRuntimeController ) { self.targetLanguage = targetLanguage self.bridgeToken = bridgeToken self.translationDispatchState = translationDispatchState + self.runtimeController = runtimeController + babelDOCService = runtimeController.service + babelDOCExternalEngine = BabelDOCExternalEngine( + executorManager: runtimeController.service + ) window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 1_320, height: 820), styleMask: [.titled, .closable, .miniaturizable, .resizable], @@ -436,9 +533,18 @@ final class PDFTranslationWindowController: NSObject, NSWindowDelegate { defer: true ) super.init() + runtimeState = runtimeController.dashboardState + batchCoordinator.onChange = { [weak self] in + self?.refreshInterface() + } + startObservingRuntime() configureWindow() } + deinit { + runtimeObservationTask?.cancel() + } + func show() { window.center() NSApp.activate(ignoringOtherApps: true) @@ -465,33 +571,72 @@ final class PDFTranslationWindowController: NSObject, NSWindowDelegate { } func stop() { + serviceStartupGeneration &+= 1 serviceStartupTask?.cancel() serviceStartupTask = nil - batchTranslationTask?.cancel() - batchTranslationTask = nil + batchCoordinator.cancelAndDetach() progressRefreshTask?.cancel() progressRefreshTask = nil activePerformanceRunID = nil - Task { await babelDOCService.stop() } + runtimeController.closeModule() layoutServiceBaseURL = nil layoutCacheDirectoryURL = nil serviceState = .stopped } func stopAndWait() async { - serviceStartupTask?.cancel() + serviceStartupGeneration &+= 1 + let startupTask = serviceStartupTask + startupTask?.cancel() serviceStartupTask = nil - batchTranslationTask?.cancel() - batchTranslationTask = nil + let serviceCancellation = Task { [babelDOCService] in + try? await babelDOCService.cancelCurrent() + } progressRefreshTask?.cancel() progressRefreshTask = nil activePerformanceRunID = nil - await babelDOCService.stop() + await runtimeController.cancelModulePreparationAndWait() + await batchCoordinator.cancelAndWait() + await serviceCancellation.value + await startupTask?.value + await runtimeController.closeModuleAndWait() layoutServiceBaseURL = nil layoutCacheDirectoryURL = nil serviceState = .stopped } + func cancelActiveTranslation() { + batchCoordinator.cancel() + } + + func cancelActiveTranslationAndWait() async { + let serviceCancellation = Task { [babelDOCService] in + try? await babelDOCService.cancelCurrent() + } + await batchCoordinator.cancelAndWait() + await serviceCancellation.value + } + + func prepareForRuntimeMaintenance( + releaseWhenComplete: Bool = false + ) async { + runtimeMaintenancePending = true + runtimeMaintenancePreparationInFlight = true + refreshInterface() + serviceStartupGeneration &+= 1 + let startupTask = serviceStartupTask + startupTask?.cancel() + serviceStartupTask = nil + await runtimeController.cancelModulePreparationAndWait() + await startupTask?.value + await cancelActiveTranslationAndWait() + runtimeMaintenancePreparationInFlight = false + if releaseWhenComplete { + runtimeMaintenancePending = false + } + refreshInterface() + } + func windowWillClose(_ notification: Notification) { stop() } @@ -1051,8 +1196,9 @@ final class PDFTranslationWindowController: NSObject, NSWindowDelegate { let hasItem = item != nil pdfView.isHidden = !hasItem emptyStateView.isHidden = hasItem - clearCompletedButton.isEnabled = !completedItems.isEmpty && batchTranslationTask == nil - addFilesButton.isEnabled = batchTranslationTask == nil + clearCompletedButton.isEnabled = + !completedItems.isEmpty && !batchCoordinator.preventsStarting + addFilesButton.isEnabled = !batchCoordinator.isActive documentTitleLabel.stringValue = item?.sourceURL.lastPathComponent ?? "未选择 PDF" if let item, pdfView.document !== item.document { @@ -1131,7 +1277,10 @@ final class PDFTranslationWindowController: NSObject, NSWindowDelegate { } private func updateInspector(for item: PDFQueueItem?) { - let controlsEnabled = item != nil && item?.state != .running && batchTranslationTask == nil + let controlsEnabled = + item != nil + && item?.state != .running + && !batchCoordinator.preventsStarting targetLanguagePopup.isEnabled = controlsEnabled outputModeControl.isEnabled = controlsEnabled outputFolderButton.isEnabled = controlsEnabled @@ -1155,25 +1304,30 @@ final class PDFTranslationWindowController: NSObject, NSWindowDelegate { engineLabel.stringValue = serviceDescription let runnableCount = runnableItems.count - if batchTranslationTask != nil { + if batchCoordinator.isActive { translateButton.title = "停止批量翻译" translateButton.bezelColor = .systemRed translateButton.isEnabled = true + } else if batchCoordinator.preventsStarting { + translateButton.title = "正在停止批量翻译…" + translateButton.bezelColor = nil + translateButton.isEnabled = false } else if runnableCount > 0 { translateButton.title = runnableCount == 1 ? "开始翻译" : "开始批量翻译(\(runnableCount))" - translateButton.bezelColor = .controlAccentColor - translateButton.isEnabled = true + translateButton.bezelColor = + runtimeAllowsNewBatch ? .controlAccentColor : nil + translateButton.isEnabled = runtimeAllowsNewBatch } else if item?.outputURL != nil { translateButton.title = "打开译文" translateButton.bezelColor = .controlAccentColor translateButton.isEnabled = true - } else if BabelDOCExternalEngine.resolveRuntime() == nil { - translateButton.title = "安装 BabelDOC…" + } else if runtimeController.currentRuntimeLaunch == nil { + translateButton.title = "正在准备 PDF 运行时…" translateButton.bezelColor = nil - translateButton.isEnabled = true + translateButton.isEnabled = false } else { translateButton.title = "开始批量翻译" translateButton.bezelColor = nil @@ -1185,21 +1339,108 @@ final class PDFTranslationWindowController: NSObject, NSWindowDelegate { } private var serviceDescription: String { - let runtime = BabelDOCExternalEngine.resolveRuntime() - return switch (runtime, serviceState) { - case (nil, _): - "需要安装 BabelDOC" - case (.some(let runtime), .stopped): - "BabelDOC · \(runtime.source) · PDF 服务未启动" - case (.some(let runtime), .starting): - "BabelDOC · \(runtime.source) · 正在启动常驻 PDF 服务…" - case (.some(let runtime), .ready): - "BabelDOC · \(runtime.source) · PDF 服务已就绪" - case (.some(let runtime), .failed(let message)): - "BabelDOC · \(runtime.source) · 服务回退:\(message)" + let presentation = runtimeState.presentation + return switch serviceState { + case .stopped: + presentation.headline + case .starting: + "正在启动常驻 PDF 服务…" + case .ready: + presentation.headline + case .failed(let message): + "PDF 服务不可用:\(message)" + } + } + + private var runtimeAllowsNewBatch: Bool { + let serviceIsReady = + if case .ready = serviceState { + true + } else { + false + } + return Self.canStartBatch( + runtimeState: runtimeState, + serviceIsReady: serviceIsReady, + maintenancePending: runtimeMaintenancePending + ) + } + + nonisolated static func canStartBatch( + runtimeState: PDFRuntimeDashboardState, + serviceIsReady: Bool, + maintenancePending: Bool + ) -> Bool { + serviceIsReady + && !maintenancePending + && runtimeAllowsNewBatch(runtimeState) + } + + nonisolated static func runtimeAllowsNewBatch( + _ state: PDFRuntimeDashboardState + ) -> Bool { + switch state { + case .ready, .updateAvailable: + true + case .checking, .notInstalled, .installing, .starting, .translating, + .reconnecting, .stopping, .failed, .stopped: + false } } + private func startObservingRuntime() { + runtimeObservationTask = Task { [weak self, runtimeController] in + for await state in runtimeController.stateChanges() { + guard let self, !Task.isCancelled else { return } + await applyRuntimeState(state) + } + } + } + + private func applyRuntimeState(_ state: PDFRuntimeDashboardState) async { + runtimeState = state + switch state { + case .ready(let info), .updateAvailable(let info, _): + if !runtimeMaintenancePreparationInFlight { + runtimeMaintenancePending = false + } + layoutServiceBaseURL = URL(string: info.endpoint) + serviceState = .ready + let cacheURL = await babelDOCService.layoutCacheDirectoryURL + guard runtimeState == state else { return } + layoutCacheDirectoryURL = cacheURL + case .translating(let info, _, _): + layoutServiceBaseURL = URL(string: info.endpoint) + serviceState = .ready + let cacheURL = await babelDOCService.layoutCacheDirectoryURL + guard runtimeState == state else { return } + layoutCacheDirectoryURL = cacheURL + case .checking, .installing, .starting, .reconnecting: + layoutServiceBaseURL = nil + layoutCacheDirectoryURL = nil + serviceState = .starting + case .failed(let message, _, _): + if !runtimeMaintenancePreparationInFlight { + runtimeMaintenancePending = false + } + layoutServiceBaseURL = nil + layoutCacheDirectoryURL = nil + serviceState = .failed(message) + case .stopping: + layoutServiceBaseURL = nil + layoutCacheDirectoryURL = nil + serviceState = .stopped + case .notInstalled, .stopped: + if !runtimeMaintenancePreparationInFlight { + runtimeMaintenancePending = false + } + layoutServiceBaseURL = nil + layoutCacheDirectoryURL = nil + serviceState = .stopped + } + refreshInterface() + } + private func selectLanguage(_ targetName: String) { if let index = targetLanguagePopup.itemArray.firstIndex(where: { $0.representedObject as? String == targetName @@ -1213,30 +1454,38 @@ final class PDFTranslationWindowController: NSObject, NSWindowDelegate { } private func startPDFServiceIfNeeded() { - guard serviceStartupTask == nil, - layoutServiceBaseURL == nil, - let runtime = BabelDOCExternalEngine.resolveRuntime() + guard !runtimeMaintenancePending, + serviceStartupTask == nil, + layoutServiceBaseURL == nil else { return } serviceState = .starting engineLabel.stringValue = serviceDescription - serviceStartupTask = Task { [weak self] in + serviceStartupGeneration &+= 1 + let generation = serviceStartupGeneration + let startupTask = Task { [weak self] in guard let self else { return } do { - let baseURL = try await babelDOCService.start(runtime: runtime) - guard !Task.isCancelled else { return } - layoutServiceBaseURL = baseURL - layoutCacheDirectoryURL = await babelDOCService.layoutCacheDirectoryURL + try Task.checkCancellation() + let prepared = try await runtimeController.prepareModule() + try Task.checkCancellation() + guard serviceStartupGeneration == generation else { return } + layoutServiceBaseURL = prepared.layoutServiceBaseURL + layoutCacheDirectoryURL = prepared.layoutCacheDirectoryURL serviceState = .ready } catch is CancellationError { + guard serviceStartupGeneration == generation else { return } serviceState = .stopped } catch { + guard serviceStartupGeneration == generation else { return } serviceState = .failed(error.localizedDescription) layoutServiceBaseURL = nil layoutCacheDirectoryURL = nil } + guard serviceStartupGeneration == generation else { return } serviceStartupTask = nil refreshInterface() } + serviceStartupTask = startupTask } @objc private func choosePDFFiles() { @@ -1326,28 +1575,25 @@ final class PDFTranslationWindowController: NSObject, NSWindowDelegate { } @objc private func primaryAction() { - if let batchTranslationTask { - batchTranslationTask.cancel() + if batchCoordinator.isActive { + cancelActiveTranslation() return } if !runnableItems.isEmpty { - beginBatchTranslation() + if runtimeAllowsNewBatch { + beginBatchTranslation() + } return } if let outputURL = selectedItem?.outputURL { NSWorkspace.shared.open(outputURL) return } - if BabelDOCExternalEngine.resolveRuntime() == nil { - showBabelDOCInstallationHelp() - } + startPDFServiceIfNeeded() } private func beginBatchTranslation() { - guard let runtime = BabelDOCExternalEngine.resolveRuntime() else { - showBabelDOCInstallationHelp() - return - } + guard runtimeAllowsNewBatch else { return } guard let token = bridgeToken() else { showAlert( title: "翻译服务尚未就绪", @@ -1357,23 +1603,35 @@ final class PDFTranslationWindowController: NSObject, NSWindowDelegate { } let queue = runnableItems guard !queue.isEmpty else { return } - batchTranslationTask = Task { [weak self] in + batchCoordinator.start { [weak self] in guard let self else { return } - if layoutServiceBaseURL == nil { - startPDFServiceIfNeeded() - await serviceStartupTask?.value + guard runtimeAllowsNewBatch, + let runtime = runtimeController.currentRuntimeLaunch, + layoutServiceBaseURL != nil + else { + let message = + if case .failed(let reason) = serviceState { + reason + } else { + "PDF 服务尚未就绪" + } + for item in queue where item.state != .completed { + item.state = .failed + item.progress = nil + item.statusText = message + } + refreshInterface() + return } for item in queue { guard !Task.isCancelled else { break } await translate(item, runtime: runtime, bridgeToken: token) } - batchTranslationTask = nil activePerformanceRunID = nil progressRefreshTask?.cancel() progressRefreshTask = nil refreshInterface() } - refreshInterface() } private func translate( @@ -1402,6 +1660,12 @@ final class PDFTranslationWindowController: NSObject, NSWindowDelegate { layoutServiceBaseURL == nil ? "正在启动 BabelDOC…" : "正在连接常驻 PDF 服务…" + runtimeController.translationDidStart(fileName: item.sourceURL.lastPathComponent) + defer { + runtimeController.translationDidFinish( + fileName: item.sourceURL.lastPathComponent + ) + } select(item) refreshInterface() @@ -1731,22 +1995,6 @@ final class PDFTranslationWindowController: NSObject, NSWindowDelegate { zoomLabel.stringValue = "\(Int((pdfView.scaleFactor * 100).rounded()))%" } - private func showBabelDOCInstallationHelp() { - let alert = NSAlert() - alert.alertStyle = .informational - alert.messageText = "需要安装 BabelDOC" - alert.informativeText = """ - PDF 翻译使用独立安装的 BabelDOC。 - - 安装命令: - uv tool install --python 3.12 BabelDOC - - 也可以通过 GLOSS_BABELDOC_BIN 指定可执行文件。 - """ - alert.addButton(withTitle: "好") - alert.beginSheetModal(for: window) - } - private func showAlert(title: String, message: String) { let alert = NSAlert() alert.alertStyle = .warning diff --git a/Sources/Gloss/SettingsWindowController.swift b/Sources/Gloss/SettingsWindowController.swift index 4845faf..92ee3ec 100644 --- a/Sources/Gloss/SettingsWindowController.swift +++ b/Sources/Gloss/SettingsWindowController.swift @@ -14,6 +14,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel var onRevealBrowserExtension: (() -> Void)? var onOpenSafariExtensionSettings: (() -> Void)? var onBridgeAction: (() -> Void)? + var onPDFRuntimeAction: ((PDFRuntimeDashboardAction) -> Void)? var onRevealLogs: (() -> Void)? var onOpenServicesSettings: (() -> Void)? var onSetLaunchAtLogin: ((Bool) -> Bool)? @@ -25,6 +26,11 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel private let bridgeStatus = NSTextField(labelWithString: "正在检查本地连接…") private let bridgeDetail = NSTextField(labelWithString: "127.0.0.1:8787") private let bridgePath = NSTextField(labelWithString: "") + private let pdfRuntimeStatus = NSTextField(labelWithString: "正在检查 PDF 运行时…") + private let pdfRuntimeDetail = NSTextField( + labelWithString: "正在验证已安装版本与残留进程" + ) + private let pdfRuntimePath = NSTextField(labelWithString: "") private let browserExtensionStatus = NSTextField(labelWithString: "正在准备浏览器扩展") private let shortcutStatus = NSTextField(labelWithString: "手动翻译当前选区") private let launchAtLoginStatus = NSTextField(labelWithString: "关闭") @@ -43,6 +49,13 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel private let launchAtLoginSwitch = NSSwitch() private let bridgeActionButton = NSButton(title: "正在检查…", target: nil, action: nil) private let bridgeProgressIndicator = NSProgressIndicator() + private let pdfRuntimeActionButton = NSButton( + title: "正在检查…", + target: nil, + action: nil + ) + private let pdfRuntimeProgressIndicator = NSProgressIndicator() + private var pdfRuntimeState = PDFRuntimeDashboardState.checking override init() { window = NSWindow( @@ -132,8 +145,34 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel window.delegate = self window.minSize = NSSize(width: 520, height: 810) + let root = NSView() + let scrollView = NSScrollView() + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + scrollView.translatesAutoresizingMaskIntoConstraints = false + root.addSubview(scrollView) + window.contentView = root + let content = NSView() - window.contentView = content + content.translatesAutoresizingMaskIntoConstraints = false + scrollView.documentView = content + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: root.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: root.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: root.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: root.bottomAnchor), + content.leadingAnchor.constraint( + equalTo: scrollView.contentView.leadingAnchor + ), + content.trailingAnchor.constraint( + equalTo: scrollView.contentView.trailingAnchor + ), + content.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor), + content.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor), + ]) let icon = NSImageView() icon.image = GlossBrand.markImage(pointSize: 36) @@ -191,6 +230,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel let providerCard = makeProviderCard() let browserCard = makeBridgeCard() + let pdfRuntimeCard = makePDFRuntimeCard() let launchAtLoginCard = makeStatusCard( symbol: "power", @@ -244,6 +284,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel servicesCard, providerCard, browserCard, + pdfRuntimeCard, launchAtLoginCard, actionButtons, hint, @@ -278,7 +319,17 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel browserCard.leadingAnchor.constraint(equalTo: providerCard.leadingAnchor), browserCard.trailingAnchor.constraint(equalTo: providerCard.trailingAnchor), - launchAtLoginCard.topAnchor.constraint(equalTo: browserCard.bottomAnchor, constant: 10), + pdfRuntimeCard.topAnchor.constraint( + equalTo: browserCard.bottomAnchor, + constant: 10 + ), + pdfRuntimeCard.leadingAnchor.constraint(equalTo: browserCard.leadingAnchor), + pdfRuntimeCard.trailingAnchor.constraint(equalTo: browserCard.trailingAnchor), + + launchAtLoginCard.topAnchor.constraint( + equalTo: pdfRuntimeCard.bottomAnchor, + constant: 10 + ), launchAtLoginCard.leadingAnchor.constraint(equalTo: browserCard.leadingAnchor), launchAtLoginCard.trailingAnchor.constraint(equalTo: browserCard.trailingAnchor), @@ -288,8 +339,106 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel hint.topAnchor.constraint(equalTo: actionButtons.bottomAnchor, constant: 16), hint.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 44), hint.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -44), - hint.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -20), + hint.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -20), + ]) + } + + private func makePDFRuntimeCard() -> NSView { + let card = NSVisualEffectView() + card.material = .contentBackground + card.blendingMode = .withinWindow + card.state = .active + card.wantsLayer = true + card.layer?.cornerRadius = 10 + card.translatesAutoresizingMaskIntoConstraints = false + + let icon = NSImageView() + icon.image = NSImage( + systemSymbolName: "doc.richtext", + accessibilityDescription: "PDF 运行时" + ) + icon.contentTintColor = .secondaryLabelColor + icon.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = NSTextField(labelWithString: "PDF 运行时") + titleLabel.font = .systemFont(ofSize: 13, weight: .semibold) + titleLabel.setContentHuggingPriority(.required, for: .horizontal) + + pdfRuntimeProgressIndicator.style = .spinning + pdfRuntimeProgressIndicator.controlSize = .small + + pdfRuntimeStatus.font = .systemFont(ofSize: 12, weight: .medium) + pdfRuntimeStatus.lineBreakMode = .byTruncatingTail + pdfRuntimeStatus.setContentCompressionResistancePriority( + .defaultLow, + for: .horizontal + ) + + let statusStack = NSStackView( + views: [pdfRuntimeProgressIndicator, pdfRuntimeStatus] + ) + statusStack.orientation = .horizontal + statusStack.alignment = .centerY + statusStack.spacing = 5 + + pdfRuntimeActionButton.target = self + pdfRuntimeActionButton.action = #selector(performPDFRuntimeAction) + pdfRuntimeActionButton.setContentHuggingPriority( + .required, + for: .horizontal + ) + + let topRow = NSStackView( + views: [titleLabel, statusStack, pdfRuntimeActionButton] + ) + topRow.orientation = .horizontal + topRow.alignment = .centerY + topRow.spacing = 10 + topRow.distribution = .fill + + pdfRuntimeDetail.font = .systemFont(ofSize: 11.5) + pdfRuntimeDetail.textColor = .secondaryLabelColor + pdfRuntimeDetail.lineBreakMode = .byTruncatingMiddle + pdfRuntimeDetail.setContentCompressionResistancePriority( + .defaultLow, + for: .horizontal + ) + + pdfRuntimePath.font = .monospacedSystemFont(ofSize: 10.5, weight: .regular) + pdfRuntimePath.textColor = .tertiaryLabelColor + pdfRuntimePath.lineBreakMode = .byTruncatingMiddle + pdfRuntimePath.setContentCompressionResistancePriority( + .defaultLow, + for: .horizontal + ) + pdfRuntimePath.isHidden = true + + let content = NSStackView( + views: [topRow, pdfRuntimeDetail, pdfRuntimePath] + ) + content.orientation = .vertical + content.alignment = .leading + content.spacing = 4 + content.translatesAutoresizingMaskIntoConstraints = false + + card.addSubview(icon) + card.addSubview(content) + NSLayoutConstraint.activate([ + card.heightAnchor.constraint(equalToConstant: 92), + icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16), + icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 16), + icon.widthAnchor.constraint(equalToConstant: 22), + icon.heightAnchor.constraint(equalToConstant: 22), + content.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 12), + content.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14), + content.topAnchor.constraint(equalTo: card.topAnchor, constant: 11), + content.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10), + topRow.widthAnchor.constraint(equalTo: content.widthAnchor), + pdfRuntimeDetail.widthAnchor.constraint(equalTo: content.widthAnchor), + pdfRuntimePath.widthAnchor.constraint(equalTo: content.widthAnchor), ]) + showPDFRuntimeState(pdfRuntimeState) + return card } private func makeBridgeCard() -> NSView { @@ -644,6 +793,11 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel onBridgeAction?() } + @objc private func performPDFRuntimeAction() { + guard let action = pdfRuntimeState.action else { return } + onPDFRuntimeAction?(action) + } + @objc private func openServicesSettings() { onOpenServicesSettings?() } @@ -682,6 +836,34 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel } } + func showPDFRuntimeState(_ state: PDFRuntimeDashboardState) { + pdfRuntimeState = state + let presentation = state.presentation + pdfRuntimeStatus.stringValue = presentation.headline + pdfRuntimeDetail.stringValue = presentation.detail + pdfRuntimeDetail.toolTip = presentation.detail + pdfRuntimePath.stringValue = presentation.path ?? "" + pdfRuntimePath.toolTip = presentation.path + pdfRuntimePath.isHidden = presentation.path == nil + pdfRuntimeActionButton.title = presentation.actionTitle + pdfRuntimeActionButton.isEnabled = presentation.actionEnabled + pdfRuntimeActionButton.contentTintColor = + presentation.actionIsDestructive ? .systemRed : nil + pdfRuntimeProgressIndicator.isHidden = !presentation.showsProgress + if presentation.showsProgress { + pdfRuntimeProgressIndicator.startAnimation(nil) + } else { + pdfRuntimeProgressIndicator.stopAnimation(nil) + } + pdfRuntimeStatus.textColor = + switch presentation.tone { + case .neutral: .secondaryLabelColor + case .positive: .systemGreen + case .warning: .systemOrange + case .negative: .systemRed + } + } + func showBrowserExtensionStatus(_ message: String, succeeded: Bool) { browserExtensionStatus.stringValue = message browserExtensionStatus.textColor = succeeded ? .secondaryLabelColor : .systemRed diff --git a/Tests/GlossAppTests/PDFRuntimeDashboardStateTests.swift b/Tests/GlossAppTests/PDFRuntimeDashboardStateTests.swift new file mode 100644 index 0000000..be384d4 --- /dev/null +++ b/Tests/GlossAppTests/PDFRuntimeDashboardStateTests.swift @@ -0,0 +1,204 @@ +import GlossCore +import XCTest + +@testable import Gloss + +final class PDFRuntimeDashboardStateTests: XCTestCase { + func testReadyStateShowsVerifiedRuntimeIdentityAndReconnect() { + let state = PDFRuntimeDashboardState.ready( + PDFRuntimeReadyInfo( + endpoint: "http://127.0.0.1:49160", + processIdentifier: 42, + version: "0.6.4+gloss.3", + executablePath: "/Applications/Gloss.app/Contents/Resources/gloss-babeldoc" + ) + ) + + XCTAssertTrue(state.isReady) + XCTAssertEqual(state.action, .reconnect) + XCTAssertEqual(state.presentation.headline, "PDF 服务已就绪") + XCTAssertTrue(state.presentation.detail.contains("PID 42")) + XCTAssertTrue(state.presentation.detail.contains("0.6.4+gloss.3")) + XCTAssertFalse(state.presentation.actionIsDestructive) + } + + func testTranslatingStateMakesOnlyTaskCancellationDestructive() { + let info = PDFRuntimeReadyInfo( + endpoint: "http://127.0.0.1:49160", + processIdentifier: 42, + version: "0.6.4+gloss.3", + executablePath: nil + ) + let state = PDFRuntimeDashboardState.translating( + info, + fileName: "paper.pdf", + progress: 37 + ) + + XCTAssertEqual(state.action, .cancel) + XCTAssertEqual(state.presentation.actionTitle, "停止任务") + XCTAssertTrue(state.presentation.actionIsDestructive) + XCTAssertTrue(state.presentation.detail.contains("37%")) + XCTAssertTrue(state.presentation.showsProgress) + } + + func testInstallUpdateFailureAndRollbackHaveDistinctActions() { + XCTAssertEqual(PDFRuntimeDashboardState.notInstalled.action, .install) + XCTAssertEqual( + PDFRuntimeDashboardState.failed( + message: "健康检查失败", + installedVersion: "0.6.4+gloss.3", + canRollback: false + ).action, + .reconnect + ) + XCTAssertEqual( + PDFRuntimeDashboardState.failed( + message: "更新后无法启动", + installedVersion: "0.6.4+gloss.3", + canRollback: true + ).action, + .rollback + ) + } + + func testReconnectingExplainsVerifiedOldProcessCleanup() { + let state = PDFRuntimeDashboardState.reconnecting( + previousProcessIdentifier: 314 + ) + + XCTAssertNil(state.action) + XCTAssertTrue(state.presentation.detail.contains("PID 314")) + XCTAssertFalse(state.presentation.actionEnabled) + XCTAssertTrue(state.presentation.showsProgress) + } + + func testStoppingDisablesRepeatedActions() { + let state = PDFRuntimeDashboardState.stopping( + previousProcessIdentifier: 314 + ) + + XCTAssertNil(state.action) + XCTAssertTrue(state.presentation.detail.contains("PID 314")) + XCTAssertFalse(state.presentation.actionEnabled) + XCTAssertTrue(state.presentation.showsProgress) + } + + func testControllerMapsMissingRuntimeToAutomaticInstall() { + let state = PDFRuntimeController.dashboardState( + runtime: nil, + service: BabelDOCExecutorServiceSnapshot( + installed: false, + lifecycleState: .stopped + ), + activeDocumentName: nil, + fallbackRuntimeAvailable: false + ) + + XCTAssertEqual(state, .notInstalled) + XCTAssertEqual(state.action, .install) + } + + func testControllerMapsAuthenticatedServiceAndTaskProgress() { + let runtime = BabelDOCRuntimeSnapshot( + channel: .stable, + pinnedVersion: nil, + currentVersion: "0.6.4+gloss.3", + previousVersion: "0.6.4+gloss.2", + availableVersion: "0.6.4+gloss.3", + currentExecutableURL: URL(fileURLWithPath: "/runtime/gloss-babeldoc"), + updateAvailable: false, + operation: .ready, + lastError: nil + ) + let service = BabelDOCExecutorServiceSnapshot( + installed: true, + runtimeVersion: "0.6.4+gloss.3", + endpoint: URL(string: "http://127.0.0.1:49160"), + processIdentifier: 42, + processStartTime: 123, + instanceID: "instance", + lifecycleState: .ready, + activeTaskID: "task-1", + activeExecutionID: "execution-1", + activeStatus: "running", + activeProgress: 37, + lastError: nil + ) + + let state = PDFRuntimeController.dashboardState( + runtime: runtime, + service: service, + activeDocumentName: "paper.pdf", + fallbackRuntimeAvailable: false + ) + + guard case .translating(let info, let fileName, let progress) = state else { + return XCTFail("Expected translating state, got \(state)") + } + XCTAssertEqual(info.processIdentifier, 42) + XCTAssertEqual(fileName, "paper.pdf") + XCTAssertEqual(progress, 37) + } + + func testControllerSurfacesAvailableRuntimeUpdate() { + let runtime = BabelDOCRuntimeSnapshot( + channel: .stable, + pinnedVersion: nil, + currentVersion: "0.6.4+gloss.2", + previousVersion: nil, + availableVersion: "0.6.4+gloss.3", + currentExecutableURL: URL(fileURLWithPath: "/runtime/gloss-babeldoc"), + updateAvailable: true, + operation: .ready, + lastError: nil + ) + let service = BabelDOCExecutorServiceSnapshot( + installed: true, + runtimeVersion: "0.6.4+gloss.2", + endpoint: URL(string: "http://127.0.0.1:49160"), + processIdentifier: 42, + lifecycleState: .ready + ) + + let state = PDFRuntimeController.dashboardState( + runtime: runtime, + service: service, + activeDocumentName: nil, + fallbackRuntimeAvailable: false + ) + + XCTAssertEqual(state.action, .update) + XCTAssertTrue(state.presentation.detail.contains("0.6.4+gloss.3")) + } + + func testServiceFailureReconnectsBeforeOfferingRuntimeRollback() { + let runtime = BabelDOCRuntimeSnapshot( + channel: .stable, + pinnedVersion: nil, + currentVersion: "0.6.4+gloss.3", + previousVersion: "0.6.4+gloss.2", + availableVersion: "0.6.4+gloss.3", + currentExecutableURL: URL(fileURLWithPath: "/runtime/gloss-babeldoc"), + updateAvailable: false, + operation: .ready, + lastError: nil + ) + let service = BabelDOCExecutorServiceSnapshot( + installed: true, + runtimeVersion: "0.6.4+gloss.3", + lifecycleState: .failed, + lastError: "端口健康检查失败" + ) + + let state = PDFRuntimeController.dashboardState( + runtime: runtime, + service: service, + activeDocumentName: nil, + fallbackRuntimeAvailable: false + ) + + XCTAssertEqual(state.action, .reconnect) + XCTAssertEqual(state.presentation.actionTitle, "重新连接") + } +} diff --git a/Tests/GlossAppTests/PDFRuntimeLifecycleTests.swift b/Tests/GlossAppTests/PDFRuntimeLifecycleTests.swift new file mode 100644 index 0000000..0529291 --- /dev/null +++ b/Tests/GlossAppTests/PDFRuntimeLifecycleTests.swift @@ -0,0 +1,171 @@ +import GlossCore +import XCTest + +@testable import Gloss + +private actor PDFLifecycleTestGate { + private var continuation: CheckedContinuation? + + func wait() async { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func open() { + continuation?.resume() + continuation = nil + } +} + +private actor PDFLifecycleEventRecorder { + private var events: [String] = [] + + func append(_ event: String) { + events.append(event) + } + + func contains(_ event: String) -> Bool { + events.contains(event) + } +} + +@MainActor +final class PDFRuntimeLifecycleTests: XCTestCase { + func testRuntimeControllerSupportsIndependentStateStreams() async { + let service = BabelDOCServiceSession( + persistedStateDirectoryURL: FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + ) + let controller = PDFRuntimeController( + service: service, + runtimeManager: nil + ) + var firstIterator = controller.stateChanges().makeAsyncIterator() + var secondIterator = controller.stateChanges().makeAsyncIterator() + + let firstState = await firstIterator.next() + let secondState = await secondIterator.next() + + XCTAssertNotNil(firstState) + XCTAssertNotNil(secondState) + } + + func testMaintenanceWaitsForCapturedModuleClose() async throws { + let controller = PDFRuntimeController( + service: BabelDOCServiceSession( + persistedStateDirectoryURL: FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + ), + runtimeManager: nil + ) + let closeGate = PDFLifecycleTestGate() + let events = PDFLifecycleEventRecorder() + let closeStarted = expectation(description: "module close started") + let closeTask = Task { + closeStarted.fulfill() + await closeGate.wait() + } + await fulfillment(of: [closeStarted], timeout: 1) + + let maintenanceTask = Task { + try await controller.waitForModuleClose(closeTask, id: nil) + await events.append("maintenance") + } + await Task.yield() + let maintenanceStartedEarly = await events.contains("maintenance") + XCTAssertFalse(maintenanceStartedEarly) + + await closeGate.open() + try await maintenanceTask.value + let maintenanceStarted = await events.contains("maintenance") + XCTAssertTrue(maintenanceStarted) + } + + func testNewBatchWaitsForCancelledBatchAndOldCompletionKeepsNewIdentity() async { + let coordinator = PDFBatchTaskCoordinator() + let firstGate = PDFLifecycleTestGate() + let secondGate = PDFLifecycleTestGate() + let events = PDFLifecycleEventRecorder() + let firstStarted = expectation(description: "first batch started") + let secondStarted = expectation(description: "second batch started") + + XCTAssertTrue( + coordinator.start { + await events.append("first") + firstStarted.fulfill() + await firstGate.wait() + } + ) + await fulfillment(of: [firstStarted], timeout: 1) + + coordinator.cancelAndDetach() + XCTAssertTrue( + coordinator.start { + await events.append("second") + secondStarted.fulfill() + await secondGate.wait() + } + ) + XCTAssertTrue(coordinator.isActive) + + await Task.yield() + let secondDidStartEarly = await events.contains("second") + XCTAssertFalse(secondDidStartEarly) + + await firstGate.open() + await fulfillment(of: [secondStarted], timeout: 1) + XCTAssertTrue(coordinator.isActive) + + await secondGate.open() + await coordinator.waitForTerminal() + XCTAssertFalse(coordinator.isActive) + } + + func testRuntimeMustBeIdleAndReadyBeforeStartingNewBatch() { + let readyInfo = PDFRuntimeReadyInfo( + endpoint: "http://127.0.0.1:49160", + processIdentifier: 42, + version: "0.6.4+gloss.3", + executablePath: nil + ) + + XCTAssertTrue( + PDFTranslationWindowController.runtimeAllowsNewBatch(.ready(readyInfo)) + ) + XCTAssertFalse( + PDFTranslationWindowController.canStartBatch( + runtimeState: .ready(readyInfo), + serviceIsReady: true, + maintenancePending: true + ) + ) + XCTAssertFalse( + PDFTranslationWindowController.canStartBatch( + runtimeState: .ready(readyInfo), + serviceIsReady: false, + maintenancePending: false + ) + ) + XCTAssertTrue( + PDFTranslationWindowController.runtimeAllowsNewBatch( + .updateAvailable(readyInfo, availableVersion: "0.6.4+gloss.4") + ) + ) + XCTAssertFalse( + PDFTranslationWindowController.runtimeAllowsNewBatch( + .translating(readyInfo, fileName: "active.pdf", progress: 20) + ) + ) + XCTAssertFalse( + PDFTranslationWindowController.runtimeAllowsNewBatch( + .installing(version: "0.6.4+gloss.4", progress: 40) + ) + ) + XCTAssertFalse( + PDFTranslationWindowController.runtimeAllowsNewBatch( + .stopping(previousProcessIdentifier: 42) + ) + ) + } +} From 7c7b6d4c9ce088ba74375bb88f29bf2e07be56cd Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Thu, 23 Jul 2026 01:23:48 -0700 Subject: [PATCH 4/8] ci: package Gloss 0.8.0 and generate Homebrew casks --- .github/workflows/ci.yml | 81 ++++++++++ .github/workflows/homebrew-cask.yml | 97 ++++++++++++ .github/workflows/release.yml | 223 +++++++++++++++++++++++++++ README.md | 31 ++++ Resources/Info.plist | 4 +- Scripts/generate_homebrew_cask.sh | 89 +++++++++++ Scripts/generate_release_metadata.sh | 160 +++++++++++++++++++ Scripts/lint_changed_swift.sh | 47 ++++++ Scripts/package_release.sh | 35 +++++ Scripts/validate_homebrew_cask.sh | 31 ++++ docs/release-notes/v0.8.0.md | 36 +++++ docs/runtime-distribution.md | 150 ++++++++++++++++++ 12 files changed, 982 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/homebrew-cask.yml create mode 100644 .github/workflows/release.yml create mode 100755 Scripts/generate_homebrew_cask.sh create mode 100755 Scripts/generate_release_metadata.sh create mode 100755 Scripts/lint_changed_swift.sh create mode 100755 Scripts/package_release.sh create mode 100755 Scripts/validate_homebrew_cask.sh create mode 100644 docs/release-notes/v0.8.0.md create mode 100644 docs/runtime-distribution.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6f32e6e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: Swift format and lint + runs-on: macos-15 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Show toolchain + run: swift --version + - name: Lint changed Swift formatting + env: + BASE_REF: ${{ github.event.pull_request.base.sha }} + run: Scripts/lint_changed_swift.sh "${BASE_REF:-HEAD^}" + - name: Validate package manifest + run: swift package describe >/dev/null + - name: Lint release scripts + run: bash -n Scripts/*.sh + - name: Smoke-test release metadata and Homebrew cask + run: | + temporary_directory="$(mktemp -d)" + trap 'rm -rf "$temporary_directory"' EXIT + mkdir -p "$temporary_directory/release" + printf 'arm64 archive' >"$temporary_directory/release/Gloss-macos-arm64.zip" + printf 'x86_64 archive' >"$temporary_directory/release/Gloss-macos-x86_64.zip" + Scripts/generate_release_metadata.sh \ + "$temporary_directory/release/Gloss-macos-arm64.zip" \ + "$temporary_directory/release/Gloss-macos-x86_64.zip" \ + 0.0.0 \ + "$temporary_directory/release" \ + v0.0.0 \ + "${GITHUB_REPOSITORY}" + ( + cd "$temporary_directory/release" + shasum -a 256 --check SHA256SUMS + ) + + test: + name: Swift tests + runs-on: macos-15 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Run tests + run: swift test --parallel + + release-build: + name: Release build (${{ matrix.architecture }}) + strategy: + fail-fast: false + matrix: + include: + - architecture: arm64 + runner: macos-15 + - architecture: x86_64 + runner: macos-15-intel + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Verify runner architecture + run: test "$(uname -m)" = "${{ matrix.architecture }}" + - name: Build release products + run: swift build --configuration release diff --git a/.github/workflows/homebrew-cask.yml b/.github/workflows/homebrew-cask.yml new file mode 100644 index 0000000..8772847 --- /dev/null +++ b/.github/workflows/homebrew-cask.yml @@ -0,0 +1,97 @@ +name: Update Homebrew cask + +on: + workflow_dispatch: + inputs: + release_tag: + description: Published Gloss release tag + required: true + type: string + download_base_url: + description: Optional public HTTPS directory containing both architecture archives + required: false + type: string + +permissions: + contents: write + pull-requests: write + +concurrency: + group: homebrew-cask-${{ inputs.release_tag }} + cancel-in-progress: false + +jobs: + update: + name: Generate and propose cask + runs-on: macos-15 + timeout-minutes: 15 + env: + RELEASE_TAG: ${{ inputs.release_tag }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + - name: Download and verify release archives + run: | + mkdir -p "$RUNNER_TEMP/gloss-release" + gh release download "$RELEASE_TAG" \ + --pattern 'Gloss-macos-*.zip' \ + --pattern SHA256SUMS \ + --dir "$RUNNER_TEMP/gloss-release" + ( + cd "$RUNNER_TEMP/gloss-release" + grep -E ' Gloss-macos-(arm64|x86_64)\.zip$' SHA256SUMS >ARCHIVE_SHA256SUMS + [[ "$(wc -l /dev/null 2>&1; then + git fetch origin "$branch" + git switch -C "$branch" "origin/$branch" + else + git switch -c "$branch" + fi + mkdir -p Casks + cp "$RUNNER_TEMP/gloss.rb" Casks/gloss.rb + if [[ -z "$(git status --porcelain -- Casks/gloss.rb)" ]]; then + echo "Cask is already current." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Casks/gloss.rb + git commit -m "chore: update Homebrew cask to $version" + git push --set-upstream origin "$branch" + if [[ -z "$(gh pr list --head "$branch" --json url --jq '.[0].url')" ]]; then + gh pr create \ + --base main \ + --head "$branch" \ + --title "chore: update Homebrew cask to $version" \ + --body "Generated and checksum-verified from the published $RELEASE_TAG release." + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cf8bbfe --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,223 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + release_tag: + description: Existing tag to package without publishing + required: true + type: string + +permissions: + actions: write + contents: write + +concurrency: + group: release-${{ github.ref_name }}-${{ inputs.release_tag }} + cancel-in-progress: false + +jobs: + build: + name: Build macOS ${{ matrix.architecture }} + strategy: + fail-fast: false + matrix: + include: + - architecture: arm64 + runner: macos-15 + - architecture: x86_64 + runner: macos-15-intel + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 + env: + RELEASE_TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.release_tag }} + HAS_DEVELOPER_ID_CERTIFICATE: ${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 != '' }} + HAS_NOTARY_CREDENTIALS: ${{ secrets.APPLE_NOTARY_PASSWORD != '' }} + steps: + - name: Check out Gloss + uses: actions/checkout@v4 + with: + path: gloss + ref: ${{ github.event_name == 'push' && github.ref || inputs.release_tag }} + - name: Require signing and notarization for published releases + if: github.event_name == 'push' + env: + CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 }} + CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_CERTIFICATE_PASSWORD }} + SIGN_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION }} + KEYCHAIN_PASSWORD: ${{ secrets.RELEASE_KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_NOTARY_APPLE_ID }} + APPLE_NOTARY_PASSWORD: ${{ secrets.APPLE_NOTARY_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + missing=() + [[ -n "$CERTIFICATE_BASE64" ]] || missing+=("DEVELOPER_ID_CERTIFICATE_BASE64") + [[ -n "$CERTIFICATE_PASSWORD" ]] || missing+=("DEVELOPER_ID_CERTIFICATE_PASSWORD") + [[ -n "$SIGN_IDENTITY" ]] || missing+=("DEVELOPER_ID_APPLICATION") + [[ -n "$KEYCHAIN_PASSWORD" ]] || missing+=("RELEASE_KEYCHAIN_PASSWORD") + [[ -n "$APPLE_ID" ]] || missing+=("APPLE_NOTARY_APPLE_ID") + [[ -n "$APPLE_NOTARY_PASSWORD" ]] || missing+=("APPLE_NOTARY_PASSWORD") + [[ -n "$APPLE_TEAM_ID" ]] || missing+=("APPLE_TEAM_ID") + if [[ ${#missing[@]} -ne 0 ]]; then + printf 'Published releases require secret: %s\n' "${missing[@]}" >&2 + exit 1 + fi + - name: Check out browser extensions + uses: actions/checkout@v4 + with: + repository: SunChJ/personal-immersive-translator + ref: 3e9c7c8cb75ce4b08e56a714ee0e4eb7ebaa652e + path: personal-immersive-translator + - name: Validate release version and runner architecture + working-directory: gloss + env: + EXPECTED_ARCHITECTURE: ${{ matrix.architecture }} + run: | + version="${RELEASE_TAG#v}" + plist_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Resources/Info.plist)" + if [[ "$version" != "$plist_version" ]]; then + echo "Tag version $version does not match Info.plist version $plist_version." >&2 + exit 1 + fi + if [[ "$(uname -m)" != "$EXPECTED_ARCHITECTURE" ]]; then + echo "Runner architecture $(uname -m) does not match $EXPECTED_ARCHITECTURE." >&2 + exit 1 + fi + - name: Import Developer ID certificate + if: env.HAS_DEVELOPER_ID_CERTIFICATE == 'true' + env: + CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 }} + CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.RELEASE_KEYCHAIN_PASSWORD }} + SIGN_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION }} + run: | + certificate="$RUNNER_TEMP/developer-id.p12" + keychain="$RUNNER_TEMP/gloss-release.keychain-db" + printf '%s' "$CERTIFICATE_BASE64" | base64 --decode >"$certificate" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain" + security import "$certificate" \ + -k "$keychain" \ + -P "$CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign \ + -T /usr/bin/security + security set-key-partition-list \ + -S apple-tool:,apple: \ + -s \ + -k "$KEYCHAIN_PASSWORD" \ + "$keychain" + security list-keychains -d user -s "$keychain" login.keychain-db + echo "GLOSS_SIGN_IDENTITY=$SIGN_IDENTITY" >>"$GITHUB_ENV" + - name: Build Gloss.app + working-directory: gloss + run: Scripts/build_app.sh + - name: Verify app architecture and Developer ID signature + working-directory: gloss + env: + EXPECTED_ARCHITECTURE: ${{ matrix.architecture }} + run: | + lipo -verify_arch "$EXPECTED_ARCHITECTURE" dist/Gloss.app/Contents/MacOS/Gloss + if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then + codesign --verify --deep --strict --verbose=2 dist/Gloss.app + codesign --display --verbose=4 dist/Gloss.app 2>&1 \ + | grep -F "Authority=Developer ID Application:" + fi + - name: Notarize and staple + if: env.HAS_NOTARY_CREDENTIALS == 'true' + working-directory: gloss + env: + APPLE_ID: ${{ secrets.APPLE_NOTARY_APPLE_ID }} + APPLE_NOTARY_PASSWORD: ${{ secrets.APPLE_NOTARY_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + /usr/bin/ditto -c -k --keepParent dist/Gloss.app "$RUNNER_TEMP/Gloss-notary.zip" + xcrun notarytool submit "$RUNNER_TEMP/Gloss-notary.zip" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_NOTARY_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait + xcrun stapler staple dist/Gloss.app + xcrun stapler validate dist/Gloss.app + - name: Package architecture-specific app + working-directory: gloss + env: + GLOSS_RELEASE_ARCHITECTURE: ${{ matrix.architecture }} + run: Scripts/package_release.sh + - name: Upload architecture artifact + uses: actions/upload-artifact@v4 + with: + name: Gloss-${{ matrix.architecture }} + path: gloss/dist/release/Gloss-macos-${{ matrix.architecture }}.zip + if-no-files-found: error + + assemble: + name: Assemble release metadata + needs: + - build + runs-on: macos-15 + timeout-minutes: 15 + env: + RELEASE_TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.release_tag }} + steps: + - name: Check out Gloss + uses: actions/checkout@v4 + with: + path: gloss + ref: ${{ github.event_name == 'push' && github.ref || inputs.release_tag }} + - name: Download architecture artifacts + uses: actions/download-artifact@v4 + with: + pattern: Gloss-* + path: release-input + merge-multiple: true + - name: Generate checksums, manifest, and Homebrew cask + working-directory: gloss + run: | + version="${RELEASE_TAG#v}" + mkdir -p dist/release + cp ../release-input/Gloss-macos-arm64.zip dist/release/ + cp ../release-input/Gloss-macos-x86_64.zip dist/release/ + Scripts/generate_release_metadata.sh \ + dist/release/Gloss-macos-arm64.zip \ + dist/release/Gloss-macos-x86_64.zip \ + "$version" \ + dist/release \ + "$RELEASE_TAG" \ + "$GITHUB_REPOSITORY" + - name: Upload combined workflow artifact + uses: actions/upload-artifact@v4 + with: + name: Gloss-release-${{ env.RELEASE_TAG }} + path: | + gloss/dist/release/Gloss-macos-arm64.zip + gloss/dist/release/Gloss-macos-x86_64.zip + gloss/dist/release/SHA256SUMS + gloss/dist/release/gloss-release-manifest.json + gloss/dist/release/Casks/gloss.rb + if-no-files-found: error + - name: Publish GitHub release assets + if: github.event_name == 'push' + working-directory: gloss + env: + GH_TOKEN: ${{ github.token }} + run: | + if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release create "$RELEASE_TAG" --verify-tag --generate-notes + fi + gh release upload "$RELEASE_TAG" \ + dist/release/Gloss-macos-arm64.zip \ + dist/release/Gloss-macos-x86_64.zip \ + dist/release/SHA256SUMS \ + dist/release/gloss-release-manifest.json \ + dist/release/Casks/gloss.rb \ + --clobber + - name: Start Homebrew cask update + if: github.event_name == 'push' + working-directory: gloss + env: + GH_TOKEN: ${{ github.token }} + run: gh workflow run homebrew-cask.yml --ref main -f release_tag="$RELEASE_TAG" diff --git a/README.md b/README.md index 72b4136..1477b97 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,37 @@ GLOSS_SIGN_IDENTITY="Apple Development: Your Name (TEAMID)" ./Scripts/build_app. 正式分发时使用 `Developer ID Application` 证书执行同一命令;脚本会自动启用 Hardened Runtime 与可信时间戳。随后仍需用 Apple `notarytool` 公证并对 App 执行 `stapler staple`。 +### BabelDOC runtime 更新 + +Gloss 可以管理来自 `SunChJ/BabelDOC` GitHub Releases 的固定版本 runtime。更新 manifest 使用 +内置 Ed25519 public key 验证 detached signature,runtime archive 再做 SHA-256 校验;签名或 +校验失败不会替换当前版本。安装器当前开放 stable 通道,并支持版本 pin 和一键 rollback; +beta、nightly 会在对应的已签名 release alias 上线后再开放。安装过程通过 staging directory 与 +atomic state file 防止半安装状态。完整 manifest schema、安全边界和发布 secret 见 +[Gloss 与 BabelDOC 发行链路](docs/runtime-distribution.md)。 + +### GitHub Release 与 Homebrew + +推送与 `Resources/Info.plist` 一致的 `v*` tag 会运行 Release workflow,产出 +arm64 与 x86_64 两套 `Gloss.app` zip、`SHA256SUMS`、release manifest 和带 +`on_arm` / `on_intel` 校验的 Homebrew cask。Release 完成后,自动化会在本仓库创建 +`Casks/gloss.rb` 更新 PR;不依赖额外的外部 tap 仓库。 + +当前 Gloss 仓库仍是 private,匿名 Homebrew 安装需要先提供 public GitHub Release 或其他公共 +binary host。公开发行地址就绪后,首次安装以及后续升级为: + +```bash +brew tap sunchj/gloss https://github.com/SunChJ/gloss +brew install --cask sunchj/gloss/gloss +brew update +brew upgrade --cask gloss +``` + +正式 tag Release 必须同时具备 Developer ID 与 Apple 公证 secrets,否则 workflow 会在上传 +public Release 和 Homebrew cask 前 fail closed。没有签名凭据时,手工 workflow 只会生成适合 +内部验证的 ad-hoc artifact。具体变量、私有仓库限制与本地打包命令见 +[发行文档](docs/runtime-distribution.md)。 + ## 代码结构 ```text diff --git a/Resources/Info.plist b/Resources/Info.plist index d16d818..e01e96d 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.7.0 + 0.8.0 CFBundleVersion - 7 + 8 CFBundleDocumentTypes diff --git a/Scripts/generate_homebrew_cask.sh b/Scripts/generate_homebrew_cask.sh new file mode 100755 index 0000000..e7d9ad0 --- /dev/null +++ b/Scripts/generate_homebrew_cask.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 4 || $# -gt 8 ]]; then + echo "Usage: $0 [release-tag] [repository] [arm64-archive] [x86_64-archive]" >&2 + exit 64 +fi + +VERSION="$1" +ARM64_SHA256="$2" +X86_64_SHA256="$3" +OUTPUT_PATH="$4" +RELEASE_TAG="${5:-v$VERSION}" +REPOSITORY="${6:-${GITHUB_REPOSITORY:-SunChJ/gloss}}" +ARM64_ARCHIVE="${7:-Gloss-macos-arm64.zip}" +X86_64_ARCHIVE="${8:-Gloss-macos-x86_64.zip}" +DOWNLOAD_BASE_URL="${GLOSS_CASK_DOWNLOAD_BASE_URL:-}" +if [[ -z "$DOWNLOAD_BASE_URL" ]]; then + DOWNLOAD_BASE_URL="https://github.com/$REPOSITORY/releases/download/$RELEASE_TAG" +fi + +if [[ ! "$VERSION" =~ ^[0-9]+(\.[0-9]+){2}([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid cask version: $VERSION" >&2 + exit 65 +fi +for checksum in "$ARM64_SHA256" "$X86_64_SHA256"; do + if [[ ! "$checksum" =~ ^[0-9a-fA-F]{64}$ ]]; then + echo "Invalid cask SHA-256: $checksum" >&2 + exit 65 + fi +done +if [[ ! "$REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "Invalid GitHub repository: $REPOSITORY" >&2 + exit 65 +fi +if [[ ! "$RELEASE_TAG" =~ ^v[0-9A-Za-z][0-9A-Za-z.+_-]*$ ]]; then + echo "Invalid release tag: $RELEASE_TAG" >&2 + exit 65 +fi +for archive in "$ARM64_ARCHIVE" "$X86_64_ARCHIVE"; do + if [[ "$archive" == *"/"* || -z "$archive" ]]; then + echo "Invalid archive name: $archive" >&2 + exit 65 + fi +done +DOWNLOAD_URL_PATTERN='^https://[^[:space:]"\\]+$' +if [[ ! "$DOWNLOAD_BASE_URL" =~ $DOWNLOAD_URL_PATTERN ]]; then + echo "Invalid HTTPS download base URL: $DOWNLOAD_BASE_URL" >&2 + exit 65 +fi + +ARM64_SHA256="$(printf '%s' "$ARM64_SHA256" | tr '[:upper:]' '[:lower:]')" +X86_64_SHA256="$(printf '%s' "$X86_64_SHA256" | tr '[:upper:]' '[:lower:]')" +DOWNLOAD_BASE_URL="${DOWNLOAD_BASE_URL%/}" +mkdir -p "$(dirname "$OUTPUT_PATH")" +cat >"$OUTPUT_PATH" <= :sonoma" + + app "Gloss.app" + + uninstall quit: "com.samsoncj.gloss" + + zap trash: [ + "~/Library/Application Support/Gloss", + "~/Library/Caches/Gloss", + "~/Library/Logs/Gloss", + "~/Library/Preferences/com.samsoncj.gloss.plist", + ] +end +RUBY + +echo "$OUTPUT_PATH" diff --git a/Scripts/generate_release_metadata.sh b/Scripts/generate_release_metadata.sh new file mode 100755 index 0000000..fcda7d0 --- /dev/null +++ b/Scripts/generate_release_metadata.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 5 || $# -gt 7 ]]; then + echo "Usage: $0 [repository] [asset-base-url]" >&2 + exit 64 +fi + +ARM64_ARCHIVE="$1" +X86_64_ARCHIVE="$2" +VERSION="$3" +OUTPUT_DIRECTORY="$4" +RELEASE_TAG="$5" +REPOSITORY="${6:-${GITHUB_REPOSITORY:-SunChJ/gloss}}" +ASSET_BASE_URL="${7:-https://github.com/$REPOSITORY/releases/download/$RELEASE_TAG}" + +for archive in "$ARM64_ARCHIVE" "$X86_64_ARCHIVE"; do + if [[ ! -f "$archive" ]]; then + echo "Release archive not found: $archive" >&2 + exit 66 + fi +done +if [[ ! "$VERSION" =~ ^[0-9]+(\.[0-9]+){2}([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid release version: $VERSION" >&2 + exit 65 +fi +if [[ ! "$REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "Invalid GitHub repository: $REPOSITORY" >&2 + exit 65 +fi +if [[ ! "$RELEASE_TAG" =~ ^v[0-9A-Za-z][0-9A-Za-z.+_-]*$ ]]; then + echo "Invalid release tag: $RELEASE_TAG" >&2 + exit 65 +fi +ASSET_URL_PATTERN='^https://[^[:space:]"\\]+$' +if [[ ! "$ASSET_BASE_URL" =~ $ASSET_URL_PATTERN ]]; then + echo "Invalid HTTPS asset base URL: $ASSET_BASE_URL" >&2 + exit 65 +fi + +mkdir -p "$OUTPUT_DIRECTORY" +ARM64_NAME="$(basename "$ARM64_ARCHIVE")" +X86_64_NAME="$(basename "$X86_64_ARCHIVE")" +if [[ "$ARM64_NAME" != "Gloss-macos-arm64.zip" ]]; then + echo "Unexpected arm64 archive name: $ARM64_NAME" >&2 + exit 65 +fi +if [[ "$X86_64_NAME" != "Gloss-macos-x86_64.zip" ]]; then + echo "Unexpected x86_64 archive name: $X86_64_NAME" >&2 + exit 65 +fi + +OUTPUT_DIRECTORY_ABSOLUTE="$(cd "$OUTPUT_DIRECTORY" && pwd -P)" +ARM64_SOURCE_ABSOLUTE="$(cd "$(dirname "$ARM64_ARCHIVE")" && pwd -P)/$ARM64_NAME" +X86_64_SOURCE_ABSOLUTE="$(cd "$(dirname "$X86_64_ARCHIVE")" && pwd -P)/$X86_64_NAME" +ARM64_OUTPUT="$OUTPUT_DIRECTORY_ABSOLUTE/$ARM64_NAME" +X86_64_OUTPUT="$OUTPUT_DIRECTORY_ABSOLUTE/$X86_64_NAME" +if [[ "$ARM64_SOURCE_ABSOLUTE" != "$ARM64_OUTPUT" ]]; then + cp "$ARM64_SOURCE_ABSOLUTE" "$ARM64_OUTPUT" +fi +if [[ "$X86_64_SOURCE_ABSOLUTE" != "$X86_64_OUTPUT" ]]; then + cp "$X86_64_SOURCE_ABSOLUTE" "$X86_64_OUTPUT" +fi + +ARM64_SHA256="$(shasum -a 256 "$ARM64_OUTPUT" | awk '{print $1}')" +X86_64_SHA256="$(shasum -a 256 "$X86_64_OUTPUT" | awk '{print $1}')" +ARM64_SIZE="$(stat -f '%z' "$ARM64_OUTPUT")" +X86_64_SIZE="$(stat -f '%z' "$X86_64_OUTPUT")" +PUBLISHED_AT="$( + if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then + date -u -r "$SOURCE_DATE_EPOCH" '+%Y-%m-%dT%H:%M:%SZ' + else + date -u '+%Y-%m-%dT%H:%M:%SZ' + fi +)" +MANIFEST_PATH="$OUTPUT_DIRECTORY/gloss-release-manifest.json" +CHECKSUMS_PATH="$OUTPUT_DIRECTORY/SHA256SUMS" + +ASSET_BASE_URL="${ASSET_BASE_URL%/}" +python3 - \ + "$MANIFEST_PATH" \ + "$VERSION" \ + "$RELEASE_TAG" \ + "$PUBLISHED_AT" \ + "$ASSET_BASE_URL" \ + "$ARM64_NAME" \ + "$ARM64_SHA256" \ + "$ARM64_SIZE" \ + "$X86_64_NAME" \ + "$X86_64_SHA256" \ + "$X86_64_SIZE" <<'PY' +import json +import pathlib +import sys + +( + output, + version, + release_tag, + published_at, + asset_base_url, + arm64_name, + arm64_sha256, + arm64_size, + x86_64_name, + x86_64_sha256, + x86_64_size, +) = sys.argv[1:] +manifest = { + "schemaVersion": 1, + "channel": "stable", + "version": version, + "releaseTag": release_tag, + "publishedAt": published_at, + "minimumMacOSVersion": "14.0", + "assets": [ + { + "operatingSystem": "macos", + "architecture": "arm64", + "url": f"{asset_base_url}/{arm64_name}", + "sha256": arm64_sha256, + "size": int(arm64_size), + }, + { + "operatingSystem": "macos", + "architecture": "x86_64", + "url": f"{asset_base_url}/{x86_64_name}", + "sha256": x86_64_sha256, + "size": int(x86_64_size), + }, + ], +} +pathlib.Path(output).write_text( + json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", +) +PY + +MANIFEST_SHA256="$(shasum -a 256 "$MANIFEST_PATH" | awk '{print $1}')" +{ + printf '%s %s\n' "$ARM64_SHA256" "$ARM64_NAME" + printf '%s %s\n' "$X86_64_SHA256" "$X86_64_NAME" + printf '%s %s\n' "$MANIFEST_SHA256" "$(basename "$MANIFEST_PATH")" +} >"$CHECKSUMS_PATH" + +GLOSS_CASK_DOWNLOAD_BASE_URL="$ASSET_BASE_URL" \ + "$(dirname "$0")/generate_homebrew_cask.sh" \ + "$VERSION" \ + "$ARM64_SHA256" \ + "$X86_64_SHA256" \ + "$OUTPUT_DIRECTORY/Casks/gloss.rb" \ + "$RELEASE_TAG" \ + "$REPOSITORY" \ + "$ARM64_NAME" \ + "$X86_64_NAME" +"$(dirname "$0")/validate_homebrew_cask.sh" \ + "$OUTPUT_DIRECTORY/Casks/gloss.rb" + +echo "$MANIFEST_PATH" +echo "$CHECKSUMS_PATH" diff --git a/Scripts/lint_changed_swift.sh b/Scripts/lint_changed_swift.sh new file mode 100755 index 0000000..da3b158 --- /dev/null +++ b/Scripts/lint_changed_swift.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +if [[ $# -gt 1 ]]; then + echo "Usage: $0 [base-revision]" >&2 + exit 64 +fi + +if [[ $# -eq 1 ]]; then + BASE_REVISION="$1" +elif git rev-parse --verify HEAD^ >/dev/null 2>&1; then + BASE_REVISION="HEAD^" +else + BASE_REVISION="" +fi + +SWIFT_FILES=() +if [[ -n "$BASE_REVISION" ]]; then + while IFS= read -r path; do + [[ -n "$path" && -f "$path" ]] && SWIFT_FILES+=("$path") + done < <( + git diff \ + --name-only \ + --diff-filter=ACMR \ + "$BASE_REVISION...HEAD" \ + -- '*.swift' + ) + if ! git diff --quiet "$BASE_REVISION...HEAD" -- Package.swift; then + SWIFT_FILES+=("Package.swift") + fi +else + while IFS= read -r path; do + SWIFT_FILES+=("$path") + done < <(git ls-files '*.swift') + SWIFT_FILES+=("Package.swift") +fi + +if [[ ${#SWIFT_FILES[@]} -eq 0 ]]; then + echo "No changed Swift files to lint." + exit 0 +fi + +printf 'Linting %s\n' "${SWIFT_FILES[@]}" +swift format lint --strict "${SWIFT_FILES[@]}" diff --git a/Scripts/package_release.sh b/Scripts/package_release.sh new file mode 100755 index 0000000..e095c7e --- /dev/null +++ b/Scripts/package_release.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +APP_PATH="${GLOSS_APP_PATH:-$ROOT_DIR/dist/Gloss.app}" +OUTPUT_DIRECTORY="${GLOSS_RELEASE_OUTPUT:-$ROOT_DIR/dist/release}" + +if [[ ! -d "$APP_PATH" ]]; then + echo "Gloss.app not found: $APP_PATH" >&2 + echo "Run Scripts/build_app.sh first." >&2 + exit 66 +fi + +VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_PATH/Contents/Info.plist")" +ARCHITECTURE="${GLOSS_RELEASE_ARCHITECTURE:-$(uname -m)}" +case "$ARCHITECTURE" in + arm64|aarch64) + ARCHITECTURE="arm64" + ;; + x86_64|amd64) + ARCHITECTURE="x86_64" + ;; + *) + echo "Unsupported release architecture: $ARCHITECTURE" >&2 + exit 65 + ;; +esac +ARCHIVE_PATH="$OUTPUT_DIRECTORY/Gloss-macos-$ARCHITECTURE.zip" + +mkdir -p "$OUTPUT_DIRECTORY" +rm -f "$ARCHIVE_PATH" +codesign --verify --deep --strict "$APP_PATH" +/usr/bin/ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ARCHIVE_PATH" +printf '%s\n' "$VERSION" >"$OUTPUT_DIRECTORY/version.txt" +echo "$ARCHIVE_PATH" diff --git a/Scripts/validate_homebrew_cask.sh b/Scripts/validate_homebrew_cask.sh new file mode 100755 index 0000000..cdd4eab --- /dev/null +++ b/Scripts/validate_homebrew_cask.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " >&2 + exit 64 +fi + +CASK_PATH="$1" +if [[ ! -f "$CASK_PATH" ]]; then + echo "Cask not found: $CASK_PATH" >&2 + exit 66 +fi + +ruby -c "$CASK_PATH" +ruby - "$CASK_PATH" <<'RUBY' +path = ARGV.fetch(0) +content = File.read(path, encoding: "UTF-8") +required = [ + /^cask "gloss" do$/, + /^ version "[^"]+"$/, + /^ on_arm do$/, + /^ on_intel do$/, + /^ sha256 "[0-9a-f]{64}"$/, + %r{^ url "https://[^"]+/Gloss-macos-arm64\.zip"$}, + %r{^ url "https://[^"]+/Gloss-macos-x86_64\.zip"$}, + /^ app "Gloss\.app"$/, +] +missing = required.reject { |pattern| content.match?(pattern) } +abort "Cask is missing required declarations: #{missing.join(", ")}" unless missing.empty? +RUBY diff --git a/docs/release-notes/v0.8.0.md b/docs/release-notes/v0.8.0.md new file mode 100644 index 0000000..45bff78 --- /dev/null +++ b/docs/release-notes/v0.8.0.md @@ -0,0 +1,36 @@ +# Gloss 0.8.0 + +Gloss 0.8.0 completes the managed PDF runtime boundary introduced by the +native batch-translation workspace. + +## PDF runtime + +- Installs the Gloss-maintained BabelDOC runtime from a signed release + manifest and verifies both the manifest signature and archive SHA-256 before + activation. +- Keeps one authenticated, loopback-only PDF executor alive while the PDF + workspace is open. +- Reconnects to the same execution after an event-stream interruption and + supports targeted cancellation without affecting a newer task. +- Verifies persisted process identity before terminating a stale executor, + then performs bounded graceful, TERM, and KILL cleanup when required. +- Preserves a previous verified runtime for one-click rollback if an update + cannot start. + +## Dashboard and delivery + +- Shows the installed runtime version, service endpoint, PID, active document, + progress, update availability, errors, and rollback state in the Gloss + Dashboard. +- Adds explicit install, update, start, reconnect, cancel, retry, and rollback + actions. +- Adds macOS CI, reproducible release metadata, and a generated Homebrew cask + for release artifacts. + +## Compatibility + +The managed runtime requires the `executor.http.v1` and +`executor.events.ndjson.v1` capabilities. Older external BabelDOC +installations remain available only as an explicit transitional fallback when +the executor protocol is unsupported; runtime failures do not silently fall +back to an unmanaged process. diff --git a/docs/runtime-distribution.md b/docs/runtime-distribution.md new file mode 100644 index 0000000..f7ab80f --- /dev/null +++ b/docs/runtime-distribution.md @@ -0,0 +1,150 @@ +# Gloss 与 BabelDOC 发行链路 + +Gloss 将自维护的 BabelDOC 作为独立 runtime 更新,不再要求用户从系统 `PATH` 安装任意版本。 +App 只接受 `SunChJ/BabelDOC` Release 中由 Gloss 发布密钥签名的 manifest。 + +## BabelDOC runtime manifest + +默认更新地址: + +| 通道 | Manifest | +| --- | --- | +| stable | `https://github.com/SunChJ/BabelDOC/releases/latest/download/gloss-runtime-manifest.json` | + +本轮发行只开放 `stable`。`beta` 与 `nightly` 枚举值为后续兼容而保留,但不会出现在 App +可选通道中,调用 `setChannel` 也会在对应 release alias 与签名产物上线前明确拒绝。 + +每个 manifest 必须有相邻的 detached Ed25519 签名 +`gloss-runtime-manifest.json.sig`。签名覆盖 manifest 文件的原始 bytes;签名文件可以是 64-byte raw +signature 或其 Base64 文本。Gloss 内置并固定 raw 32-byte public key: + +```text +0lgbX+CkmBjf4BnH9JO66I7Krd1DYM8lTOjIt+7zWEE= +``` + +私钥只存放在 BabelDOC 仓库的 GitHub Actions secret,不能提交到任一仓库。签名或 SHA-256 +不匹配时,更新会 fail closed,现用 runtime 保持不变。 + +Manifest schema v1 示例: + +```json +{ + "schemaVersion": 1, + "channel": "stable", + "version": "0.6.4+gloss.3", + "releaseTag": "v0.6.4-gloss.3", + "publishedAt": "2026-07-22T00:00:00Z", + "minimumGlossVersion": "0.8.0", + "releaseNotesURL": "https://github.com/SunChJ/BabelDOC/releases/tag/v0.6.4-gloss.3", + "assets": [ + { + "operatingSystem": "macos", + "architecture": "arm64", + "url": "https://github.com/SunChJ/BabelDOC/releases/download/v0.6.4-gloss.3/gloss-babeldoc-macos-arm64.tar.gz", + "sha256": "64-character-lowercase-hex", + "size": 123456, + "archiveFormat": "tar.gz", + "executablePath": "gloss-babeldoc" + } + ] +} +``` + +Runtime archive 的入口必须命名为 `gloss-babeldoc`。安装器在解包前拒绝绝对路径、`..` 和 +Windows drive 路径,在激活前拒绝符号链接/硬链接并验证可执行权限。下载与解包发生在相同 +filesystem 的 staging 目录,完整校验后才移动到版本目录;`state.json` 使用 atomic replace, +因此下载中断或进程崩溃不会切换 active runtime。 + +## App 内的状态与控制 + +`BabelDOCRuntimeManager` 是 actor,并暴露: + +- `snapshot()` / `snapshots()`:当前、上一版、可用版本、更新通道、pin、操作状态与错误。 +- `currentVersion` / `currentExecutableURL`:由 snapshot 提供给 executor 启动逻辑。 +- `checkForUpdates()` / `update()`:验证 detached signature 后检查或安装新版本。 +- `install(_:)`:显式安装一个已经验证策略的 manifest。 +- `pin(version:)`:固定 runtime 版本;其他版本的 manifest 会被拒绝。 +- `setChannel(_:)`:切换到已发布通道并清除旧 pin;当前仅允许 stable。 +- `rollback()`:原子交换 current/previous,保留一次快速回滚能力。 + +默认数据目录是: + +```text +~/Library/Application Support/Gloss/BabelDOCRuntime/ +├── state.json +└── versions/ + ├── 0.6.4+gloss.2-/ + └── 0.6.4+gloss.3-/ +``` + +测试和受控企业分发可以向 manager 注入 manifest URL、transport 和 Ed25519 public key,不需要 +访问公网,也不会降低 production 默认校验。 + +## Gloss Release + +`.github/workflows/release.yml` 在 `v*` tag 上: + +1. 分别在 `macos-15` arm64 和 `macos-15-intel` x86_64 runner 构建 `Gloss.app`;tag 发行强制 + Developer ID 签名、公证与 stapling。 +2. 生成 `Gloss-macos-arm64.zip`、`Gloss-macos-x86_64.zip`、`SHA256SUMS` 和包含两个 + architecture asset 的 `gloss-release-manifest.json`。 +3. 生成并校验使用 `on_arm` / `on_intel` URL 与 SHA-256 的 `Casks/gloss.rb`。 +4. 上传 Actions artifact 与 GitHub Release assets。 +5. 启动 Homebrew cask 更新 workflow。 + +完整 App 会同时检出并构建浏览器扩展仓库。正式 tag 发行必须配置以下签名和公证 secrets: + +| Secret | 用途 | +| --- | --- | +| `DEVELOPER_ID_CERTIFICATE_BASE64` | Base64 编码的 `.p12` | +| `DEVELOPER_ID_CERTIFICATE_PASSWORD` | `.p12` 密码 | +| `DEVELOPER_ID_APPLICATION` | `Developer ID Application: ...` identity | +| `RELEASE_KEYCHAIN_PASSWORD` | 临时 CI keychain 密码 | +| `APPLE_NOTARY_APPLE_ID` | 公证 Apple ID | +| `APPLE_NOTARY_PASSWORD` | App-specific password | +| `APPLE_TEAM_ID` | Apple Developer Team ID | + +tag workflow 在任一签名或公证 secret 缺失时 fail closed,不会上传 public GitHub Release 或 +启动 Homebrew 更新。手工 `workflow_dispatch` 可以在没有 secrets 时生成仅供内部验证的 +ad-hoc artifact,但不会发布。 + +本地生成发行元数据: + +```bash +Scripts/build_app.sh +GLOSS_RELEASE_ARCHITECTURE="$(uname -m)" Scripts/package_release.sh + +# 收集在两类 Mac 上生成的 zip 后: +Scripts/generate_release_metadata.sh \ + dist/release/Gloss-macos-arm64.zip \ + dist/release/Gloss-macos-x86_64.zip \ + 0.8.0 \ + dist/release \ + v0.8.0 +``` + +## Homebrew 更新 + +`homebrew-cask.yml` 从已发布 Release 重新下载两种架构的 app zip,先按 `SHA256SUMS` 校验, +再生成 `Casks/gloss.rb` 并向本仓库 `main` 提交 PR。它不依赖尚不存在的外部 tap,也不会绕过 +branch protection。仓库需要启用 GitHub Actions 的“Allow GitHub Actions to create and +approve pull requests”;若策略不允许,workflow artifact 中仍会保留已经校验的 cask,维护者 +可以手工提交。 + +当前 `SunChJ/gloss` 是 private repository,因此匿名 Homebrew 安装尚未闭环:GitHub private +Release 的 app zip 不能作为公共 cask 下载地址。要向外部分发,必须先将仓库和 binary +Release 设为 public,或把两种架构的 zip 发布到稳定的公共 HTTPS host 并让 cask generator 使用 +`GLOSS_CASK_DOWNLOAD_BASE_URL=https://downloads.example.com/gloss/v0.8.0` 指向该目录。手工 +运行 Homebrew workflow 时也可以填写 `download_base_url`。完成其中一项后,本仓库才能作为 +自定义 tap: + +```bash +brew tap sunchj/gloss https://github.com/SunChJ/gloss +brew install --cask sunchj/gloss/gloss +brew update +brew upgrade --cask gloss +``` + +每次 Gloss Release 都会自动生成并发起 cask 更新;失败时也可从 Actions 手工运行 +“Update Homebrew cask”并传入已经发布的 tag。这个自动化完成的是可发布 cask 的生成与校验, +不等同于当前 private repository 已经提供匿名 Homebrew 更新。 From b0a5f7c57cec0e54b64fd7728f123ac310698290 Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Thu, 23 Jul 2026 02:11:45 -0700 Subject: [PATCH 5/8] release: publish Homebrew assets through public repos --- .github/workflows/ci.yml | 8 +- .github/workflows/homebrew-cask.yml | 97 ------------------ .github/workflows/release.yml | 144 ++++++++++++--------------- Scripts/build_app.sh | 10 +- Scripts/generate_homebrew_cask.sh | 71 ++++++++++++- Scripts/generate_release_metadata.sh | 6 +- Scripts/validate_homebrew_cask.sh | 40 ++++++++ 7 files changed, 180 insertions(+), 196 deletions(-) delete mode 100644 .github/workflows/homebrew-cask.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f32e6e..27531f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,13 @@ jobs: 0.0.0 \ "$temporary_directory/release" \ v0.0.0 \ - "${GITHUB_REPOSITORY}" + SunChJ/gloss-releases + grep -F \ + 'https://github.com/SunChJ/gloss-releases/releases/download/v0.0.0/' \ + "$temporary_directory/release/gloss-release-manifest.json" + grep -F \ + 'https://github.com/SunChJ/gloss-releases/releases/download/v0.0.0/' \ + "$temporary_directory/release/Casks/gloss.rb" ( cd "$temporary_directory/release" shasum -a 256 --check SHA256SUMS diff --git a/.github/workflows/homebrew-cask.yml b/.github/workflows/homebrew-cask.yml deleted file mode 100644 index 8772847..0000000 --- a/.github/workflows/homebrew-cask.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: Update Homebrew cask - -on: - workflow_dispatch: - inputs: - release_tag: - description: Published Gloss release tag - required: true - type: string - download_base_url: - description: Optional public HTTPS directory containing both architecture archives - required: false - type: string - -permissions: - contents: write - pull-requests: write - -concurrency: - group: homebrew-cask-${{ inputs.release_tag }} - cancel-in-progress: false - -jobs: - update: - name: Generate and propose cask - runs-on: macos-15 - timeout-minutes: 15 - env: - RELEASE_TAG: ${{ inputs.release_tag }} - GH_TOKEN: ${{ github.token }} - steps: - - uses: actions/checkout@v4 - with: - ref: main - fetch-depth: 0 - - name: Download and verify release archives - run: | - mkdir -p "$RUNNER_TEMP/gloss-release" - gh release download "$RELEASE_TAG" \ - --pattern 'Gloss-macos-*.zip' \ - --pattern SHA256SUMS \ - --dir "$RUNNER_TEMP/gloss-release" - ( - cd "$RUNNER_TEMP/gloss-release" - grep -E ' Gloss-macos-(arm64|x86_64)\.zip$' SHA256SUMS >ARCHIVE_SHA256SUMS - [[ "$(wc -l /dev/null 2>&1; then - git fetch origin "$branch" - git switch -C "$branch" "origin/$branch" - else - git switch -c "$branch" - fi - mkdir -p Casks - cp "$RUNNER_TEMP/gloss.rb" Casks/gloss.rb - if [[ -z "$(git status --porcelain -- Casks/gloss.rb)" ]]; then - echo "Cask is already current." - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Casks/gloss.rb - git commit -m "chore: update Homebrew cask to $version" - git push --set-upstream origin "$branch" - if [[ -z "$(gh pr list --head "$branch" --json url --jq '.[0].url')" ]]; then - gh pr create \ - --base main \ - --head "$branch" \ - --title "chore: update Homebrew cask to $version" \ - --body "Generated and checksum-verified from the published $RELEASE_TAG release." - fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf8bbfe..5d73064 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,16 +12,42 @@ on: type: string permissions: - actions: write - contents: write + contents: read + +env: + GLOSS_RELEASE_REPOSITORY: SunChJ/gloss-releases + GLOSS_HOMEBREW_TAP_REPOSITORY: SunChJ/homebrew-tap + GLOSS_HOMEBREW_WORKFLOW: update-cask.yml concurrency: group: release-${{ github.ref_name }}-${{ inputs.release_tag }} cancel-in-progress: false jobs: + preflight: + name: Validate publication credentials + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require release credentials + env: + DISTRIBUTION_TOKEN: ${{ secrets.GLOSS_DISTRIBUTION_TOKEN }} + EXTENSION_TOKEN: ${{ secrets.GLOSS_EXTENSION_TOKEN }} + run: | + missing=() + [[ -n "$EXTENSION_TOKEN" ]] || missing+=("GLOSS_EXTENSION_TOKEN") + if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then + [[ -n "$DISTRIBUTION_TOKEN" ]] || missing+=("GLOSS_DISTRIBUTION_TOKEN") + fi + if [[ ${#missing[@]} -ne 0 ]]; then + printf 'Release workflow requires secret: %s\n' "${missing[@]}" >&2 + exit 1 + fi + build: name: Build macOS ${{ matrix.architecture }} + needs: + - preflight strategy: fail-fast: false matrix: @@ -34,43 +60,21 @@ jobs: timeout-minutes: 90 env: RELEASE_TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.release_tag }} - HAS_DEVELOPER_ID_CERTIFICATE: ${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 != '' }} - HAS_NOTARY_CREDENTIALS: ${{ secrets.APPLE_NOTARY_PASSWORD != '' }} + GLOSS_SIGN_IDENTITY: "-" steps: - name: Check out Gloss uses: actions/checkout@v4 with: path: gloss ref: ${{ github.event_name == 'push' && github.ref || inputs.release_tag }} - - name: Require signing and notarization for published releases - if: github.event_name == 'push' - env: - CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 }} - CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_CERTIFICATE_PASSWORD }} - SIGN_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION }} - KEYCHAIN_PASSWORD: ${{ secrets.RELEASE_KEYCHAIN_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_NOTARY_APPLE_ID }} - APPLE_NOTARY_PASSWORD: ${{ secrets.APPLE_NOTARY_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: | - missing=() - [[ -n "$CERTIFICATE_BASE64" ]] || missing+=("DEVELOPER_ID_CERTIFICATE_BASE64") - [[ -n "$CERTIFICATE_PASSWORD" ]] || missing+=("DEVELOPER_ID_CERTIFICATE_PASSWORD") - [[ -n "$SIGN_IDENTITY" ]] || missing+=("DEVELOPER_ID_APPLICATION") - [[ -n "$KEYCHAIN_PASSWORD" ]] || missing+=("RELEASE_KEYCHAIN_PASSWORD") - [[ -n "$APPLE_ID" ]] || missing+=("APPLE_NOTARY_APPLE_ID") - [[ -n "$APPLE_NOTARY_PASSWORD" ]] || missing+=("APPLE_NOTARY_PASSWORD") - [[ -n "$APPLE_TEAM_ID" ]] || missing+=("APPLE_TEAM_ID") - if [[ ${#missing[@]} -ne 0 ]]; then - printf 'Published releases require secret: %s\n' "${missing[@]}" >&2 - exit 1 - fi - name: Check out browser extensions uses: actions/checkout@v4 with: repository: SunChJ/personal-immersive-translator ref: 3e9c7c8cb75ce4b08e56a714ee0e4eb7ebaa652e path: personal-immersive-translator + token: ${{ secrets.GLOSS_EXTENSION_TOKEN }} + persist-credentials: false - name: Validate release version and runner architecture working-directory: gloss env: @@ -86,62 +90,18 @@ jobs: echo "Runner architecture $(uname -m) does not match $EXPECTED_ARCHITECTURE." >&2 exit 1 fi - - name: Import Developer ID certificate - if: env.HAS_DEVELOPER_ID_CERTIFICATE == 'true' - env: - CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 }} - CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_CERTIFICATE_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.RELEASE_KEYCHAIN_PASSWORD }} - SIGN_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION }} - run: | - certificate="$RUNNER_TEMP/developer-id.p12" - keychain="$RUNNER_TEMP/gloss-release.keychain-db" - printf '%s' "$CERTIFICATE_BASE64" | base64 --decode >"$certificate" - security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain" - security set-keychain-settings -lut 21600 "$keychain" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain" - security import "$certificate" \ - -k "$keychain" \ - -P "$CERTIFICATE_PASSWORD" \ - -T /usr/bin/codesign \ - -T /usr/bin/security - security set-key-partition-list \ - -S apple-tool:,apple: \ - -s \ - -k "$KEYCHAIN_PASSWORD" \ - "$keychain" - security list-keychains -d user -s "$keychain" login.keychain-db - echo "GLOSS_SIGN_IDENTITY=$SIGN_IDENTITY" >>"$GITHUB_ENV" - name: Build Gloss.app working-directory: gloss run: Scripts/build_app.sh - - name: Verify app architecture and Developer ID signature + - name: Verify app architecture and ad-hoc signature working-directory: gloss env: EXPECTED_ARCHITECTURE: ${{ matrix.architecture }} run: | lipo -verify_arch "$EXPECTED_ARCHITECTURE" dist/Gloss.app/Contents/MacOS/Gloss - if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then - codesign --verify --deep --strict --verbose=2 dist/Gloss.app - codesign --display --verbose=4 dist/Gloss.app 2>&1 \ - | grep -F "Authority=Developer ID Application:" - fi - - name: Notarize and staple - if: env.HAS_NOTARY_CREDENTIALS == 'true' - working-directory: gloss - env: - APPLE_ID: ${{ secrets.APPLE_NOTARY_APPLE_ID }} - APPLE_NOTARY_PASSWORD: ${{ secrets.APPLE_NOTARY_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: | - /usr/bin/ditto -c -k --keepParent dist/Gloss.app "$RUNNER_TEMP/Gloss-notary.zip" - xcrun notarytool submit "$RUNNER_TEMP/Gloss-notary.zip" \ - --apple-id "$APPLE_ID" \ - --password "$APPLE_NOTARY_PASSWORD" \ - --team-id "$APPLE_TEAM_ID" \ - --wait - xcrun stapler staple dist/Gloss.app - xcrun stapler validate dist/Gloss.app + codesign --verify --deep --strict --verbose=2 dist/Gloss.app + codesign --display --verbose=4 dist/Gloss.app 2>&1 \ + | grep -F "Signature=adhoc" - name: Package architecture-specific app working-directory: gloss env: @@ -187,7 +147,7 @@ jobs: "$version" \ dist/release \ "$RELEASE_TAG" \ - "$GITHUB_REPOSITORY" + "$GLOSS_RELEASE_REPOSITORY" - name: Upload combined workflow artifact uses: actions/upload-artifact@v4 with: @@ -203,12 +163,28 @@ jobs: if: github.event_name == 'push' working-directory: gloss env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.GLOSS_DISTRIBUTION_TOKEN }} run: | - if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - gh release create "$RELEASE_TAG" --verify-tag --generate-notes + version="${RELEASE_TAG#v}" + if ! gh release view "$RELEASE_TAG" \ + --repo "$GLOSS_RELEASE_REPOSITORY" >/dev/null 2>&1; then + notes_file="docs/release-notes/v$version.md" + if [[ -f "$notes_file" ]]; then + gh release create "$RELEASE_TAG" \ + --repo "$GLOSS_RELEASE_REPOSITORY" \ + --target main \ + --title "Gloss $version" \ + --notes-file "$notes_file" + else + gh release create "$RELEASE_TAG" \ + --repo "$GLOSS_RELEASE_REPOSITORY" \ + --target main \ + --title "Gloss $version" \ + --notes "Checksum-pinned, ad-hoc signed macOS release of Gloss $version." + fi fi gh release upload "$RELEASE_TAG" \ + --repo "$GLOSS_RELEASE_REPOSITORY" \ dist/release/Gloss-macos-arm64.zip \ dist/release/Gloss-macos-x86_64.zip \ dist/release/SHA256SUMS \ @@ -217,7 +193,11 @@ jobs: --clobber - name: Start Homebrew cask update if: github.event_name == 'push' - working-directory: gloss env: - GH_TOKEN: ${{ github.token }} - run: gh workflow run homebrew-cask.yml --ref main -f release_tag="$RELEASE_TAG" + GH_TOKEN: ${{ secrets.GLOSS_DISTRIBUTION_TOKEN }} + run: | + gh workflow run "$GLOSS_HOMEBREW_WORKFLOW" \ + --repo "$GLOSS_HOMEBREW_TAP_REPOSITORY" \ + --ref main \ + -f release_tag="$RELEASE_TAG" \ + -f release_repository="$GLOSS_RELEASE_REPOSITORY" diff --git a/Scripts/build_app.sh b/Scripts/build_app.sh index 45c227b..647292d 100755 --- a/Scripts/build_app.sh +++ b/Scripts/build_app.sh @@ -40,15 +40,7 @@ case "$CODEX_RUNTIME_MODE" in exit 1 ;; esac -SIGN_IDENTITY="${GLOSS_SIGN_IDENTITY:-}" -if [[ -z "$SIGN_IDENTITY" ]] && command -v security >/dev/null 2>&1; then - SIGN_IDENTITY="$( - security find-identity -v -p codesigning \ - | sed -nE 's/.*"(Apple Development: [^"]+)".*/\1/p' \ - | sed -n '1p' - )" -fi -SIGN_IDENTITY="${SIGN_IDENTITY:--}" +SIGN_IDENTITY="${GLOSS_SIGN_IDENTITY:--}" if [[ ! -x "$PLUGIN_DIR/node_modules/.bin/wxt" ]]; then npm --prefix "$PLUGIN_DIR" ci diff --git a/Scripts/generate_homebrew_cask.sh b/Scripts/generate_homebrew_cask.sh index e7d9ad0..3cb94e8 100755 --- a/Scripts/generate_homebrew_cask.sh +++ b/Scripts/generate_homebrew_cask.sh @@ -11,7 +11,7 @@ ARM64_SHA256="$2" X86_64_SHA256="$3" OUTPUT_PATH="$4" RELEASE_TAG="${5:-v$VERSION}" -REPOSITORY="${6:-${GITHUB_REPOSITORY:-SunChJ/gloss}}" +REPOSITORY="${6:-${GLOSS_RELEASE_REPOSITORY:-SunChJ/gloss-releases}}" ARM64_ARCHIVE="${7:-Gloss-macos-arm64.zip}" X86_64_ARCHIVE="${8:-Gloss-macos-x86_64.zip}" DOWNLOAD_BASE_URL="${GLOSS_CASK_DOWNLOAD_BASE_URL:-}" @@ -59,22 +59,80 @@ cask "gloss" do on_arm do sha256 "$ARM64_SHA256" + url "$DOWNLOAD_BASE_URL/$ARM64_ARCHIVE" end - on_intel do sha256 "$X86_64_SHA256" + url "$DOWNLOAD_BASE_URL/$X86_64_ARCHIVE" end name "Gloss" - desc "Native, context-aware translation for macOS" + desc "Context-aware text and document translation" homepage "https://github.com/$REPOSITORY" - depends_on macos: ">= :sonoma" + depends_on macos: :sonoma app "Gloss.app" + postflight do + app_path = "#{appdir}/Gloss.app" + extension_path = "#{app_path}/Contents/PlugIns/Gloss Extension.appex" + entitlement_paths = [extension_path, app_path] + entitlements_before = entitlement_paths.map do |code_path| + system_command("/usr/bin/codesign", + args: ["--display", "--entitlements", "-", code_path], + sudo: false, + must_succeed: true, + print_stderr: false).stdout + end + code_paths = [ + extension_path, + "#{app_path}/Contents/Helpers/gloss-codex-app-server", + "#{app_path}/Contents/Helpers/gloss-cli", + app_path, + ] + code_paths.each do |code_path| + system_command "/usr/bin/codesign", + args: [ + "--force", + "--sign", + "-", + "--preserve-metadata=identifier,entitlements,requirements,flags,runtime", + code_path, + ], + sudo: false, + must_succeed: true + end + entitlements_after = entitlement_paths.map do |code_path| + system_command("/usr/bin/codesign", + args: ["--display", "--entitlements", "-", code_path], + sudo: false, + must_succeed: true, + print_stderr: false).stdout + end + raise "Gloss code-signing entitlements changed during installation" if entitlements_after != entitlements_before + + system_command "/usr/bin/xattr", + args: ["-dr", "com.apple.quarantine", app_path], + sudo: false, + must_succeed: true + remaining_attributes = system_command "/usr/bin/xattr", + args: ["-lr", app_path], + sudo: false, + must_succeed: true, + print_stderr: false + if remaining_attributes.stdout.include?("com.apple.quarantine") + raise "Gloss quarantine attribute remains after installation" + end + + system_command "/usr/bin/codesign", + args: ["--verify", "--deep", "--strict", app_path], + sudo: false, + must_succeed: true + end + uninstall quit: "com.samsoncj.gloss" zap trash: [ @@ -83,6 +141,11 @@ cask "gloss" do "~/Library/Logs/Gloss", "~/Library/Preferences/com.samsoncj.gloss.plist", ] + + caveats <<~EOS + Gloss uses an ad-hoc code signature and is not Apple-notarized. This custom + tap re-signs the installed app and removes its quarantine attribute. + EOS end RUBY diff --git a/Scripts/generate_release_metadata.sh b/Scripts/generate_release_metadata.sh index fcda7d0..2c16166 100755 --- a/Scripts/generate_release_metadata.sh +++ b/Scripts/generate_release_metadata.sh @@ -11,7 +11,7 @@ X86_64_ARCHIVE="$2" VERSION="$3" OUTPUT_DIRECTORY="$4" RELEASE_TAG="$5" -REPOSITORY="${6:-${GITHUB_REPOSITORY:-SunChJ/gloss}}" +REPOSITORY="${6:-${GLOSS_RELEASE_REPOSITORY:-SunChJ/gloss-releases}}" ASSET_BASE_URL="${7:-https://github.com/$REPOSITORY/releases/download/$RELEASE_TAG}" for archive in "$ARM64_ARCHIVE" "$X86_64_ARCHIVE"; do @@ -144,7 +144,7 @@ MANIFEST_SHA256="$(shasum -a 256 "$MANIFEST_PATH" | awk '{print $1}')" } >"$CHECKSUMS_PATH" GLOSS_CASK_DOWNLOAD_BASE_URL="$ASSET_BASE_URL" \ - "$(dirname "$0")/generate_homebrew_cask.sh" \ + bash "$(dirname "$0")/generate_homebrew_cask.sh" \ "$VERSION" \ "$ARM64_SHA256" \ "$X86_64_SHA256" \ @@ -153,7 +153,7 @@ GLOSS_CASK_DOWNLOAD_BASE_URL="$ASSET_BASE_URL" \ "$REPOSITORY" \ "$ARM64_NAME" \ "$X86_64_NAME" -"$(dirname "$0")/validate_homebrew_cask.sh" \ +bash "$(dirname "$0")/validate_homebrew_cask.sh" \ "$OUTPUT_DIRECTORY/Casks/gloss.rb" echo "$MANIFEST_PATH" diff --git a/Scripts/validate_homebrew_cask.sh b/Scripts/validate_homebrew_cask.sh index cdd4eab..514c751 100755 --- a/Scripts/validate_homebrew_cask.sh +++ b/Scripts/validate_homebrew_cask.sh @@ -25,7 +25,47 @@ required = [ %r{^ url "https://[^"]+/Gloss-macos-arm64\.zip"$}, %r{^ url "https://[^"]+/Gloss-macos-x86_64\.zip"$}, /^ app "Gloss\.app"$/, + /^ postflight do$/, + %r{^ system_command "/usr/bin/codesign",$}, + %r{^ system_command "/usr/bin/codesign",$}, + %r{#\{app_path\}/Contents/PlugIns/Gloss Extension\.appex}, + %r{#\{app_path\}/Contents/Helpers/gloss-codex-app-server}, + %r{#\{app_path\}/Contents/Helpers/gloss-cli}, + /^ code_paths\.each do \|code_path\|$/, + /entitlements_before = entitlement_paths\.map/, + /entitlements_after = entitlement_paths\.map/, + /Gloss code-signing entitlements changed during installation/, + /"--preserve-metadata=identifier,entitlements,requirements,flags,runtime"/, + %r{^ system_command "/usr/bin/xattr",$}, + /\["-dr", "com\.apple\.quarantine", app_path\]/, + /\["-lr", app_path\]/, + /Gloss quarantine attribute remains after installation/, + /\["--verify", "--deep", "--strict", app_path\]/, + /^\s+must_succeed: true$/, + /^ caveats <<~EOS$/, ] missing = required.reject { |pattern| content.match?(pattern) } abort "Cask is missing required declarations: #{missing.join(", ")}" unless missing.empty? + +code_paths = content.match(%r{^ code_paths = \[$(.*?)^ \]$}m)&.[](1) +abort "Cask explicit signing paths are missing" unless code_paths +expected_order = [ + "extension_path,", + '"#{app_path}/Contents/Helpers/gloss-codex-app-server",', + '"#{app_path}/Contents/Helpers/gloss-cli",', + "app_path,", +] +cursor = -1 +expected_order.each do |entry| + position = code_paths.index(entry) + abort "Cask signing order is invalid: #{entry}" unless position && position > cursor + + cursor = position +end + +signing_section = content.match( + %r{^ code_paths\.each do \|code_path\|$(.*?)^ entitlements_after =}m, +)&.[](1) +abort "Cask signing section is missing" unless signing_section +abort "Cask must sign nested code explicitly, without --deep" if signing_section.include?('"--deep"') RUBY From 7e3b113a8594323d2a189f700970be87cfb95f39 Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Thu, 23 Jul 2026 02:11:51 -0700 Subject: [PATCH 6/8] docs: explain ad-hoc Homebrew distribution --- README.md | 29 ++++--- docs/release-notes/v0.8.0.md | 8 +- docs/runtime-distribution.md | 153 ++++++++++++++++++++++++++--------- 3 files changed, 139 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 1477b97..545ca8f 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ swift run gloss-cli --provider llama 'Hello from local Gloss.' open dist/Gloss.app ``` -构建脚本会按 `CodexRuntime.lock` 下载并校验固定版本的官方 Rust app-server,把它与许可证一起嵌入 App;本地 provider 当前复用系统安装的 `llama-server`。随后脚本在相邻的 `personal-immersive-translator` 仓库中生成 Chrome/Safari 产物,并把 Chrome 资源与 Safari `.appex` 嵌入 App。结果位于 `dist/Gloss.app`。脚本会优先使用钥匙串中的第一个 Apple Development 身份;没有可用证书时退回临时签名,此时 Safari 配对不可用。正式分发前需要换成 Developer ID 签名和公证。 +构建脚本会按 `CodexRuntime.lock` 下载并校验固定版本的官方 Rust app-server,把它与许可证一起嵌入 App;本地 provider 当前复用系统安装的 `llama-server`。随后脚本在相邻的 `personal-immersive-translator` 仓库中生成 Chrome/Safari 产物,并把 Chrome 资源与 Safari `.appex` 嵌入 App。结果位于 `dist/Gloss.app`。脚本默认使用 `-` 做 ad-hoc codesign;这种签名没有 Apple 开发者身份,Safari 配对不可用。 如果不希望下载或嵌入固定 Rust app-server,可构建依赖用户 Codex CLI 的轻量版本: @@ -148,13 +148,13 @@ open dist/Gloss.app 该脚本会先确认当前环境中的 `codex app-server` 可用,但不会把 Codex runtime、许可证或版本锁文件放入 App。运行时 Gloss 会查找 `GLOSS_CODEX_BIN`、`PATH`、Homebrew 与常用本地安装路径,并执行 `codex app-server --listen stdio://`。进程与 thread 仍统一经过 `CodexAppServerClient`,因此会复用相同的静态模型目录、隔离工作目录和 MCP/skills/tools 禁用配置,不会退回较慢的默认启动方式。 -需要稳定的本机开发签名时,可显式传入钥匙串中的证书: +本机调试 Safari 配对时,可显式传入钥匙串中的 Apple Development 证书: ```bash GLOSS_SIGN_IDENTITY="Apple Development: Your Name (TEAMID)" ./Scripts/build_app.sh ``` -正式分发时使用 `Developer ID Application` 证书执行同一命令;脚本会自动启用 Hardened Runtime 与可信时间戳。随后仍需用 Apple `notarytool` 公证并对 App 执行 `stapler staple`。 +当前公开 Homebrew 发行也明确使用 ad-hoc 签名,不要求 Developer ID 或 Apple 公证。 ### BabelDOC runtime 更新 @@ -169,22 +169,25 @@ atomic state file 防止半安装状态。完整 manifest schema、安全边界 推送与 `Resources/Info.plist` 一致的 `v*` tag 会运行 Release workflow,产出 arm64 与 x86_64 两套 `Gloss.app` zip、`SHA256SUMS`、release manifest 和带 -`on_arm` / `on_intel` 校验的 Homebrew cask。Release 完成后,自动化会在本仓库创建 -`Casks/gloss.rb` 更新 PR;不依赖额外的外部 tap 仓库。 +`on_arm` / `on_intel` 校验的 Homebrew cask。私有 `SunChJ/gloss` 只负责构建;ad-hoc +签名后的资产发布到公开 `SunChJ/gloss-releases`,随后自动 dispatch +`SunChJ/homebrew-tap` 更新 Cask。下载 URL 不会指向私有主仓。 -当前 Gloss 仓库仍是 private,匿名 Homebrew 安装需要先提供 public GitHub Release 或其他公共 -binary host。公开发行地址就绪后,首次安装以及后续升级为: +首次安装以及后续升级为: ```bash -brew tap sunchj/gloss https://github.com/SunChJ/gloss -brew install --cask sunchj/gloss/gloss +brew tap sunchj/tap +brew install --cask sunchj/tap/gloss brew update -brew upgrade --cask gloss +brew upgrade --cask sunchj/tap/gloss ``` -正式 tag Release 必须同时具备 Developer ID 与 Apple 公证 secrets,否则 workflow 会在上传 -public Release 和 Homebrew cask 前 fail closed。没有签名凭据时,手工 workflow 只会生成适合 -内部验证的 ad-hoc artifact。具体变量、私有仓库限制与本地打包命令见 +Release workflow 使用只读 `GLOSS_EXTENSION_TOKEN` 检出私有浏览器扩展;正式 tag 另外要求 +跨仓库 `GLOSS_DISTRIBUTION_TOKEN`。缺失时 workflow 会在构建和上传前 fail closed。手工 +workflow 不发布,但仍需要 extension token 才能生成完整 App artifact。 +Cask 的 `postflight` 会重新 ad-hoc 签名、移除 quarantine 并验证签名,让安装后启动不弹 +Gatekeeper 交互;这也意味着 macOS 无法验证 Apple 开发者身份或公证票据。公开仓库初始化、 +fine-grained token 权限、完整安全取舍、发行顺序与恢复步骤见 [发行文档](docs/runtime-distribution.md)。 ## 代码结构 diff --git a/docs/release-notes/v0.8.0.md b/docs/release-notes/v0.8.0.md index 45bff78..a6f5a34 100644 --- a/docs/release-notes/v0.8.0.md +++ b/docs/release-notes/v0.8.0.md @@ -24,8 +24,12 @@ native batch-translation workspace. Dashboard. - Adds explicit install, update, start, reconnect, cancel, retry, and rollback actions. -- Adds macOS CI, reproducible release metadata, and a generated Homebrew cask - for release artifacts. +- Publishes checksum-pinned arm64 and Intel artifacts through the public + `SunChJ/gloss-releases` repository and updates the + `SunChJ/homebrew-tap` cask. +- Uses an ad-hoc signature rather than Apple Developer ID/notarization. The + custom Cask re-signs the installed app, preserves its identifiers and + entitlements, removes quarantine, and verifies the resulting signature. ## Compatibility diff --git a/docs/runtime-distribution.md b/docs/runtime-distribution.md index f7ab80f..c732846 100644 --- a/docs/runtime-distribution.md +++ b/docs/runtime-distribution.md @@ -84,29 +84,72 @@ filesystem 的 staging 目录,完整校验后才移动到版本目录;`state `.github/workflows/release.yml` 在 `v*` tag 上: -1. 分别在 `macos-15` arm64 和 `macos-15-intel` x86_64 runner 构建 `Gloss.app`;tag 发行强制 - Developer ID 签名、公证与 stapling。 +1. 分别在 `macos-15` arm64 和 `macos-15-intel` x86_64 runner 构建 `Gloss.app`,并显式使用 + `GLOSS_SIGN_IDENTITY=-` 对 App、helper 与嵌套 extension 做 ad-hoc codesign;不导入 Apple + 证书,也不执行 notarization 或 stapling。 2. 生成 `Gloss-macos-arm64.zip`、`Gloss-macos-x86_64.zip`、`SHA256SUMS` 和包含两个 architecture asset 的 `gloss-release-manifest.json`。 -3. 生成并校验使用 `on_arm` / `on_intel` URL 与 SHA-256 的 `Casks/gloss.rb`。 -4. 上传 Actions artifact 与 GitHub Release assets。 -5. 启动 Homebrew cask 更新 workflow。 - -完整 App 会同时检出并构建浏览器扩展仓库。正式 tag 发行必须配置以下签名和公证 secrets: +3. 生成并校验使用 `on_arm` / `on_intel` URL 与 SHA-256 的 `Casks/gloss.rb`。Manifest 和 + Cask 中的下载地址固定指向公开仓库 + `https://github.com/SunChJ/gloss-releases/releases/download//`。 +4. 始终上传私有主仓中的 Actions artifact,便于内部验证。 +5. 仅在 tag 事件中使用跨仓库 token,把 app zip、校验和、manifest 与生成的 Cask 发布到 + 公开的 `SunChJ/gloss-releases` GitHub Release。 +6. Release 上传成功后,dispatch `SunChJ/homebrew-tap` 的 `update-cask.yml`,由公开 tap + 下载并二次校验 Release,再更新 `Casks/gloss.rb`。 + +完整 App 会同时检出并构建私有浏览器扩展仓库。Release workflow 使用两个职责分离的 +fine-grained token: | Secret | 用途 | | --- | --- | -| `DEVELOPER_ID_CERTIFICATE_BASE64` | Base64 编码的 `.p12` | -| `DEVELOPER_ID_CERTIFICATE_PASSWORD` | `.p12` 密码 | -| `DEVELOPER_ID_APPLICATION` | `Developer ID Application: ...` identity | -| `RELEASE_KEYCHAIN_PASSWORD` | 临时 CI keychain 密码 | -| `APPLE_NOTARY_APPLE_ID` | 公证 Apple ID | -| `APPLE_NOTARY_PASSWORD` | App-specific password | -| `APPLE_TEAM_ID` | Apple Developer Team ID | - -tag workflow 在任一签名或公证 secret 缺失时 fail closed,不会上传 public GitHub Release 或 -启动 Homebrew 更新。手工 `workflow_dispatch` 可以在没有 secrets 时生成仅供内部验证的 -ad-hoc artifact,但不会发布。 +| `GLOSS_EXTENSION_TOKEN` | 只读检出私有 `SunChJ/personal-immersive-translator` | +| `GLOSS_DISTRIBUTION_TOKEN` | 向公开 binary repo 上传 Release,并 dispatch 公开 tap workflow | + +workflow 的第一个 job 始终检查 `GLOSS_EXTENSION_TOKEN`;tag 事件还会检查 +`GLOSS_DISTRIBUTION_TOKEN`。缺失即 fail closed,不会开始正式构建。手工 +`workflow_dispatch` 不走 public publication 路径,因此不需要 distribution token,但仍需 +只读 extension token 才能构建完整 App。 + +### 公开仓库与 token 初始化 + +公开分发使用两个独立仓库,私有 `SunChJ/gloss` 不承载匿名下载: + +- `SunChJ/gloss-releases`:public;初始化 `main` 分支,仅承载发行说明、tag 和二进制 Release + assets。 +- `SunChJ/homebrew-tap`:public;初始化 `main` 分支,包含 `Casks/gloss.rb` 以及 + `.github/workflows/update-cask.yml`。 + +在 GitHub 创建 fine-grained personal access token,并按下面的最小边界配置: + +1. Resource owner 选择 `SunChJ`,Repository access 只选择 + `SunChJ/gloss-releases` 和 `SunChJ/homebrew-tap`。 +2. Repository permissions 设置 `Contents: Read and write`,用于在 `gloss-releases` + 创建 tag/Release 和上传 assets。 +3. Repository permissions 设置 `Actions: Read and write`,用于 dispatch + `homebrew-tap/.github/workflows/update-cask.yml`。 +4. 将 token 保存为私有 `SunChJ/gloss` 仓库的 Actions secret + `GLOSS_DISTRIBUTION_TOKEN`。不要把 token 写入 workflow、日志、公开仓库或本地发行产物; + 按 token 到期时间提前轮换。 + +同一个 fine-grained token 的权限会应用到所选的两个仓库,因此这里使用完成两项跨仓库操作所需 +权限的并集。Token 不需要访问私有 `SunChJ/gloss`;workflow 通过该仓库自己的 +`GITHUB_TOKEN` 只读检出源码。 + +另建一个 fine-grained token,只选择私有 +`SunChJ/personal-immersive-translator`,仅授予 `Contents: Read-only`,并保存为 +`GLOSS_EXTENSION_TOKEN`。不要让这个只读 token 访问公开发行仓库,也不要让 +`GLOSS_DISTRIBUTION_TOKEN` 访问私有扩展源码。 + +`homebrew-tap` 的 `update-cask.yml` 必须声明两个 required `workflow_dispatch` inputs: +`release_tag` 和 `release_repository`。它应只接受 +`release_repository == "SunChJ/gloss-releases"`,下载 +`Gloss-macos-arm64.zip`、`Gloss-macos-x86_64.zip`、`SHA256SUMS` 与 `gloss.rb`,执行 +SHA-256 校验并确认 Cask 内的版本、两个 checksum、公开 URL 和安全 `postflight` 后才更新 +`Casks/gloss.rb`。若 workflow 通过 PR 更新 `main`,还需在 tap 仓库 +Settings → Actions → General +启用 “Allow GitHub Actions to create and approve pull requests”,并给该 workflow +`contents: write`、`pull-requests: write`。 本地生成发行元数据: @@ -120,31 +163,69 @@ Scripts/generate_release_metadata.sh \ dist/release/Gloss-macos-x86_64.zip \ 0.8.0 \ dist/release \ - v0.8.0 + v0.8.0 \ + SunChJ/gloss-releases ``` ## Homebrew 更新 -`homebrew-cask.yml` 从已发布 Release 重新下载两种架构的 app zip,先按 `SHA256SUMS` 校验, -再生成 `Casks/gloss.rb` 并向本仓库 `main` 提交 PR。它不依赖尚不存在的外部 tap,也不会绕过 -branch protection。仓库需要启用 GitHub Actions 的“Allow GitHub Actions to create and -approve pull requests”;若策略不允许,workflow artifact 中仍会保留已经校验的 cask,维护者 -可以手工提交。 +Gloss 主仓不再包含会向自身提交 Cask PR 的 `homebrew-cask.yml`。正式 Release 成功后,它会 +运行等价于下面的跨仓库 dispatch: + +```bash +gh workflow run update-cask.yml \ + --repo SunChJ/homebrew-tap \ + --ref main \ + -f release_tag=v0.8.0 \ + -f release_repository=SunChJ/gloss-releases +``` -当前 `SunChJ/gloss` 是 private repository,因此匿名 Homebrew 安装尚未闭环:GitHub private -Release 的 app zip 不能作为公共 cask 下载地址。要向外部分发,必须先将仓库和 binary -Release 设为 public,或把两种架构的 zip 发布到稳定的公共 HTTPS host 并让 cask generator 使用 -`GLOSS_CASK_DOWNLOAD_BASE_URL=https://downloads.example.com/gloss/v0.8.0` 指向该目录。手工 -运行 Homebrew workflow 时也可以填写 `download_base_url`。完成其中一项后,本仓库才能作为 -自定义 tap: +公开 tap 合并生成的 Cask 更新后,用户使用标准 tap 名称安装和升级: ```bash -brew tap sunchj/gloss https://github.com/SunChJ/gloss -brew install --cask sunchj/gloss/gloss +brew tap sunchj/tap +brew install --cask sunchj/tap/gloss brew update -brew upgrade --cask gloss +brew upgrade --cask sunchj/tap/gloss ``` -每次 Gloss Release 都会自动生成并发起 cask 更新;失败时也可从 Actions 手工运行 -“Update Homebrew cask”并传入已经发布的 tag。这个自动化完成的是可发布 cask 的生成与校验, -不等同于当前 private repository 已经提供匿名 Homebrew 更新。 +### 正式发行顺序 + +1. 先发布兼容的 `SunChJ/BabelDOC` signed runtime,并确认 stable manifest 可下载。 +2. 合并 Gloss 的发行提交,确认 `Resources/Info.plist` 版本与准备创建的 `v*` tag 完全一致。 +3. 确认两个公开仓库、`update-cask.yml`、`GLOSS_EXTENSION_TOKEN`、 + `GLOSS_DISTRIBUTION_TOKEN` 和 tap 的 Actions/branch protection 设置均已就绪。 +4. 在私有 Gloss 仓库的目标 commit 上创建并推送 tag,例如 `v0.8.0`。 +5. 等待 Gloss Release workflow 完成 ad-hoc 签名、公开资产上传和 tap dispatch。 +6. 在 `SunChJ/gloss-releases` 验证两种架构 zip、`SHA256SUMS`、 + `gloss-release-manifest.json` 与 `Casks/gloss.rb` 均存在且 URL 指向该公开 Release。 +7. 审阅并合并 `SunChJ/homebrew-tap` 生成的 Cask PR,然后在 arm64 与 x86_64 Mac 上分别执行 + `brew install --cask sunchj/tap/gloss` smoke test。 + +如果公开 Release 已成功但 tap dispatch 失败,可以从 `SunChJ/homebrew-tap` Actions 页面手工 +运行 `update-cask.yml`,输入相同的 tag 和固定 repository +`SunChJ/gloss-releases`。不要从私有 Gloss Release 或未经 `SHA256SUMS` 验证的临时 URL +生成公开 Cask。 + +### Ad-hoc 分发的安全取舍 + +这个渠道刻意不使用 Developer ID Application 证书、Apple notarization 或 stapled ticket: + +- macOS 无法把 `Gloss.app` 的签名绑定到经过 Apple 验证的发布者身份,也不会获得 Apple + notarization 的恶意软件扫描与撤销信号。 +- Cask 的下载 SHA-256 和公开 Release 的 `SHA256SUMS` 能证明实际下载内容与 tap 固定的内容 + 相同,但它们不能替代发布者身份签名;`gloss-releases`、`homebrew-tap` 或跨仓库 token + 同时失守时,攻击者可能替换二进制与 checksum。 +- custom tap 的 `postflight` 按最深层优先顺序分别对 Safari `.appex`、Codex helper、CLI 和 + 最外层 `Gloss.app` 执行 `codesign --force --sign -`,不使用可能覆盖嵌套 entitlement 的 + `--deep --sign`;每一步都通过 + `--preserve-metadata=identifier,entitlements,requirements,flags,runtime` 保留已有 metadata, + 并比较签名前后 App 与 `.appex` 的 entitlement bytes。随后只递归删除 + `com.apple.quarantine`、确认该属性已经不存在,最后用 + `codesign --verify --deep --strict` fail closed 验证完整签名。这让正常 Homebrew 安装后的首次 + 启动不需要用户绕过 Gatekeeper,但也主动移除了 Gatekeeper 的隔离检查。 +- ad-hoc 签名不能完成 Safari App Extension 与宿主 App 的 Apple 身份配对,因此该发行方式不 + 承诺 Safari extension 可用;需要 Safari 配对时仍应在本地使用 Apple Development 身份构建。 + +因此该 Cask 只适用于用户明确信任 `SunChJ/homebrew-tap` 和 +`SunChJ/gloss-releases` 的自定义分发场景,不应被描述为 Apple 已签名或已公证的软件。 From 9818f713a8b98dc94e06aa3eee648b7304fe3da5 Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Thu, 23 Jul 2026 02:24:07 -0700 Subject: [PATCH 7/8] fix: make bridge callback captures explicit --- Sources/Gloss/GlossAppDelegate.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/Gloss/GlossAppDelegate.swift b/Sources/Gloss/GlossAppDelegate.swift index 7093b80..18a1aa6 100644 --- a/Sources/Gloss/GlossAppDelegate.swift +++ b/Sources/Gloss/GlossAppDelegate.swift @@ -1735,29 +1735,29 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { server.onStateChange = { [weak self] state in Task { @MainActor [weak self] in guard let self, - isCurrentBridgeListener(generation: generation, attempt: attempt) + self.isCurrentBridgeListener(generation: generation, attempt: attempt) else { return } switch state { case .starting: break case .ready: - await finishBrowserBridgeStartup( + await self.finishBrowserBridgeStartup( token: token, generation: generation, attempt: attempt ) case .failed(let message): - await handleBrowserBridgeFailure( + await self.handleBrowserBridgeFailure( message, token: token, generation: generation, attempt: attempt ) case .stopped: - bridgeListenerAttempt = nil - loopbackServer = nil - updateBridgeState(.stopped) - bridgeRecoveryTask = nil + self.bridgeListenerAttempt = nil + self.loopbackServer = nil + self.updateBridgeState(.stopped) + self.bridgeRecoveryTask = nil } } } From 7086c5a8e37168bc46656c50ff9bdd460cd0a009 Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Thu, 23 Jul 2026 02:27:56 -0700 Subject: [PATCH 8/8] release: make published artifacts immutable --- .github/workflows/release.yml | 18 ++++++++++++++++-- docs/runtime-distribution.md | 7 ++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d73064..38f8313 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -166,19 +166,30 @@ jobs: GH_TOKEN: ${{ secrets.GLOSS_DISTRIBUTION_TOKEN }} run: | version="${RELEASE_TAG#v}" - if ! gh release view "$RELEASE_TAG" \ - --repo "$GLOSS_RELEASE_REPOSITORY" >/dev/null 2>&1; then + if release_is_draft="$( + gh release view "$RELEASE_TAG" \ + --repo "$GLOSS_RELEASE_REPOSITORY" \ + --json isDraft \ + --jq '.isDraft' 2>/dev/null + )"; then + if [[ "$release_is_draft" != "true" ]]; then + echo "Release $RELEASE_TAG is already published and must remain immutable." >&2 + exit 1 + fi + else notes_file="docs/release-notes/v$version.md" if [[ -f "$notes_file" ]]; then gh release create "$RELEASE_TAG" \ --repo "$GLOSS_RELEASE_REPOSITORY" \ --target main \ + --draft \ --title "Gloss $version" \ --notes-file "$notes_file" else gh release create "$RELEASE_TAG" \ --repo "$GLOSS_RELEASE_REPOSITORY" \ --target main \ + --draft \ --title "Gloss $version" \ --notes "Checksum-pinned, ad-hoc signed macOS release of Gloss $version." fi @@ -191,6 +202,9 @@ jobs: dist/release/gloss-release-manifest.json \ dist/release/Casks/gloss.rb \ --clobber + gh release edit "$RELEASE_TAG" \ + --repo "$GLOSS_RELEASE_REPOSITORY" \ + --draft=false - name: Start Homebrew cask update if: github.event_name == 'push' env: diff --git a/docs/runtime-distribution.md b/docs/runtime-distribution.md index c732846..f13ac1e 100644 --- a/docs/runtime-distribution.md +++ b/docs/runtime-distribution.md @@ -196,12 +196,17 @@ brew upgrade --cask sunchj/tap/gloss 3. 确认两个公开仓库、`update-cask.yml`、`GLOSS_EXTENSION_TOKEN`、 `GLOSS_DISTRIBUTION_TOKEN` 和 tap 的 Actions/branch protection 设置均已就绪。 4. 在私有 Gloss 仓库的目标 commit 上创建并推送 tag,例如 `v0.8.0`。 -5. 等待 Gloss Release workflow 完成 ad-hoc 签名、公开资产上传和 tap dispatch。 +5. 等待 Gloss Release workflow 完成 ad-hoc 签名;workflow 会先创建 draft Release,上传全部 + 资产后再发布,最后 dispatch tap 更新。 6. 在 `SunChJ/gloss-releases` 验证两种架构 zip、`SHA256SUMS`、 `gloss-release-manifest.json` 与 `Casks/gloss.rb` 均存在且 URL 指向该公开 Release。 7. 审阅并合并 `SunChJ/homebrew-tap` 生成的 Cask PR,然后在 arm64 与 x86_64 Mac 上分别执行 `brew install --cask sunchj/tap/gloss` smoke test。 +`SunChJ/gloss-releases` 必须启用 GitHub release immutability。已发布 Release 的 tag 与资产 +不可覆盖;相同 tag 的 workflow 重跑会 fail closed。上传中断时 Release 仍保持 draft, +重跑可以修复 draft 资产并重新发布。 + 如果公开 Release 已成功但 tap dispatch 失败,可以从 `SunChJ/homebrew-tap` Actions 页面手工 运行 `update-cask.yml`,输入相同的 tag 和固定 repository `SunChJ/gloss-releases`。不要从私有 Gloss Release 或未经 `SHA256SUMS` 验证的临时 URL