From 479fc74f0b54e082ffc6a0d8817c8c10c45f4f8f Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Mon, 27 Jul 2026 01:04:08 -0700 Subject: [PATCH 1/9] fix: require compatible BabelDOC runtime --- Sources/Gloss/PDFRuntimeController.swift | 212 +++++++++++++----- .../BabelDOCRuntimeCompatibility.swift | 183 +++++++++++++++ .../BabelDOCRuntimeDistribution.swift | 17 +- .../GlossCore/BabelDOCServiceSession.swift | 7 +- .../PDFRuntimeLifecycleTests.swift | 137 +++++++++++ .../BabelDOCRuntimeCompatibilityTests.swift | 124 ++++++++++ .../BabelDOCRuntimeDistributionTests.swift | 44 +++- 7 files changed, 655 insertions(+), 69 deletions(-) create mode 100644 Sources/GlossCore/BabelDOCRuntimeCompatibility.swift create mode 100644 Tests/GlossCoreTests/BabelDOCRuntimeCompatibilityTests.swift diff --git a/Sources/Gloss/PDFRuntimeController.swift b/Sources/Gloss/PDFRuntimeController.swift index 5c58e93..0b43c8b 100644 --- a/Sources/Gloss/PDFRuntimeController.swift +++ b/Sources/Gloss/PDFRuntimeController.swift @@ -3,15 +3,26 @@ import GlossCore @MainActor final class PDFRuntimeController { + nonisolated static let minimumCompatibleManagedRuntimeVersion = + BabelDOCRuntimeCompatibility.minimumManagedVersion + struct PreparedRuntime { let launch: BabelDOCRuntimeLaunch let layoutServiceBaseURL: URL let layoutCacheDirectoryURL: URL? } + enum ManagedRuntimePreparation: Equatable { + case install + case installAvailableUpdate + case startCurrent + case updateRequired(currentVersion: String) + } + private enum ControllerError: LocalizedError { case runtimeManagerUnavailable case runtimeUnavailable + case runtimeUpdateRequired(current: String, minimum: String) var errorDescription: String? { switch self { @@ -19,6 +30,8 @@ final class PDFRuntimeController { "无法初始化 BabelDOC 运行时管理器。" case .runtimeUnavailable: "没有可用的 BabelDOC 运行时。" + case .runtimeUpdateRequired(let current, let minimum): + "当前 BabelDOC 运行时 \(current) 已知不兼容。请联网更新到 \(minimum) 或更高版本后再使用 PDF 翻译。" } } } @@ -96,13 +109,7 @@ final class PDFRuntimeController { } var currentRuntimeLaunch: BabelDOCRuntimeLaunch? { - if let executable = runtimeSnapshot?.currentExecutableURL { - return Self.managedLaunch( - executable: executable, - version: runtimeSnapshot?.currentVersion - ) - } - return BabelDOCExternalEngine.resolveRuntime() + Self.compatibleManagedRuntimeLaunch(for: runtimeSnapshot) } func prepareAtLaunch() { @@ -350,68 +357,82 @@ final class PDFRuntimeController { ) async throws -> PreparedRuntime { try await waitForLaunchPreparation() try Task.checkCancellation() + guard let runtimeManager else { + throw ControllerError.runtimeManagerUnavailable + } + var changedManagedVersion = false - if let runtimeManager { - var snapshot = await runtimeManager.snapshot() + var snapshot = await runtimeManager.snapshot() + runtimeSnapshot = snapshot + + // A verified local runtime is sufficient to start the resident service. + // Launch-time update discovery remains advisory and must not put the + // network on the critical path or replace a runtime already in use. + if let launch = Self.compatibleManagedRuntimeLaunch(for: snapshot) { + return try await start(launch) + } + + if snapshot.currentExecutableURL != nil, !forceUpdateCheck { + await waitForBackgroundUpdateCheck() try Task.checkCancellation() + snapshot = await runtimeManager.snapshot() 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 snapshot.currentExecutableURL == nil { + do { + snapshot = try await runtimeManager.update() + try Task.checkCancellation() + changedManagedVersion = true + } catch is CancellationError { + throw CancellationError() + } catch { + runtimeSnapshot = await runtimeManager.snapshot() + throw error } + } else if forceUpdateCheck { + do { + snapshot = try await runtimeManager.checkForUpdates() + try Task.checkCancellation() + } catch is CancellationError { + throw CancellationError() + } catch { + snapshot = await runtimeManager.snapshot() + runtimeSnapshot = snapshot + throw error + } + } - if forceUpdateCheck, snapshot.updateAvailable { - try await stopServiceForRuntimeReplacement() + if Self.managedRuntimePreparation(for: snapshot) == .installAvailableUpdate { + try await stopServiceForRuntimeReplacement() + try Task.checkCancellation() + do { + snapshot = try await runtimeManager.installAvailableUpdate() 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) - } + changedManagedVersion = true + } catch is CancellationError { + throw CancellationError() + } catch { + runtimeSnapshot = await runtimeManager.snapshot() + throw error } + } + if case .updateRequired = Self.managedRuntimePreparation(for: snapshot) { runtimeSnapshot = snapshot + throw Self.runtimeUpdateRequiredError(for: snapshot) } + runtimeSnapshot = snapshot guard let launch = currentRuntimeLaunch else { - throw ControllerError.runtimeUnavailable + throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot) } do { return try await start(launch) } catch is CancellationError { throw CancellationError() } catch { + logNonfatalUpdateFailure(error) guard changedManagedVersion, - let runtimeManager, (await runtimeManager.snapshot()).previousVersion != nil else { throw error @@ -420,8 +441,11 @@ final class PDFRuntimeController { try Task.checkCancellation() runtimeSnapshot = try await runtimeManager.rollback() try Task.checkCancellation() + if case .updateRequired = Self.managedRuntimePreparation(for: runtimeSnapshot) { + throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot) + } guard let rollbackLaunch = currentRuntimeLaunch else { - throw ControllerError.runtimeUnavailable + throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot) } return try await start(rollbackLaunch) } @@ -437,17 +461,26 @@ final class PDFRuntimeController { try await stopServiceForRuntimeReplacement() try Task.checkCancellation() do { - runtimeSnapshot = try await runtimeManager.update() + let checkedSnapshot = await runtimeManager.snapshot() + if checkedSnapshot.updateAvailable { + runtimeSnapshot = try await runtimeManager.installAvailableUpdate() + } else { + runtimeSnapshot = try await runtimeManager.update() + } try Task.checkCancellation() changedManagedVersion = runtimeSnapshot?.currentVersion != versionBeforeUpdate + if case .updateRequired = Self.managedRuntimePreparation(for: runtimeSnapshot) { + throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot) + } guard let launch = currentRuntimeLaunch else { - throw ControllerError.runtimeUnavailable + throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot) } return try await start(launch) } catch is CancellationError { throw CancellationError() } catch { + logNonfatalUpdateFailure(error) let snapshot = await runtimeManager.snapshot() if changedManagedVersion, snapshot.previousVersion != nil { try await stopServiceForRuntimeReplacement() @@ -457,6 +490,9 @@ final class PDFRuntimeController { runtimeSnapshot = snapshot logNonfatalUpdateFailure(error) } + if case .updateRequired = Self.managedRuntimePreparation(for: runtimeSnapshot) { + throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot) + } guard let launch = currentRuntimeLaunch else { throw error } @@ -473,14 +509,14 @@ final class PDFRuntimeController { runtimeSnapshot = try await runtimeManager.rollback() try Task.checkCancellation() guard let launch = currentRuntimeLaunch else { - throw ControllerError.runtimeUnavailable + throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot) } return try await start(launch) } private func reconnect() async throws -> PreparedRuntime { guard let launch = currentRuntimeLaunch else { - throw ControllerError.runtimeUnavailable + throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot) } let baseURL = try await service.reconnect(runtime: launch, force: true) let cacheURL = await service.layoutCacheDirectoryURL @@ -551,6 +587,11 @@ final class PDFRuntimeController { backgroundUpdateID = nil } + private func waitForBackgroundUpdateCheck() async { + let task = backgroundUpdateTask + await task?.value + } + private func takeAndCancelModulePreparation() -> Task? { let task = modulePreparationTask task?.cancel() @@ -574,11 +615,14 @@ final class PDFRuntimeController { private func logNonfatalUpdateFailure(_ error: Error) { GlossRuntimeLog.shared.write( "pdf-runtime", - "continuing_with_current_runtime update_error=\(error.localizedDescription)" + "runtime_update_or_start_failed error=\(error.localizedDescription)" ) } private func start(_ launch: BabelDOCRuntimeLaunch) async throws -> PreparedRuntime { + guard launch == Self.compatibleManagedRuntimeLaunch(for: runtimeSnapshot) else { + throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot) + } let baseURL = try await service.start(runtime: launch) let cacheURL = await service.layoutCacheDirectoryURL return PreparedRuntime( @@ -630,8 +674,7 @@ final class PDFRuntimeController { runtime: BabelDOCRuntimeSnapshot?, service: BabelDOCExecutorServiceSnapshot, activeDocumentName: String?, - fallbackRuntimeAvailable: Bool = - BabelDOCExternalEngine.resolveRuntime() != nil + fallbackRuntimeAvailable: Bool = false ) -> PDFRuntimeDashboardState { if service.lifecycleState != .ready, let runtime { switch runtime.operation { @@ -718,7 +761,7 @@ final class PDFRuntimeController { } } - private static func managedLaunch( + private nonisolated static func managedLaunch( executable: URL, version: String? ) -> BabelDOCRuntimeLaunch { @@ -728,4 +771,55 @@ final class PDFRuntimeController { executorExecutable: executable.path ) } + + nonisolated static func compatibleManagedRuntimeLaunch( + for snapshot: BabelDOCRuntimeSnapshot? + ) -> BabelDOCRuntimeLaunch? { + guard let snapshot, + let executable = snapshot.currentExecutableURL, + isCompatibleManagedRuntimeVersion(snapshot.currentVersion) + else { + return nil + } + return managedLaunch( + executable: executable, + version: snapshot.currentVersion + ) + } + + nonisolated static func managedRuntimePreparation( + for snapshot: BabelDOCRuntimeSnapshot? + ) -> ManagedRuntimePreparation { + switch BabelDOCRuntimeCompatibility.preparation( + for: snapshot + ) { + case .install: + return .install + case .installAvailableUpdate: + return .installAvailableUpdate + case .useCurrent: + return .startCurrent + case .updateRequired(let currentVersion): + return .updateRequired( + currentVersion: currentVersion == "unknown" + ? "未知版本" + : currentVersion + ) + } + } + + nonisolated static func isCompatibleManagedRuntimeVersion( + _ version: String? + ) -> Bool { + BabelDOCRuntimeCompatibility.isCompatible(version) + } + + private nonisolated static func runtimeUpdateRequiredError( + for snapshot: BabelDOCRuntimeSnapshot? + ) -> ControllerError { + ControllerError.runtimeUpdateRequired( + current: snapshot?.currentVersion ?? "未知版本", + minimum: minimumCompatibleManagedRuntimeVersion + ) + } } diff --git a/Sources/GlossCore/BabelDOCRuntimeCompatibility.swift b/Sources/GlossCore/BabelDOCRuntimeCompatibility.swift new file mode 100644 index 0000000..8b272a1 --- /dev/null +++ b/Sources/GlossCore/BabelDOCRuntimeCompatibility.swift @@ -0,0 +1,183 @@ +import Foundation + +public enum BabelDOCManagedRuntimePreparation: Equatable, Sendable { + case install + case installAvailableUpdate + case useCurrent + case updateRequired(currentVersion: String) +} + +public enum BabelDOCRuntimeCompatibility { + public static let minimumManagedVersion = "0.6.4+gloss.5" + + public static func isCompatible(_ version: String?) -> Bool { + guard let version = parsedVersion(version) else { return false } + let minimumCore = [0, 6, 4] + if version.core != minimumCore { + return version.core.lexicographicallyPrecedes(minimumCore) == false + } + return version.glossRevision.map { $0 >= 5 } ?? false + } + + public static func preparation( + for snapshot: BabelDOCRuntimeSnapshot? + ) -> BabelDOCManagedRuntimePreparation { + guard let snapshot, snapshot.currentExecutableURL != nil else { + return .install + } + if isCompatible(snapshot.currentVersion) { + return .useCurrent + } + return snapshot.updateAvailable + ? .installAvailableUpdate + : .updateRequired( + currentVersion: snapshot.currentVersion ?? "unknown" + ) + } + + private static func parsedVersion( + _ value: String? + ) -> (core: [Int], glossRevision: Int?)? { + guard let value, + value == value.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty, + !value.contains("-") + else { + return nil + } + + let versionAndMetadata = value.split( + separator: "+", + maxSplits: 1, + omittingEmptySubsequences: false + ) + guard + let core = parseNumericComponents( + String(versionAndMetadata[0]), + count: 3 + ) + else { + return nil + } + + guard versionAndMetadata.count == 2 else { + return (core, nil) + } + let metadata = versionAndMetadata[1].split( + separator: ".", + omittingEmptySubsequences: false + ) + guard metadata.count == 2, + metadata[0] == "gloss", + let revision = parseNumericComponent(metadata[1]) + else { + return nil + } + return (core, revision) + } + + private static func parseNumericComponents( + _ value: String, + count: Int + ) -> [Int]? { + let components = value.split( + separator: ".", + omittingEmptySubsequences: false + ) + guard components.count == count else { return nil } + let values = components.compactMap(parseNumericComponent) + return values.count == count ? values : nil + } + + private static func parseNumericComponent( + _ value: Substring + ) -> Int? { + guard !value.isEmpty, + value.allSatisfy(\.isNumber), + value.count == 1 || value.first != "0" + else { + return nil + } + return Int(value) + } +} + +/// Resolves the Gloss product version for a standalone helper executable. +/// +/// A helper inside `Gloss.app/Contents/Helpers` does not always receive the app +/// bundle as `Bundle.main`. Homebrew may also invoke it through a symlink. Walk +/// from the resolved executable first, then support a package checkout's +/// `Resources/Info.plist` for `swift run gloss-cli`. +public enum GlossProductVersionResolver { + public static func resolve( + bundleVersion: String? = Bundle.main.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String, + executableURL: URL? = Bundle.main.executableURL, + workingDirectoryURL: URL = URL( + fileURLWithPath: FileManager.default.currentDirectoryPath, + isDirectory: true + ) + ) -> String? { + if let bundleVersion = normalized(bundleVersion) { + return bundleVersion + } + + if var candidate = executableURL?.resolvingSymlinksInPath() + .deletingLastPathComponent() + { + for _ in 0..<10 { + if candidate.pathExtension.lowercased() == "app", + let version = version( + in: candidate.appendingPathComponent( + "Contents/Info.plist" + ) + ) + { + return version + } + if let version = version( + in: candidate.appendingPathComponent( + "Resources/Info.plist" + ) + ) { + return version + } + let parent = candidate.deletingLastPathComponent() + guard parent.path != candidate.path else { break } + candidate = parent + } + } + + return version( + in: workingDirectoryURL.appendingPathComponent( + "Resources/Info.plist" + ) + ) + } + + private static func version(in plistURL: URL) -> String? { + guard let data = try? Data(contentsOf: plistURL), + let value = try? PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil + ), + let dictionary = value as? [String: Any] + else { return nil } + return normalized( + dictionary["CFBundleShortVersionString"] as? String + ) + } + + private static func normalized(_ value: String?) -> String? { + guard + let value = value?.trimmingCharacters( + in: .whitespacesAndNewlines + ), !value.isEmpty + else { + return nil + } + return value + } +} diff --git a/Sources/GlossCore/BabelDOCRuntimeDistribution.swift b/Sources/GlossCore/BabelDOCRuntimeDistribution.swift index 5c755cb..4f27339 100644 --- a/Sources/GlossCore/BabelDOCRuntimeDistribution.swift +++ b/Sources/GlossCore/BabelDOCRuntimeDistribution.swift @@ -611,12 +611,25 @@ public actor BabelDOCRuntimeManager { signatureURL: URL? = nil, progress: ProgressHandler? = nil ) async throws -> BabelDOCRuntimeSnapshot { - let checked = try await checkForUpdates( + _ = try await checkForUpdates( manifestURL: manifestURL, signatureURL: signatureURL, progress: progress ) - guard checked.updateAvailable, let manifest = availableManifest else { + return try await installAvailableUpdate(progress: progress) + } + + /// Installs the manifest most recently accepted by `checkForUpdates`. + /// + /// The cached manifest has already passed the detached-signature and policy + /// checks. Keeping this operation separate lets launch-time callers wait for + /// their in-flight check and install that exact result without fetching + /// mutable "latest" metadata a second time. + @discardableResult + public func installAvailableUpdate( + progress: ProgressHandler? = nil + ) async throws -> BabelDOCRuntimeSnapshot { + guard makeSnapshot().updateAvailable, let manifest = availableManifest else { throw BabelDOCRuntimeDistributionError.noUpdateAvailable } return try await install(manifest, progress: progress) diff --git a/Sources/GlossCore/BabelDOCServiceSession.swift b/Sources/GlossCore/BabelDOCServiceSession.swift index 662459f..c742d21 100644 --- a/Sources/GlossCore/BabelDOCServiceSession.swift +++ b/Sources/GlossCore/BabelDOCServiceSession.swift @@ -29,6 +29,9 @@ public enum BabelDOCServiceError: LocalizedError, Equatable, Sendable { /// tied to the Gloss process identity. public actor BabelDOCServiceSession: BabelDOCExecutorManaging { public static let shared = BabelDOCServiceSession() + /// A cold packaged runtime can spend close to two minutes loading the + /// DocLayout model on first launch. + public static let defaultStartupTimeout: Duration = .seconds(180) static let readyPrefix = "__GLOSS_BABELDOC_LAYOUT_READY__" static let executorReadyPrefix = "__GLOSS_BABELDOC_SERVICE_READY__" @@ -232,7 +235,7 @@ public actor BabelDOCServiceSession: BabelDOCExecutorManaging { public func start( runtime: BabelDOCRuntimeLaunch, - timeout: Duration = .seconds(90) + timeout: Duration = defaultStartupTimeout ) async throws -> URL { await acquireLifecycleOperation() defer { releaseLifecycleOperation() } @@ -640,7 +643,7 @@ public actor BabelDOCServiceSession: BabelDOCExecutorManaging { public func reconnect( runtime: BabelDOCRuntimeLaunch, force: Bool = false, - timeout: Duration = .seconds(90) + timeout: Duration = defaultStartupTimeout ) async throws -> URL { await acquireLifecycleOperation() defer { releaseLifecycleOperation() } diff --git a/Tests/GlossAppTests/PDFRuntimeLifecycleTests.swift b/Tests/GlossAppTests/PDFRuntimeLifecycleTests.swift index 0529291..ced1c56 100644 --- a/Tests/GlossAppTests/PDFRuntimeLifecycleTests.swift +++ b/Tests/GlossAppTests/PDFRuntimeLifecycleTests.swift @@ -168,4 +168,141 @@ final class PDFRuntimeLifecycleTests: XCTestCase { ) ) } + + func testIncompatibleManagedRuntimeConsumesVerifiedUpdateBeforeStarting() { + let checked = runtimeSnapshot( + currentVersion: "0.6.4+gloss.4", + availableVersion: "0.6.4+gloss.5", + updateAvailable: true, + operation: .ready + ) + + XCTAssertEqual( + PDFRuntimeController.managedRuntimePreparation(for: checked), + .installAvailableUpdate + ) + } + + func testCompatibleManagedRuntimeStartsWhileUpdateCheckRemainsAdvisory() { + let checked = runtimeSnapshot( + currentVersion: "0.6.4+gloss.5", + availableVersion: "0.6.4+gloss.6", + updateAvailable: true, + operation: .ready + ) + + XCTAssertEqual( + PDFRuntimeController.managedRuntimePreparation(for: checked), + .startCurrent + ) + XCTAssertEqual( + PDFRuntimeController.compatibleManagedRuntimeLaunch( + for: checked + )?.source, + "Gloss runtime 0.6.4+gloss.5" + ) + } + + func testCompatibleManagedRuntimeRemainsAvailableOffline() { + let offline = runtimeSnapshot( + currentVersion: "0.6.4+gloss.5", + availableVersion: nil, + updateAvailable: false, + operation: .failed + ) + + XCTAssertEqual( + PDFRuntimeController.managedRuntimePreparation(for: offline), + .startCurrent + ) + XCTAssertTrue( + PDFRuntimeController.isCompatibleManagedRuntimeVersion( + "0.6.4+gloss.10" + ) + ) + } + + func testMissingAndUnversionedManagedRuntimesCannotLaunch() { + let missing = runtimeSnapshot( + currentVersion: nil, + availableVersion: nil, + updateAvailable: false, + operation: .idle, + executableURL: nil + ) + let unversioned = runtimeSnapshot( + currentVersion: nil, + availableVersion: nil, + updateAvailable: false, + operation: .ready + ) + + XCTAssertEqual( + PDFRuntimeController.managedRuntimePreparation(for: missing), + .install + ) + XCTAssertEqual( + PDFRuntimeController.managedRuntimePreparation(for: unversioned), + .updateRequired(currentVersion: "未知版本") + ) + XCTAssertNil( + PDFRuntimeController.compatibleManagedRuntimeLaunch(for: missing) + ) + XCTAssertNil( + PDFRuntimeController.compatibleManagedRuntimeLaunch(for: unversioned) + ) + } + + func testControllerDoesNotFallBackToUnverifiedExternalRuntime() { + let controller = PDFRuntimeController( + service: BabelDOCServiceSession( + persistedStateDirectoryURL: FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + ), + runtimeManager: nil + ) + + XCTAssertNil(controller.currentRuntimeLaunch) + } + + func testIncompatibleManagedRuntimeRequiresUpdateWhenOffline() { + let offline = runtimeSnapshot( + currentVersion: "0.6.4+gloss.4", + availableVersion: nil, + updateAvailable: false, + operation: .failed + ) + + XCTAssertEqual( + PDFRuntimeController.managedRuntimePreparation(for: offline), + .updateRequired(currentVersion: "0.6.4+gloss.4") + ) + XCTAssertFalse( + PDFRuntimeController.isCompatibleManagedRuntimeVersion( + "0.6.4+gloss.4" + ) + ) + } + + private func runtimeSnapshot( + currentVersion: String?, + availableVersion: String?, + updateAvailable: Bool, + operation: BabelDOCRuntimeOperation, + executableURL: URL? = URL( + fileURLWithPath: "/runtime/gloss-babeldoc" + ) + ) -> BabelDOCRuntimeSnapshot { + BabelDOCRuntimeSnapshot( + channel: .stable, + pinnedVersion: nil, + currentVersion: currentVersion, + previousVersion: nil, + availableVersion: availableVersion, + currentExecutableURL: executableURL, + updateAvailable: updateAvailable, + operation: operation, + lastError: operation == .failed ? "offline" : nil + ) + } } diff --git a/Tests/GlossCoreTests/BabelDOCRuntimeCompatibilityTests.swift b/Tests/GlossCoreTests/BabelDOCRuntimeCompatibilityTests.swift new file mode 100644 index 0000000..6e3754a --- /dev/null +++ b/Tests/GlossCoreTests/BabelDOCRuntimeCompatibilityTests.swift @@ -0,0 +1,124 @@ +import Foundation +import XCTest + +@testable import GlossCore + +final class BabelDOCRuntimeCompatibilityTests: XCTestCase { + func testMinimumCompatibleManagedRuntimeVersion() { + XCTAssertFalse( + BabelDOCRuntimeCompatibility.isCompatible( + "0.6.4+gloss.4" + ) + ) + XCTAssertTrue( + BabelDOCRuntimeCompatibility.isCompatible( + "0.6.4+gloss.5" + ) + ) + XCTAssertTrue( + BabelDOCRuntimeCompatibility.isCompatible( + "0.6.5+gloss.1" + ) + ) + XCTAssertTrue( + BabelDOCRuntimeCompatibility.isCompatible( + "0.6.5" + ) + ) + } + + func testUnprovenRuntimeVersionsFailClosed() { + let unprovenVersions: [String?] = [ + nil, + "", + "0.6.4", + "0.6.4+gloss.05", + "0.6.4+gloss.5-dev", + "0.6.4+gloss.5.1", + "0.6.4+other.9", + "not-a-version", + ] + + for version in unprovenVersions { + XCTAssertFalse( + BabelDOCRuntimeCompatibility.isCompatible(version), + "\(version ?? "nil") should not be trusted" + ) + } + } + + func testStandaloneHelperResolvesEnclosingAppVersion() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "gloss-version-test-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let contents = root.appendingPathComponent( + "Gloss.app/Contents", + isDirectory: true + ) + let helper = contents.appendingPathComponent( + "Helpers/gloss-cli" + ) + try FileManager.default.createDirectory( + at: helper.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let plist = try PropertyListSerialization.data( + fromPropertyList: [ + "CFBundleShortVersionString": "9.8.7" + ], + format: .xml, + options: 0 + ) + try plist.write( + to: contents.appendingPathComponent("Info.plist") + ) + + XCTAssertEqual( + GlossProductVersionResolver.resolve( + bundleVersion: nil, + executableURL: helper, + workingDirectoryURL: root + ), + "9.8.7" + ) + } + + func testDevelopmentCheckoutResolvesResourcesPlist() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "gloss-checkout-version-test-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let resources = root.appendingPathComponent( + "Resources", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: resources, + withIntermediateDirectories: true + ) + let plist = try PropertyListSerialization.data( + fromPropertyList: [ + "CFBundleShortVersionString": "1.2.3" + ], + format: .xml, + options: 0 + ) + try plist.write( + to: resources.appendingPathComponent("Info.plist") + ) + + XCTAssertEqual( + GlossProductVersionResolver.resolve( + bundleVersion: nil, + executableURL: nil, + workingDirectoryURL: root + ), + "1.2.3" + ) + } +} diff --git a/Tests/GlossCoreTests/BabelDOCRuntimeDistributionTests.swift b/Tests/GlossCoreTests/BabelDOCRuntimeDistributionTests.swift index 95c4a1b..04f9315 100644 --- a/Tests/GlossCoreTests/BabelDOCRuntimeDistributionTests.swift +++ b/Tests/GlossCoreTests/BabelDOCRuntimeDistributionTests.swift @@ -544,8 +544,8 @@ struct BabelDOCRuntimeDistributionTests { #expect(try Data(contentsOf: executable) == firstPayload) } - @Test("update check uses an injected URL and transport") - func updateCheckAndInstallUseInjectedURL() async throws { + @Test("update fetches signed metadata once and installs it") + func updateUsesInjectedURLAndTransport() async throws { let root = try temporaryDirectory() let payload = Data("runtime".utf8) let manifestURL = try #require(URL(string: "https://updates.example.test/custom.json")) @@ -567,17 +567,49 @@ struct BabelDOCRuntimeDistributionTests { manifestSigningPublicKey: signingPublicKey ) - let checked = try await manager.checkForUpdates(manifestURL: manifestURL) - #expect(checked.availableVersion == "2.0.0") - #expect(checked.updateAvailable) + let installed = try await manager.update(manifestURL: manifestURL) + #expect(installed.currentVersion == "2.0.0") + #expect(!installed.updateAvailable) #expect( Set(await recorder.fetchedURLs) == Set([manifestURL, signatureURL(for: manifestURL)]) ) + #expect(await recorder.fetchedURLs.count == 2) + #expect(await recorder.downloadedURLs == [assetURL]) + } - let installed = try await manager.update(manifestURL: manifestURL) + @Test("available update installs without fetching metadata again") + func installAvailableUpdateReusesVerifiedManifest() 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) + let fetchesAfterCheck = await recorder.fetchedURLs + + let installed = try await manager.installAvailableUpdate() #expect(installed.currentVersion == "2.0.0") #expect(!installed.updateAvailable) + #expect(await recorder.fetchedURLs == fetchesAfterCheck) #expect(await recorder.downloadedURLs == [assetURL]) } From 9830f3fc411874293f7e36ca5a647ba139c56770 Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Mon, 27 Jul 2026 01:04:15 -0700 Subject: [PATCH 2/9] feat: map core capabilities to CLI scenarios --- Sources/GlossCLI/CLICommandSupport.swift | 171 ++++++++ Sources/GlossCLI/GlossCommand.swift | 85 ++++ Sources/GlossCLI/PDFScenarioCommand.swift | 351 +++++++++++++++ Sources/GlossCLI/main.swift | 164 -------- Sources/GlossCore/CodexAppServerClient.swift | 41 +- Sources/GlossCore/GlossCLIInvocation.swift | 398 ++++++++++++++++++ .../GlossCore/GlossCapabilityRegistry.swift | 245 +++++++++++ .../CodexAppServerClientTests.swift | 41 ++ .../GlossCLIInvocationTests.swift | 164 ++++++++ .../GlossCapabilityRegistryTests.swift | 127 ++++++ 10 files changed, 1612 insertions(+), 175 deletions(-) create mode 100644 Sources/GlossCLI/CLICommandSupport.swift create mode 100644 Sources/GlossCLI/GlossCommand.swift create mode 100644 Sources/GlossCLI/PDFScenarioCommand.swift delete mode 100644 Sources/GlossCLI/main.swift create mode 100644 Sources/GlossCore/GlossCLIInvocation.swift create mode 100644 Sources/GlossCore/GlossCapabilityRegistry.swift create mode 100644 Tests/GlossCoreTests/GlossCLIInvocationTests.swift create mode 100644 Tests/GlossCoreTests/GlossCapabilityRegistryTests.swift diff --git a/Sources/GlossCLI/CLICommandSupport.swift b/Sources/GlossCLI/CLICommandSupport.swift new file mode 100644 index 0000000..665e5ae --- /dev/null +++ b/Sources/GlossCLI/CLICommandSupport.swift @@ -0,0 +1,171 @@ +import Foundation +import GlossCore + +enum ProviderHandle: Sendable { + case codex(CodexAppServerClient, model: String, reasoning: CodexReasoningEffort) + case llama(LlamaServerClient, model: String) + + init( + provider: TranslationProvider, + model: String?, + reasoningEffort: CodexReasoningEffort + ) { + switch provider { + case .codex: + let selectedModel = + model ?? TranslationProviderConfiguration.defaultCodexModel + self = .codex( + CodexAppServerClient( + model: model, + reasoningEffort: reasoningEffort + ), + model: selectedModel, + reasoning: reasoningEffort + ) + case .llama: + let selectedModel = + model ?? TranslationProviderConfiguration.defaultLlamaModel + self = .llama( + LlamaServerClient(model: selectedModel), + model: selectedModel + ) + } + } + + var backend: any TranslationBackend { + switch self { + case .codex(let client, _, _): + client + case .llama(let client, _): + client + } + } + + var status: TranslationProviderStatus { + switch self { + case .codex(_, let model, let reasoning): + TranslationProviderStatus( + provider: .codex, + model: model, + reasoningEffort: reasoning, + configurationRevision: "cli", + isWarm: false + ) + case .llama(_, let model): + TranslationProviderStatus( + provider: .llama, + model: model, + configurationRevision: "cli", + isWarm: false + ) + } + } + + func stop() async { + switch self { + case .codex(let client, _, _): + await client.stop() + case .llama(let client, _): + await client.stop() + } + } +} + +enum JSONOutput { + static func encode( + _ value: T + ) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return String( + decoding: try encoder.encode(value), + as: UTF8.self + ) + } +} + +final class StandardError: @unchecked Sendable { + private static let lock = NSLock() + + static func write(_ value: String) { + lock.lock() + defer { lock.unlock() } + FileHandle.standardError.write(Data(value.utf8)) + } +} + +enum Help { + static func text(for command: GlossCLICommand?) -> String { + switch command { + case .capabilities: + """ + Usage: gloss-cli capabilities --json + + Prints the capability registry, enabled business scenarios, and CLI + command mappings as stable JSON. + """ + case .text: + """ + Usage: gloss-cli text [options] [text] + + -t, --target LANGUAGE Target language (default: Chinese (Simplified)) + -p, --profile PROFILE faithful | natural | technical | academic | subtitle + -k, --kind KIND selection | webpage | document | subtitle | ocr + --provider NAME codex | llama (default: codex) + --model MODEL Codex model, Hugging Face GGUF repo, or local GGUF path + --reasoning LEVEL minimal | low | medium | high | xhigh (Codex only) + + Invokes the reusable text-translation capability. If text is omitted, + gloss-cli reads from stdin. + """ + case .browser: + """ + Usage: gloss-cli browser [options] [text] + + -t, --target LANGUAGE Target language (default: Chinese (Simplified)) + -p, --profile PROFILE faithful | natural | technical | academic | subtitle + -k, --kind webpage Browser content kind is always webpage + --provider NAME codex | llama (default: codex) + --model MODEL Codex model, Hugging Face GGUF repo, or local GGUF path + --reasoning LEVEL minimal | low | medium | high | xhigh (Codex only) + + If text is omitted, gloss-cli reads from stdin. + """ + case .pdf: + """ + Usage: gloss-cli pdf INPUT... --output DIR [options] + + -o, --output DIR Output directory (required) + --source CODE Source language code (default: en) + -t, --target LANGUAGE Target language (default: Chinese (Simplified)) + --mode MODE mono | bilingual (default: mono) + --provider NAME codex | llama (default: codex) + --model MODEL Codex model, Hugging Face GGUF repo, or local GGUF path + --reasoning LEVEL minimal | low | medium | high | xhigh (Codex only) + + Processes one or more PDFs in order while reusing one private + managed BabelDOC/layout session. Progress is written to stderr and + the generated paths are returned as a JSON array on stdout. + """ + case nil: + """ + Usage: + gloss-cli [options] [text] + gloss-cli text [options] [text] + gloss-cli browser [options] [text] + gloss-cli pdf INPUT... --output DIR [options] + gloss-cli capabilities --json + + Legacy text options: + -t, --target LANGUAGE Target language (default: Chinese (Simplified)) + -p, --profile PROFILE faithful | natural | technical | academic | subtitle + -k, --kind KIND selection | webpage | document | subtitle | ocr + --provider NAME codex | llama (default: codex) + --model MODEL Codex model, Hugging Face GGUF repo, or local GGUF path + --reasoning LEVEL minimal | low | medium | high | xhigh (Codex only) + + If text is omitted, gloss-cli reads from stdin. + """ + } + } +} diff --git a/Sources/GlossCLI/GlossCommand.swift b/Sources/GlossCLI/GlossCommand.swift new file mode 100644 index 0000000..577e193 --- /dev/null +++ b/Sources/GlossCLI/GlossCommand.swift @@ -0,0 +1,85 @@ +import Foundation +import GlossCore + +@main +struct GlossCommand { + static func main() async { + do { + let invocation = try GlossCLIInvocationParser.parse( + Array(CommandLine.arguments.dropFirst()) + ) + switch invocation { + case .capabilitiesJSON: + print( + try JSONOutput.encode( + GlossCapabilitiesReport() + ) + ) + case .help(let command): + print(Help.text(for: command)) + case .legacyText(let options): + try await translateText(options) + case .text(let options): + try await translateText(options) + case .browser(let options): + try await translateText(options) + case .pdf(let options): + print(try await PDFScenarioCommand.run(options)) + } + } catch { + StandardError.write("gloss: \(error.localizedDescription)\n") + exit(1) + } + } + + private static func translateText( + _ options: GlossCLITextOptions + ) async throws { + let source = try sourceText(from: options.textParts) + let provider = ProviderHandle( + provider: options.provider, + model: options.model, + reasoningEffort: options.reasoningEffort + ) + let broker = TranslationBroker(backend: provider.backend) + + do { + let result = try await broker.translateText( + source, + targetLanguage: options.targetLanguage, + profile: options.profile, + contentKind: options.contentKind + ) + print(result) + await provider.stop() + } catch { + await provider.stop() + throw error + } + } + + private static func sourceText( + from textParts: [String] + ) throws -> String { + let value: String + if textParts.isEmpty { + value = + String( + data: FileHandle.standardInput.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + } else { + value = textParts.joined(separator: " ") + } + guard + !value.trimmingCharacters( + in: .whitespacesAndNewlines + ).isEmpty + else { + throw GlossCLIUsageError( + "请通过参数或 stdin 提供文本。" + ) + } + return value + } +} diff --git a/Sources/GlossCLI/PDFScenarioCommand.swift b/Sources/GlossCLI/PDFScenarioCommand.swift new file mode 100644 index 0000000..2ee9cdf --- /dev/null +++ b/Sources/GlossCLI/PDFScenarioCommand.swift @@ -0,0 +1,351 @@ +import Darwin +import Foundation +import GlossCore + +enum PDFScenarioCommand { + private struct Output: Codable { + let input: String + let outputs: [String] + } + + static func run(_ options: GlossCLIPDFOptions) async throws -> String { + let inputs = try options.inputPaths.map(resolvePDF) + let outputDirectory = URL( + fileURLWithPath: options.outputDirectoryPath, + isDirectory: true + ).standardizedFileURL + try FileManager.default.createDirectory( + at: outputDirectory, + withIntermediateDirectories: true, + attributes: nil + ) + guard + let targetLanguageCode = + TranslationLanguages.babelDOCCode( + forTargetName: options.targetLanguage + ) + else { + throw GlossCLIUsageError( + "BabelDOC 暂不支持目标语言:\(options.targetLanguage)" + ) + } + + guard let glossVersion = GlossProductVersionResolver.resolve() else { + throw GlossCLIUsageError( + "无法确定 Gloss 版本,不能安全验证 BabelDOC runtime manifest。" + ) + } + let runtimeManager = try BabelDOCRuntimeManager( + currentGlossVersion: glossVersion + ) + let runtimeSnapshot = try await managedRuntime( + from: runtimeManager + ) + guard let executableURL = runtimeSnapshot.currentExecutableURL, + BabelDOCRuntimeCompatibility.isCompatible( + runtimeSnapshot.currentVersion + ) + else { + throw GlossCLIUsageError( + "BabelDOC runtime 需要 \(BabelDOCRuntimeCompatibility.minimumManagedVersion) 或更高版本。" + ) + } + let runtime = BabelDOCRuntimeLaunch( + executable: executableURL.path, + source: runtimeSnapshot.currentVersion.map { + "Gloss runtime \($0)" + } ?? "Gloss runtime", + executorExecutable: executableURL.path + ) + + let privateRoot = FileManager.default.temporaryDirectory + .appendingPathComponent( + "Gloss-CLI-PDF-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: privateRoot, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + let stateDirectory = privateRoot.appendingPathComponent( + "service-state", + isDirectory: true + ) + let service = BabelDOCServiceSession( + persistedStateDirectoryURL: stateDirectory + ) + let provider = ProviderHandle( + provider: options.provider, + model: options.model, + reasoningEffort: options.reasoningEffort + ) + let token = UUID().uuidString + UUID().uuidString + let port = try availableLoopbackPort() + let bridge = LoopbackServer( + broker: TranslationBroker(backend: provider.backend), + token: token, + version: glossVersion, + port: port, + providerStatus: { provider.status } + ) + + do { + try bridge.start() + let bridgeBaseURL = URL( + string: "http://127.0.0.1:\(port)" + )! + try await waitForBridge( + bridgeBaseURL, + token: token + ) + StandardError.write( + "pdf: 正在启动独立 BabelDOC 服务…\n" + ) + let layoutServiceBaseURL = try await service.start( + runtime: runtime + ) + let layoutCacheDirectoryURL = + await service.layoutCacheDirectoryURL + let engine = BabelDOCExternalEngine( + executorManager: service + ) + var records: [Output] = [] + + for (index, input) in inputs.enumerated() { + try Task.checkCancellation() + let jobDirectory = privateRoot.appendingPathComponent( + "job-\(index + 1)", + isDirectory: true + ) + StandardError.write( + "pdf: [\(index + 1)/\(inputs.count)] \(input.lastPathComponent)\n" + ) + let result = try await engine.translate( + BabelDOCTranslationRequest( + inputURL: input, + outputDirectory: jobDirectory, + sourceLanguageCode: options.sourceLanguageCode, + targetLanguageCode: targetLanguageCode, + bridgeBaseURL: bridgeBaseURL.appendingPathComponent( + "v1" + ), + bridgeToken: token, + outputMode: options.outputMode, + layoutServiceBaseURL: layoutServiceBaseURL, + layoutCacheDirectoryURL: + layoutCacheDirectoryURL + ), + runtime: runtime, + onProgress: { progress in + StandardError.write( + progressLine( + progress, + item: index + 1, + count: inputs.count + ) + ) + } + ) + let generated: URL? = + switch options.outputMode { + case .monolingual: + result.monolingualPDF + case .bilingual: + result.bilingualPDF + } + guard let generated else { + throw BabelDOCExternalEngineError.outputMissing + } + let destination = availableDestination( + for: input, + mode: options.outputMode, + in: outputDirectory + ) + try FileManager.default.copyItem( + at: generated, + to: destination + ) + records.append( + Output( + input: input.path, + outputs: [destination.path] + ) + ) + } + + bridge.stop() + _ = await service.stop() + await provider.stop() + try? FileManager.default.removeItem(at: privateRoot) + return try JSONOutput.encode(records) + } catch { + bridge.stop() + _ = await service.stop() + await provider.stop() + try? FileManager.default.removeItem(at: privateRoot) + throw error + } + } + + private static func managedRuntime( + from manager: BabelDOCRuntimeManager + ) async throws -> BabelDOCRuntimeSnapshot { + let snapshot = await manager.snapshot() + if snapshot.currentExecutableURL != nil, + BabelDOCRuntimeCompatibility.isCompatible( + snapshot.currentVersion + ) + { + return snapshot + } + + let progress: BabelDOCRuntimeManager.ProgressHandler = { + update in + let version = update.version.map { " \($0)" } ?? "" + StandardError.write( + "runtime: \(update.operation.rawValue)\(version)\n" + ) + } + if snapshot.currentExecutableURL == nil { + return try await manager.update(progress: progress) + } + + let checked = try await manager.checkForUpdates( + progress: progress + ) + guard checked.updateAvailable, + BabelDOCRuntimeCompatibility.isCompatible( + checked.availableVersion + ) + else { + throw GlossCLIUsageError( + "已安装 BabelDOC runtime \(snapshot.currentVersion ?? "unknown") 不兼容,且没有可用的兼容更新。" + ) + } + return try await manager.installAvailableUpdate( + progress: progress + ) + } + + private static func resolvePDF(_ path: String) throws -> URL { + let url = URL(fileURLWithPath: path).standardizedFileURL + let values = try? url.resourceValues( + forKeys: [.isRegularFileKey] + ) + guard values?.isRegularFile == true, + url.pathExtension.lowercased() == "pdf" + else { + throw GlossCLIUsageError( + "不是可读取的 PDF 文件:\(path)" + ) + } + return url + } + + private static func availableDestination( + for input: URL, + mode: BabelDOCOutputMode, + in outputDirectory: URL + ) -> URL { + let stem = input.deletingPathExtension().lastPathComponent + let suffix = + mode == .monolingual ? "gloss-mono" : "gloss-dual" + var candidate = outputDirectory.appendingPathComponent( + "\(stem)-\(suffix).pdf" + ) + var copy = 2 + while FileManager.default.fileExists(atPath: candidate.path) { + candidate = outputDirectory.appendingPathComponent( + "\(stem)-\(suffix)-\(copy).pdf" + ) + copy += 1 + } + return candidate + } + + private static func progressLine( + _ progress: BabelDOCProgressUpdate, + item: Int, + count: Int + ) -> String { + let percent = Int(progress.overallProgress.rounded()) + let stage = progress.stageName.map { " \($0)" } ?? "" + return + "pdf: [\(item)/\(count)] \(progress.phase.rawValue) \(percent)%\(stage)\n" + } + + private static func availableLoopbackPort() throws -> UInt16 { + let descriptor = socket(AF_INET, SOCK_STREAM, 0) + guard descriptor >= 0 else { + throw GlossCLIUsageError("无法创建 CLI 翻译桥接端口。") + } + defer { Darwin.close(descriptor) } + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = 0 + address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + let bindResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound( + to: sockaddr.self, + capacity: 1 + ) { + bind( + descriptor, + $0, + socklen_t(MemoryLayout.size) + ) + } + } + guard bindResult == 0 else { + throw GlossCLIUsageError("无法绑定 CLI 翻译桥接端口。") + } + + var boundAddress = sockaddr_in() + var length = socklen_t(MemoryLayout.size) + let readResult = withUnsafeMutablePointer( + to: &boundAddress + ) { pointer in + pointer.withMemoryRebound( + to: sockaddr.self, + capacity: 1 + ) { + getsockname(descriptor, $0, &length) + } + } + guard readResult == 0 else { + throw GlossCLIUsageError("无法读取 CLI 翻译桥接端口。") + } + return UInt16(bigEndian: boundAddress.sin_port) + } + + private static func waitForBridge( + _ baseURL: URL, + token: String + ) async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 0.5 + configuration.timeoutIntervalForResource = 0.5 + let session = URLSession(configuration: configuration) + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(5)) + while clock.now < deadline { + try Task.checkCancellation() + var request = URLRequest( + url: baseURL.appendingPathComponent("health") + ) + request.setValue(token, forHTTPHeaderField: "X-Gloss-Token") + if let (_, response) = try? await session.data(for: request), + (response as? HTTPURLResponse)?.statusCode == 200 + { + return + } + try await Task.sleep(for: .milliseconds(80)) + } + throw GlossCLIUsageError( + "CLI 翻译桥接服务启动超时。" + ) + } +} diff --git a/Sources/GlossCLI/main.swift b/Sources/GlossCLI/main.swift deleted file mode 100644 index 089746e..0000000 --- a/Sources/GlossCLI/main.swift +++ /dev/null @@ -1,164 +0,0 @@ -import Darwin -import Foundation -import GlossCore - -@main -struct GlossCommand { - static func main() async { - do { - let arguments = try Arguments(CommandLine.arguments.dropFirst()) - let source = try arguments.sourceText() - let backend: any TranslationBackend - let stop: @Sendable () async -> Void - switch arguments.provider { - case .codex: - let codex = CodexAppServerClient( - model: arguments.model, - reasoningEffort: arguments.reasoningEffort - ) - backend = codex - stop = { await codex.stop() } - case .llama: - let llama = LlamaServerClient( - model: arguments.model - ?? TranslationProviderConfiguration.defaultLlamaModel - ) - backend = llama - stop = { await llama.stop() } - } - let broker = TranslationBroker(backend: backend) - - do { - let result = try await broker.translateText( - source, - targetLanguage: arguments.targetLanguage, - profile: arguments.profile, - contentKind: arguments.contentKind - ) - print(result) - await stop() - } catch { - await stop() - throw error - } - } catch { - FileHandle.standardError.write(Data("gloss: \(error.localizedDescription)\n".utf8)) - exit(1) - } - } -} - -private struct Arguments { - let targetLanguage: String - let profile: TranslationProfile - let contentKind: TranslationContentKind - let provider: TranslationProvider - let model: String? - let reasoningEffort: CodexReasoningEffort - let textParts: [String] - - init(_ rawArguments: ArraySlice) throws { - var targetLanguage = "Chinese (Simplified)" - var profile: TranslationProfile = .natural - var contentKind: TranslationContentKind = .selection - var provider: TranslationProvider = .codex - var model: String? - var reasoningEffort: CodexReasoningEffort = .low - var textParts: [String] = [] - var iterator = rawArguments.makeIterator() - - while let argument = iterator.next() { - switch argument { - case "--target", "-t": - guard let value = iterator.next() else { - throw UsageError("--target 需要语言名称。") - } - targetLanguage = value - case "--profile", "-p": - guard let value = iterator.next(), let parsed = TranslationProfile(rawValue: value) else { - throw UsageError("--profile 可选 faithful、natural、technical、academic 或 subtitle。") - } - profile = parsed - case "--kind", "-k": - guard let value = iterator.next(), - let parsed = TranslationContentKind(rawValue: value) - else { - throw UsageError( - "--kind 可选 selection、webpage、document、subtitle 或 ocr。" - ) - } - contentKind = parsed - case "--provider": - guard let value = iterator.next(), - let parsed = TranslationProvider(rawValue: value) - else { - throw UsageError("--provider 可选 codex 或 llama。") - } - provider = parsed - case "--model": - guard let value = iterator.next(), !value.isEmpty else { - throw UsageError("--model 需要模型名称或 GGUF 路径。") - } - model = value - case "--reasoning": - guard let value = iterator.next(), - let parsed = CodexReasoningEffort(rawValue: value) - else { - throw UsageError( - "--reasoning 可选 minimal、low、medium、high 或 xhigh。" - ) - } - reasoningEffort = parsed - case "--help", "-h": - print(Self.help) - exit(0) - default: - textParts.append(argument) - } - } - - self.targetLanguage = targetLanguage - self.profile = profile - self.contentKind = contentKind - self.provider = provider - self.model = model - self.reasoningEffort = reasoningEffort - self.textParts = textParts - } - - func sourceText() throws -> String { - let value: String - if textParts.isEmpty { - value = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? "" - } else { - value = textParts.joined(separator: " ") - } - guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw UsageError("请通过参数或 stdin 提供文本。") - } - return value - } - - static let help = """ - Usage: gloss-cli [options] [text] - - -t, --target LANGUAGE Target language (default: Chinese (Simplified)) - -p, --profile PROFILE faithful | natural | technical | academic | subtitle - -k, --kind KIND selection | webpage | document | subtitle | ocr - --provider NAME codex | llama (default: codex) - --model MODEL Codex model, Hugging Face GGUF repo, or local GGUF path - --reasoning LEVEL minimal | low | medium | high | xhigh (Codex only) - - If text is omitted, gloss-cli reads from stdin. - """ -} - -private struct UsageError: LocalizedError { - let message: String - - init(_ message: String) { - self.message = message - } - - var errorDescription: String? { message } -} diff --git a/Sources/GlossCore/CodexAppServerClient.swift b/Sources/GlossCore/CodexAppServerClient.swift index 3a2fa23..bb4cc58 100644 --- a/Sources/GlossCore/CodexAppServerClient.swift +++ b/Sources/GlossCore/CodexAppServerClient.swift @@ -984,7 +984,8 @@ public actor CodexAppServerClient: TranslationBackend { successfulTurnsByThreadIndex.removeAll() availableThreadIndices = Array(startedThreadIDs.indices) activeThreadPriorities.removeAll() - let hedgeMilliseconds = modelWaitHedgeNanoseconds + let hedgeMilliseconds = + modelWaitHedgeNanoseconds .map { String($0 / 1_000_000) } ?? "off" runtimeLog.write( "codex", @@ -1709,7 +1710,8 @@ public actor CodexAppServerClient: TranslationBackend { static func resolveRuntime( environment: [String: String], - bundleURL: URL = Bundle.main.bundleURL + bundleURL: URL = Bundle.main.bundleURL, + executableURL: URL? = Bundle.main.executableURL ) -> CodexRuntimeLaunch? { if let configured = environment["GLOSS_CODEX_APP_SERVER_BIN"]?.nilIfBlank, FileManager.default.isExecutableFile(atPath: configured) @@ -1733,6 +1735,20 @@ public actor CodexAppServerClient: TranslationBackend { ) } + if let sibling = executableURL? + .resolvingSymlinksInPath() + .deletingLastPathComponent() + .appendingPathComponent("gloss-codex-app-server") + .path, + FileManager.default.isExecutableFile(atPath: sibling) + { + return CodexRuntimeLaunch( + executable: sibling, + argumentPrefix: [], + source: "sibling-app-server" + ) + } + guard let executable = resolveCodexExecutable(environment: environment) else { return nil } @@ -1785,9 +1801,10 @@ public actor CodexAppServerClient: TranslationBackend { _ environment: [String: String], configured: CodexReasoningEffort? ) -> CodexReasoningEffort? { - guard let rawValue = environment["GLOSS_CODEX_DOCUMENT_REASONING_EFFORT"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased(), + guard + let rawValue = environment["GLOSS_CODEX_DOCUMENT_REASONING_EFFORT"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), !rawValue.isEmpty else { return configured } if rawValue == "inherit" { return nil } @@ -2075,9 +2092,10 @@ public actor CodexAppServerClient: TranslationBackend { _ environment: [String: String], dispatchAware: Bool = false ) -> UInt64? { - guard let rawValue = environment["GLOSS_CODEX_MODEL_WAIT_HEDGE_SECONDS"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased(), + guard + let rawValue = environment["GLOSS_CODEX_MODEL_WAIT_HEDGE_SECONDS"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), !rawValue.isEmpty else { return dispatchAware @@ -2092,9 +2110,10 @@ public actor CodexAppServerClient: TranslationBackend { } static func readThreadRotationTurns(_ environment: [String: String]) -> Int? { - guard let rawValue = environment["GLOSS_CODEX_THREAD_ROTATION_TURNS"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased(), + guard + let rawValue = environment["GLOSS_CODEX_THREAD_ROTATION_TURNS"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), !rawValue.isEmpty else { return defaultThreadRotationTurns } if ["0", "off", "disabled"].contains(rawValue) { return nil } diff --git a/Sources/GlossCore/GlossCLIInvocation.swift b/Sources/GlossCore/GlossCLIInvocation.swift new file mode 100644 index 0000000..9c79af2 --- /dev/null +++ b/Sources/GlossCore/GlossCLIInvocation.swift @@ -0,0 +1,398 @@ +import Foundation + +public struct GlossCLITextOptions: Sendable { + public let targetLanguage: String + public let profile: TranslationProfile + public let contentKind: TranslationContentKind + public let provider: TranslationProvider + public let model: String? + public let reasoningEffort: CodexReasoningEffort + public let textParts: [String] + + public init( + targetLanguage: String, + profile: TranslationProfile, + contentKind: TranslationContentKind, + provider: TranslationProvider, + model: String?, + reasoningEffort: CodexReasoningEffort, + textParts: [String] + ) { + self.targetLanguage = targetLanguage + self.profile = profile + self.contentKind = contentKind + self.provider = provider + self.model = model + self.reasoningEffort = reasoningEffort + self.textParts = textParts + } +} + +public struct GlossCLIPDFOptions: Sendable { + public let inputPaths: [String] + public let outputDirectoryPath: String + public let sourceLanguageCode: String + public let targetLanguage: String + public let outputMode: BabelDOCOutputMode + public let provider: TranslationProvider + public let model: String? + public let reasoningEffort: CodexReasoningEffort + + public init( + inputPaths: [String], + outputDirectoryPath: String, + sourceLanguageCode: String, + targetLanguage: String, + outputMode: BabelDOCOutputMode, + provider: TranslationProvider, + model: String?, + reasoningEffort: CodexReasoningEffort + ) { + self.inputPaths = inputPaths + self.outputDirectoryPath = outputDirectoryPath + self.sourceLanguageCode = sourceLanguageCode + self.targetLanguage = targetLanguage + self.outputMode = outputMode + self.provider = provider + self.model = model + self.reasoningEffort = reasoningEffort + } +} + +public enum GlossCLIInvocation: Sendable { + case legacyText(GlossCLITextOptions) + case text(GlossCLITextOptions) + case browser(GlossCLITextOptions) + case pdf(GlossCLIPDFOptions) + case capabilitiesJSON + case help(GlossCLICommand?) +} + +public struct GlossCLIUsageError: LocalizedError, Equatable, Sendable { + public let message: String + + public init(_ message: String) { + self.message = message + } + + public var errorDescription: String? { message } +} + +public enum GlossCLIInvocationParser { + public static func parse( + _ arguments: [String], + registry: GlossCapabilityRegistry = .current + ) throws -> GlossCLIInvocation { + guard let first = arguments.first, + let command = GlossCLICommand(rawValue: first) + else { + if arguments.contains("--help") || arguments.contains("-h") { + return .help(nil) + } + guard registry.isEnabled(.text) else { + throw GlossCLIUsageError( + "旧式文本翻译入口所需的能力当前未启用。" + ) + } + return .legacyText( + try parseTextOptions(arguments, scenario: nil) + ) + } + + let remaining = Array(arguments.dropFirst()) + guard registry.isEnabled(command) else { + throw GlossCLIUsageError( + "\(command.rawValue) 命令所需的场景或能力当前未启用。" + ) + } + switch command { + case .capabilities: + if remaining == ["--json"] { + return .capabilitiesJSON + } + if remaining == ["--help"] || remaining == ["-h"] { + return .help(.capabilities) + } + throw GlossCLIUsageError( + "capabilities 当前仅支持 --json。" + ) + case .text: + if remaining.contains("--help") || remaining.contains("-h") { + return .help(.text) + } + return .text( + try parseTextOptions(remaining, scenario: nil) + ) + case .browser: + if remaining.contains("--help") || remaining.contains("-h") { + return .help(.browser) + } + return .browser( + try parseTextOptions( + remaining, + scenario: .browserTranslation + ) + ) + case .pdf: + return try parsePDFOptions(remaining) + } + } + + private static func parseTextOptions( + _ arguments: [String], + scenario: GlossBusinessScenario? + ) throws -> GlossCLITextOptions { + var targetLanguage = "Chinese (Simplified)" + var profile: TranslationProfile = .natural + var contentKind: TranslationContentKind = + scenario == .browserTranslation ? .webpage : .selection + var provider: TranslationProvider = .codex + var model: String? + var reasoningEffort: CodexReasoningEffort = .low + var textParts: [String] = [] + var iterator = arguments.makeIterator() + + while let argument = iterator.next() { + switch argument { + case "--target", "-t": + targetLanguage = try requiredValue( + iterator.next(), + option: "--target", + description: "语言名称" + ) + case "--profile", "-p": + let value = try requiredValue( + iterator.next(), + option: "--profile", + description: "翻译风格" + ) + guard let parsed = TranslationProfile(rawValue: value) else { + throw GlossCLIUsageError( + "--profile 可选 faithful、natural、technical、academic 或 subtitle。" + ) + } + profile = parsed + case "--kind", "-k": + let value = try requiredValue( + iterator.next(), + option: "--kind", + description: "内容类型" + ) + guard let parsed = TranslationContentKind(rawValue: value) else { + throw GlossCLIUsageError( + "--kind 可选 selection、webpage、document、subtitle 或 ocr。" + ) + } + if scenario == .browserTranslation, parsed != .webpage { + throw GlossCLIUsageError( + "browser 场景的 --kind 必须是 webpage。" + ) + } + contentKind = parsed + case "--provider": + provider = try parseProvider(iterator.next()) + case "--model": + model = try requiredValue( + iterator.next(), + option: "--model", + description: "模型名称或 GGUF 路径" + ) + case "--reasoning": + reasoningEffort = try parseReasoning(iterator.next()) + case "--": + while let text = iterator.next() { + textParts.append(text) + } + default: + textParts.append(argument) + } + } + + return GlossCLITextOptions( + targetLanguage: targetLanguage, + profile: profile, + contentKind: contentKind, + provider: provider, + model: model, + reasoningEffort: reasoningEffort, + textParts: textParts + ) + } + + private static func parsePDFOptions( + _ arguments: [String] + ) throws -> GlossCLIInvocation { + var inputPaths: [String] = [] + var outputDirectoryPath: String? + var sourceLanguageCode = "en" + var targetLanguage = "Chinese (Simplified)" + var outputMode: BabelDOCOutputMode = .monolingual + var provider: TranslationProvider = .codex + var model: String? + var reasoningEffort: CodexReasoningEffort = .low + var iterator = arguments.makeIterator() + + while let argument = iterator.next() { + switch argument { + case "--output", "-o": + outputDirectoryPath = try requiredValue( + iterator.next(), + option: "--output", + description: "输出目录" + ) + case "--source": + sourceLanguageCode = try requiredValue( + iterator.next(), + option: "--source", + description: "源语言代码" + ) + case "--target", "-t": + targetLanguage = try requiredValue( + iterator.next(), + option: "--target", + description: "语言名称" + ) + case "--mode": + let value = try requiredValue( + iterator.next(), + option: "--mode", + description: "输出模式" + ) + switch value { + case "mono", "monolingual": + outputMode = .monolingual + case "dual", "bilingual": + outputMode = .bilingual + default: + throw GlossCLIUsageError( + "--mode 可选 mono 或 bilingual。" + ) + } + case "--provider": + provider = try parseProvider(iterator.next()) + case "--model": + model = try requiredValue( + iterator.next(), + option: "--model", + description: "模型名称或 GGUF 路径" + ) + case "--reasoning": + reasoningEffort = try parseReasoning(iterator.next()) + case "--help", "-h": + return .help(.pdf) + default: + guard !argument.hasPrefix("-") else { + throw GlossCLIUsageError("未知的 pdf 选项:\(argument)") + } + inputPaths.append(argument) + } + } + + guard !inputPaths.isEmpty else { + throw GlossCLIUsageError("pdf 需要至少一个 INPUT PDF 路径。") + } + guard let outputDirectoryPath else { + throw GlossCLIUsageError("pdf 需要 --output DIR。") + } + guard isLanguageCode(sourceLanguageCode) else { + throw GlossCLIUsageError("--source 需要有效的语言代码,例如 en。") + } + guard + TranslationLanguages.babelDOCCode( + forTargetName: targetLanguage + ) != nil + else { + throw GlossCLIUsageError( + "BabelDOC 暂不支持目标语言:\(targetLanguage)" + ) + } + + return .pdf( + GlossCLIPDFOptions( + inputPaths: inputPaths, + outputDirectoryPath: outputDirectoryPath, + sourceLanguageCode: sourceLanguageCode, + targetLanguage: targetLanguage, + outputMode: outputMode, + provider: provider, + model: model, + reasoningEffort: reasoningEffort + ) + ) + } + + private static func parseProvider( + _ value: String? + ) throws -> TranslationProvider { + guard let value, let parsed = TranslationProvider(rawValue: value) else { + throw GlossCLIUsageError("--provider 可选 codex 或 llama。") + } + return parsed + } + + private static func parseReasoning( + _ value: String? + ) throws -> CodexReasoningEffort { + guard let value, let parsed = CodexReasoningEffort(rawValue: value) else { + throw GlossCLIUsageError( + "--reasoning 可选 minimal、low、medium、high 或 xhigh。" + ) + } + return parsed + } + + private static func requiredValue( + _ value: String?, + option: String, + description: String + ) throws -> String { + guard let value, + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw GlossCLIUsageError("\(option) 需要\(description)。") + } + return value + } + + private static func isLanguageCode(_ value: String) -> Bool { + value.range( + of: #"^[A-Za-z]{2,3}(?:-[A-Za-z]{2,4})?$"#, + options: .regularExpression + ) != nil + } +} + +public struct GlossCapabilitiesReport: Codable, Equatable, Sendable { + public let schemaVersion: Int + public let availableCoreCapabilities: [GlossCapability] + public let enabledCoreCapabilities: [GlossCapability] + public let enabledCapabilities: [GlossCapability] + public let availableScenarios: [GlossBusinessScenario] + public let enabledScenarios: [GlossBusinessScenario] + public let commandMappings: [GlossCLICommandMapping] + + public init( + registry: GlossCapabilityRegistry = .current + ) { + schemaVersion = 1 + availableCoreCapabilities = + GlossCapabilityRegistry.coreExecutionCapabilities.sorted { + $0.rawValue < $1.rawValue + } + enabledCoreCapabilities = registry.enabledCoreCapabilities.sorted { + $0.rawValue < $1.rawValue + } + enabledCapabilities = registry.enabledCapabilities.sorted { + $0.rawValue < $1.rawValue + } + availableScenarios = GlossBusinessScenario.allCases.sorted { + $0.rawValue < $1.rawValue + } + enabledScenarios = registry.enabledScenarios.sorted { + $0.rawValue < $1.rawValue + } + commandMappings = registry.commandMappings.sorted { + $0.command.rawValue < $1.command.rawValue + } + } +} diff --git a/Sources/GlossCore/GlossCapabilityRegistry.swift b/Sources/GlossCore/GlossCapabilityRegistry.swift new file mode 100644 index 0000000..4b18895 --- /dev/null +++ b/Sources/GlossCore/GlossCapabilityRegistry.swift @@ -0,0 +1,245 @@ +/// Reusable building blocks that can be shared by App and CLI product surfaces. +public enum GlossCapability: String, CaseIterable, Codable, Sendable { + case textTranslation + case documentTranslation + case imageTextRecognition + case translationLoopbackBridge + case pdfLayoutAnalysis + case pdfExport + case providerConfiguration + case languageConfiguration + case appUpdates + case diagnostics + case launchAtLogin + case browserBridge + case chromeExtension + case safariExtension + case pdfRuntime + case pdfBatchQueue + case clipboardText + case clipboardImage + case screenshotCapture + case selectionCapture + case automaticSelection + case appExclusions + case glossaryManagement + case translationHistory +} + +/// User-facing workflows composed from one or more capabilities. +public enum GlossBusinessScenario: String, CaseIterable, Codable, Sendable { + case browserTranslation + case pdfTranslation + case clipboardTranslation + case imageTranslation + case selectionTranslation + case glossaryManagement + case translationHistory + + public var requiredCapabilities: Set { + switch self { + case .browserTranslation: + [ + .textTranslation, + .translationLoopbackBridge, + .providerConfiguration, + .languageConfiguration, + .browserBridge, + .chromeExtension, + .safariExtension, + ] + case .pdfTranslation: + [ + .textTranslation, + .documentTranslation, + .translationLoopbackBridge, + .pdfLayoutAnalysis, + .pdfExport, + .providerConfiguration, + .languageConfiguration, + .pdfRuntime, + .pdfBatchQueue, + ] + case .clipboardTranslation: + [ + .textTranslation, + .providerConfiguration, + .languageConfiguration, + .clipboardText, + ] + case .imageTranslation: + [ + .textTranslation, + .imageTextRecognition, + .providerConfiguration, + .languageConfiguration, + .clipboardImage, + .screenshotCapture, + ] + case .selectionTranslation: + [ + .textTranslation, + .providerConfiguration, + .languageConfiguration, + .selectionCapture, + .automaticSelection, + .appExclusions, + ] + case .glossaryManagement: + [.glossaryManagement] + case .translationHistory: + [.translationHistory] + } + } +} + +/// Explicit CLI entry points. Both the parser and capability reporting use this +/// enum so product scenario names cannot drift from their command mappings. +public enum GlossCLICommand: String, CaseIterable, Codable, Sendable { + case capabilities + case text + case browser + case pdf + + public var businessScenario: GlossBusinessScenario? { + switch self { + case .capabilities: + nil + case .text: + nil + case .browser: + .browserTranslation + case .pdf: + .pdfTranslation + } + } + + /// Capabilities exercised by the command itself. These can be narrower than + /// the complete App scenario: `browser` translates already-extracted webpage + /// text and therefore does not claim to drive Safari or Chrome. + public var requiredCapabilities: Set { + switch self { + case .capabilities: + [] + case .text, .browser: + [ + .textTranslation, + .providerConfiguration, + .languageConfiguration, + ] + case .pdf: + GlossBusinessScenario.pdfTranslation.requiredCapabilities + } + } +} + +public struct GlossCLICommandMapping: Codable, Equatable, Sendable { + public let command: GlossCLICommand + public let scenario: GlossBusinessScenario? + public let enabled: Bool + public let requiredCapabilities: [GlossCapability] + public let scenarioCapabilities: [GlossCapability] + + public init( + command: GlossCLICommand, + scenario: GlossBusinessScenario?, + enabled: Bool, + requiredCapabilities: [GlossCapability], + scenarioCapabilities: [GlossCapability] + ) { + self.command = command + self.scenario = scenario + self.enabled = enabled + self.requiredCapabilities = requiredCapabilities + self.scenarioCapabilities = scenarioCapabilities + } +} + +public struct GlossCapabilityRegistry: Equatable, Sendable { + /// The focused product surface. Secondary scenarios remain defined and can be + /// restored by constructing a registry with a larger set. + public static let defaultEnabledScenarios: Set = [ + .browserTranslation, + .pdfTranslation, + ] + + /// Operational controls are not business scenarios and remain available + /// regardless of which translation surfaces are currently promoted. + public static let infrastructureCapabilities: Set = [ + .providerConfiguration, + .languageConfiguration, + .appUpdates, + .diagnostics, + .launchAtLogin, + ] + + /// Reusable execution and integration units that business scenarios compose. + /// Configuration and operational controls remain regular capabilities, but + /// are intentionally excluded from the core execution list. + public static let coreExecutionCapabilities: Set = [ + .textTranslation, + .documentTranslation, + .imageTextRecognition, + .translationLoopbackBridge, + .browserBridge, + .chromeExtension, + .safariExtension, + .pdfLayoutAnalysis, + .pdfRuntime, + .pdfBatchQueue, + .pdfExport, + ] + + public static let current = GlossCapabilityRegistry() + + public let enabledScenarios: Set + + public init( + enabledScenarios: Set = Self.defaultEnabledScenarios + ) { + self.enabledScenarios = enabledScenarios + } + + public var enabledCapabilities: Set { + enabledScenarios.reduce(into: Self.infrastructureCapabilities) { + $0.formUnion($1.requiredCapabilities) + } + } + + public var enabledCoreCapabilities: Set { + enabledCapabilities.intersection(Self.coreExecutionCapabilities) + } + + public var commandMappings: [GlossCLICommandMapping] { + GlossCLICommand.allCases.map { command in + let scenario = command.businessScenario + let requiredCapabilities = command.requiredCapabilities.sorted { + $0.rawValue < $1.rawValue + } + let scenarioCapabilities = + scenario?.requiredCapabilities.sorted { + $0.rawValue < $1.rawValue + } ?? [] + return GlossCLICommandMapping( + command: command, + scenario: scenario, + enabled: (scenario.map(isEnabled) ?? true) + && requiredCapabilities.allSatisfy(supports), + requiredCapabilities: requiredCapabilities, + scenarioCapabilities: scenarioCapabilities + ) + } + } + + public func isEnabled(_ command: GlossCLICommand) -> Bool { + commandMappings.first { $0.command == command }?.enabled == true + } + + public func isEnabled(_ scenario: GlossBusinessScenario) -> Bool { + enabledScenarios.contains(scenario) + } + + public func supports(_ capability: GlossCapability) -> Bool { + enabledCapabilities.contains(capability) + } +} diff --git a/Tests/GlossCoreTests/CodexAppServerClientTests.swift b/Tests/GlossCoreTests/CodexAppServerClientTests.swift index 977c3d4..131c472 100644 --- a/Tests/GlossCoreTests/CodexAppServerClientTests.swift +++ b/Tests/GlossCoreTests/CodexAppServerClientTests.swift @@ -196,6 +196,47 @@ final class CodexAppServerClientTests: XCTestCase { XCTAssertEqual(launch.source, "external-cli") } + func testHelperExecutableFindsSiblingBundledAppServer() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let helpers = root.appendingPathComponent( + "Gloss.app/Contents/Helpers", + isDirectory: true + ) + let cli = helpers.appendingPathComponent("gloss-cli") + let runtime = helpers.appendingPathComponent( + "gloss-codex-app-server" + ) + try makeExecutable(at: cli) + try makeExecutable(at: runtime) + let homebrewBin = root.appendingPathComponent( + "homebrew-bin", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: homebrewBin, + withIntermediateDirectories: true + ) + let symlink = homebrewBin.appendingPathComponent("gloss-cli") + try FileManager.default.createSymbolicLink( + at: symlink, + withDestinationURL: cli + ) + defer { try? FileManager.default.removeItem(at: root) } + + let launch = try XCTUnwrap( + CodexAppServerClient.resolveRuntime( + environment: ["GLOSS_CODEX_BIN": "/unavailable/codex"], + bundleURL: root.appendingPathComponent("NotAnApp"), + executableURL: symlink + ) + ) + + XCTAssertEqual(launch.executable, runtime.path) + XCTAssertEqual(launch.argumentPrefix, []) + XCTAssertEqual(launch.source, "sibling-app-server") + } + func testFastLaunchArgumentsDisableUnusedCodexSubsystems() { let arguments = CodexAppServerClient.fastLaunchArguments( reasoningEffort: .low, diff --git a/Tests/GlossCoreTests/GlossCLIInvocationTests.swift b/Tests/GlossCoreTests/GlossCLIInvocationTests.swift new file mode 100644 index 0000000..03eb69e --- /dev/null +++ b/Tests/GlossCoreTests/GlossCLIInvocationTests.swift @@ -0,0 +1,164 @@ +import Foundation +import XCTest + +@testable import GlossCore + +final class GlossCLIInvocationTests: XCTestCase { + func testLegacyFlatTextInvocationRemainsCompatible() throws { + let invocation = try GlossCLIInvocationParser.parse([ + "--target", "Japanese", + "--profile", "technical", + "Hello", "world", + ]) + guard case .legacyText(let options) = invocation else { + return XCTFail("Expected legacy text invocation") + } + + XCTAssertEqual(options.targetLanguage, "Japanese") + XCTAssertEqual(options.profile, .technical) + XCTAssertEqual(options.contentKind, .selection) + XCTAssertEqual(options.textParts, ["Hello", "world"]) + } + + func testBrowserCommandMapsToWebpageScenario() throws { + let invocation = try GlossCLIInvocationParser.parse([ + GlossCLICommand.browser.rawValue, + "--provider", "llama", + "Browser text", + ]) + guard case .browser(let options) = invocation else { + return XCTFail("Expected browser invocation") + } + + XCTAssertEqual(options.contentKind, .webpage) + XCTAssertEqual(options.provider, .llama) + XCTAssertEqual(options.textParts, ["Browser text"]) + } + + func testExplicitTextCommandMapsToCoreTranslationCapability() throws { + let invocation = try GlossCLIInvocationParser.parse([ + GlossCLICommand.text.rawValue, + "--kind", "document", + "Document text", + ]) + guard case .text(let options) = invocation else { + return XCTFail("Expected text invocation") + } + + XCTAssertEqual(options.contentKind, .document) + XCTAssertEqual(options.textParts, ["Document text"]) + } + + func testBrowserRejectsNonWebpageKind() { + XCTAssertThrowsError( + try GlossCLIInvocationParser.parse([ + GlossCLICommand.browser.rawValue, + "--kind", "selection", + "Text", + ]) + ) + } + + func testPDFCommandAcceptsBatchAndUsesDocumentDefaults() throws { + let invocation = try GlossCLIInvocationParser.parse([ + GlossCLICommand.pdf.rawValue, + "one.pdf", + "two.pdf", + "--output", "/tmp/output", + ]) + guard case .pdf(let options) = invocation else { + return XCTFail("Expected PDF invocation") + } + + XCTAssertEqual(options.inputPaths, ["one.pdf", "two.pdf"]) + XCTAssertEqual(options.outputDirectoryPath, "/tmp/output") + XCTAssertEqual(options.sourceLanguageCode, "en") + XCTAssertEqual(options.targetLanguage, "Chinese (Simplified)") + XCTAssertEqual(options.outputMode, .monolingual) + } + + func testCapabilitiesJSONCommandIsExplicit() throws { + let invocation = try GlossCLIInvocationParser.parse([ + GlossCLICommand.capabilities.rawValue, + "--json", + ]) + guard case .capabilitiesJSON = invocation else { + return XCTFail("Expected capabilities JSON invocation") + } + } + + func testDisabledScenarioCannotBeDispatched() { + let registry = GlossCapabilityRegistry( + enabledScenarios: [.pdfTranslation] + ) + + XCTAssertThrowsError( + try GlossCLIInvocationParser.parse( + [GlossCLICommand.browser.rawValue, "Text"], + registry: registry + ) + ) + XCTAssertNoThrow( + try GlossCLIInvocationParser.parse( + [ + GlossCLICommand.pdf.rawValue, + "one.pdf", + "--output", "/tmp/output", + ], + registry: registry + ) + ) + } + + func testLegacyTextInvocationCannotBypassCapabilityRegistry() { + let registry = GlossCapabilityRegistry(enabledScenarios: []) + + XCTAssertThrowsError( + try GlossCLIInvocationParser.parse( + ["Legacy text"], + registry: registry + ) + ) + XCTAssertNoThrow( + try GlossCLIInvocationParser.parse( + ["--help"], + registry: registry + ) + ) + } + + func testCapabilityReportUsesStableSortedCollections() { + let report = GlossCapabilitiesReport() + + XCTAssertEqual( + report.availableCoreCapabilities.map(\.rawValue), + report.availableCoreCapabilities.map(\.rawValue).sorted() + ) + XCTAssertEqual( + report.enabledCoreCapabilities.map(\.rawValue), + report.enabledCoreCapabilities.map(\.rawValue).sorted() + ) + XCTAssertEqual( + report.availableScenarios.map(\.rawValue), + report.availableScenarios.map(\.rawValue).sorted() + ) + XCTAssertEqual( + report.enabledScenarios.map(\.rawValue), + report.enabledScenarios.map(\.rawValue).sorted() + ) + XCTAssertEqual( + report.commandMappings.map(\.command.rawValue), + report.commandMappings.map(\.command.rawValue).sorted() + ) + for mapping in report.commandMappings { + XCTAssertEqual( + mapping.requiredCapabilities.map(\.rawValue), + mapping.requiredCapabilities.map(\.rawValue).sorted() + ) + XCTAssertEqual( + mapping.scenarioCapabilities.map(\.rawValue), + mapping.scenarioCapabilities.map(\.rawValue).sorted() + ) + } + } +} diff --git a/Tests/GlossCoreTests/GlossCapabilityRegistryTests.swift b/Tests/GlossCoreTests/GlossCapabilityRegistryTests.swift new file mode 100644 index 0000000..6c616bc --- /dev/null +++ b/Tests/GlossCoreTests/GlossCapabilityRegistryTests.swift @@ -0,0 +1,127 @@ +import XCTest + +@testable import GlossCore + +final class GlossCapabilityRegistryTests: XCTestCase { + func testDefaultRegistryEnablesBrowserAndPDFScenariosOnly() { + let registry = GlossCapabilityRegistry() + + XCTAssertEqual( + registry.enabledScenarios, + [.browserTranslation, .pdfTranslation] + ) + } + + func testDefaultBrowserScenarioSupportsBothExtensionFamilies() { + let registry = GlossCapabilityRegistry() + + XCTAssertTrue(registry.supports(.textTranslation)) + XCTAssertTrue(registry.supports(.translationLoopbackBridge)) + XCTAssertTrue(registry.supports(.browserBridge)) + XCTAssertTrue(registry.supports(.chromeExtension)) + XCTAssertTrue(registry.supports(.safariExtension)) + } + + func testDefaultPDFScenarioSupportsRuntimeAndBatchQueue() { + let registry = GlossCapabilityRegistry() + + XCTAssertTrue(registry.supports(.textTranslation)) + XCTAssertTrue(registry.supports(.documentTranslation)) + XCTAssertTrue(registry.supports(.translationLoopbackBridge)) + XCTAssertTrue(registry.supports(.pdfLayoutAnalysis)) + XCTAssertTrue(registry.supports(.pdfRuntime)) + XCTAssertTrue(registry.supports(.pdfBatchQueue)) + XCTAssertTrue(registry.supports(.pdfExport)) + } + + func testDefaultRegistryKeepsInfrastructureButHidesSecondaryTools() { + let registry = GlossCapabilityRegistry() + + XCTAssertTrue(registry.supports(.providerConfiguration)) + XCTAssertTrue(registry.supports(.languageConfiguration)) + XCTAssertTrue(registry.supports(.appUpdates)) + XCTAssertTrue(registry.supports(.diagnostics)) + XCTAssertTrue(registry.supports(.launchAtLogin)) + XCTAssertFalse(registry.supports(.clipboardText)) + XCTAssertFalse(registry.supports(.clipboardImage)) + XCTAssertFalse(registry.supports(.screenshotCapture)) + XCTAssertFalse(registry.supports(.selectionCapture)) + XCTAssertFalse(registry.supports(.automaticSelection)) + XCTAssertFalse(registry.supports(.appExclusions)) + XCTAssertFalse(registry.supports(.glossaryManagement)) + XCTAssertFalse(registry.supports(.translationHistory)) + } + + func testSecondaryScenarioCanBeRestoredWithoutChangingTheRegistry() { + let registry = GlossCapabilityRegistry( + enabledScenarios: [ + .browserTranslation, + .pdfTranslation, + .selectionTranslation, + ] + ) + + XCTAssertTrue(registry.supports(.selectionCapture)) + XCTAssertTrue(registry.supports(.automaticSelection)) + XCTAssertTrue(registry.supports(.appExclusions)) + } + + func testCoreCapabilitiesAndCommandMappingsAreStable() { + let registry = GlossCapabilityRegistry() + + XCTAssertEqual( + registry.enabledCoreCapabilities, + [ + .browserBridge, + .chromeExtension, + .documentTranslation, + .pdfBatchQueue, + .pdfExport, + .pdfLayoutAnalysis, + .pdfRuntime, + .safariExtension, + .textTranslation, + .translationLoopbackBridge, + ] + ) + XCTAssertEqual( + registry.commandMappings.map(\.command), + [.capabilities, .text, .browser, .pdf] + ) + XCTAssertEqual( + registry.commandMappings.map(\.enabled), + [true, true, true, true] + ) + let browser = registry.commandMappings.first { + $0.command == .browser + } + XCTAssertEqual( + browser?.requiredCapabilities, + [ + .languageConfiguration, + .providerConfiguration, + .textTranslation, + ] + ) + XCTAssertEqual( + browser?.scenarioCapabilities, + GlossBusinessScenario.browserTranslation.requiredCapabilities.sorted { + $0.rawValue < $1.rawValue + } + ) + let pdf = registry.commandMappings.first { + $0.command == .pdf + } + XCTAssertEqual(pdf?.scenario, .pdfTranslation) + XCTAssertEqual( + pdf?.requiredCapabilities, + GlossBusinessScenario.pdfTranslation.requiredCapabilities.sorted { + $0.rawValue < $1.rawValue + } + ) + XCTAssertEqual( + pdf?.scenarioCapabilities, + pdf?.requiredCapabilities + ) + } +} From 6d8bf9b76aea626a3fb0b90a314524518d46a828 Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Mon, 27 Jul 2026 01:04:21 -0700 Subject: [PATCH 3/9] feat: verify signed Gloss release metadata --- Scripts/sign_release_manifest.swift | 88 +++ .../GlossCore/GlossAppUpdateDiscovery.swift | 698 ++++++++++++++++++ .../GlossAppUpdateDiscoveryTests.swift | 417 +++++++++++ 3 files changed, 1203 insertions(+) create mode 100644 Scripts/sign_release_manifest.swift create mode 100644 Sources/GlossCore/GlossAppUpdateDiscovery.swift create mode 100644 Tests/GlossCoreTests/GlossAppUpdateDiscoveryTests.swift diff --git a/Scripts/sign_release_manifest.swift b/Scripts/sign_release_manifest.swift new file mode 100644 index 0000000..68d5b26 --- /dev/null +++ b/Scripts/sign_release_manifest.swift @@ -0,0 +1,88 @@ +#!/usr/bin/env swift + +import CryptoKit +import Foundation + +private let expectedPublicKey = Data( + base64Encoded: "FtPLO0dyvSMP4BUSZtk4ROgObX6F2GAToLeNfOygcVY=" +)! + +private enum SigningError: LocalizedError { + case usage + case missingSigningKey + case invalidSigningKey + case unexpectedPublicKey + case invalidSignature + + var errorDescription: String? { + switch self { + case .usage: + "Usage: sign_release_manifest.swift [signature-path]" + case .missingSigningKey: + "GLOSS_APP_UPDATE_MANIFEST_SIGNING_KEY is required." + case .invalidSigningKey: + "GLOSS_APP_UPDATE_MANIFEST_SIGNING_KEY must be a Base64-encoded Ed25519 private key." + case .unexpectedPublicKey: + "The manifest signing key does not match Gloss's pinned public key." + case .invalidSignature: + "Failed to create a 64-byte Ed25519 signature." + } + } +} + +private func run() throws { + let arguments = CommandLine.arguments.dropFirst() + guard arguments.count == 1 || arguments.count == 2 else { + throw SigningError.usage + } + + let manifestURL = URL(fileURLWithPath: String(arguments[arguments.startIndex])) + let signatureURL: URL + if arguments.count == 2 { + signatureURL = URL(fileURLWithPath: String(arguments[arguments.index(after: arguments.startIndex)])) + } else { + signatureURL = URL(fileURLWithPath: manifestURL.path + ".sig") + } + + guard + let encodedPrivateKey = ProcessInfo.processInfo.environment[ + "GLOSS_APP_UPDATE_MANIFEST_SIGNING_KEY" + ]?.trimmingCharacters(in: .whitespacesAndNewlines), + !encodedPrivateKey.isEmpty + else { + throw SigningError.missingSigningKey + } + guard let privateKeyData = Data(base64Encoded: encodedPrivateKey) else { + throw SigningError.invalidSigningKey + } + + let privateKey: Curve25519.Signing.PrivateKey + do { + privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData) + } catch { + throw SigningError.invalidSigningKey + } + guard privateKey.publicKey.rawRepresentation == expectedPublicKey else { + throw SigningError.unexpectedPublicKey + } + + let manifestData = try Data(contentsOf: manifestURL) + let signature = try privateKey.signature(for: manifestData) + let publicKey = try Curve25519.Signing.PublicKey( + rawRepresentation: expectedPublicKey + ) + guard signature.count == 64, + publicKey.isValidSignature(signature, for: manifestData) + else { + throw SigningError.invalidSignature + } + + try signature.base64EncodedData().write(to: signatureURL, options: .atomic) +} + +do { + try run() +} catch { + fputs("sign_release_manifest.swift: \(error.localizedDescription)\n", stderr) + exit(1) +} diff --git a/Sources/GlossCore/GlossAppUpdateDiscovery.swift b/Sources/GlossCore/GlossAppUpdateDiscovery.swift new file mode 100644 index 0000000..8748269 --- /dev/null +++ b/Sources/GlossCore/GlossAppUpdateDiscovery.swift @@ -0,0 +1,698 @@ +import CryptoKit +import Foundation + +public struct GlossSemanticVersion: Comparable, CustomStringConvertible, Sendable { + public let major: Int + public let minor: Int + public let patch: Int + public let prerelease: [String] + public let buildMetadata: [String] + + public init?(_ value: String) { + let buildParts = value.split(separator: "+", maxSplits: 1, omittingEmptySubsequences: false) + guard buildParts.count <= 2, + buildParts.first?.isEmpty == false + else { + return nil + } + + let precedence = buildParts[0] + let precedenceParts = precedence.split( + separator: "-", + maxSplits: 1, + omittingEmptySubsequences: false + ) + guard precedenceParts.count <= 2, + precedenceParts.first?.isEmpty == false + else { + return nil + } + + let core = precedenceParts[0].split( + separator: ".", + omittingEmptySubsequences: false + ) + guard core.count == 3, + let major = Self.parseCoreNumber(core[0]), + let minor = Self.parseCoreNumber(core[1]), + let patch = Self.parseCoreNumber(core[2]) + else { + return nil + } + + let prerelease = + precedenceParts.count == 2 + ? precedenceParts[1].split( + separator: ".", + omittingEmptySubsequences: false + ).map(String.init) + : [] + guard Self.validateIdentifiers(prerelease, rejectNumericLeadingZeroes: true) else { + return nil + } + + let buildMetadata = + buildParts.count == 2 + ? buildParts[1].split( + separator: ".", + omittingEmptySubsequences: false + ).map(String.init) + : [] + guard Self.validateIdentifiers(buildMetadata, rejectNumericLeadingZeroes: false) else { + return nil + } + + self.major = major + self.minor = minor + self.patch = patch + self.prerelease = prerelease + self.buildMetadata = buildMetadata + } + + public var description: String { + var value = "\(major).\(minor).\(patch)" + if !prerelease.isEmpty { + value += "-\(prerelease.joined(separator: "."))" + } + if !buildMetadata.isEmpty { + value += "+\(buildMetadata.joined(separator: "."))" + } + return value + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + !(lhs < rhs) && !(rhs < lhs) + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + let lhsCore = [lhs.major, lhs.minor, lhs.patch] + let rhsCore = [rhs.major, rhs.minor, rhs.patch] + if lhsCore != rhsCore { + return lhsCore.lexicographicallyPrecedes(rhsCore) + } + + if lhs.prerelease.isEmpty || rhs.prerelease.isEmpty { + return !lhs.prerelease.isEmpty && rhs.prerelease.isEmpty + } + + for (left, right) in zip(lhs.prerelease, rhs.prerelease) { + guard left != right else { + continue + } + let leftIsNumeric = left.allSatisfy(\.isNumber) + let rightIsNumeric = right.allSatisfy(\.isNumber) + switch (leftIsNumeric, rightIsNumeric) { + case (true, true): + if left.count != right.count { + return left.count < right.count + } + return left < right + case (true, false): + return true + case (false, true): + return false + case (false, false): + return left < right + } + } + return lhs.prerelease.count < rhs.prerelease.count + } + + private static func parseCoreNumber(_ value: Substring) -> Int? { + guard !value.isEmpty, + value.allSatisfy(\.isNumber), + value == "0" || value.first != "0" + else { + return nil + } + return Int(value) + } + + private static func validateIdentifiers( + _ identifiers: [String], + rejectNumericLeadingZeroes: Bool + ) -> Bool { + identifiers.allSatisfy { identifier in + guard !identifier.isEmpty, + identifier.utf8.allSatisfy({ + ($0 >= 48 && $0 <= 57) + || ($0 >= 65 && $0 <= 90) + || ($0 >= 97 && $0 <= 122) + || $0 == 45 + }) + else { + return false + } + return !rejectNumericLeadingZeroes + || !identifier.allSatisfy(\.isNumber) + || identifier == "0" + || identifier.first != "0" + } + } +} + +public struct GlossAppReleaseManifest: 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 init( + operatingSystem: String, + architecture: String, + url: URL, + sha256: String, + size: Int64 + ) { + self.operatingSystem = operatingSystem + self.architecture = architecture + self.url = url + self.sha256 = sha256 + self.size = size + } + } + + public struct HomebrewCask: Codable, Equatable, Sendable { + public let token: String + public let url: URL + public let sha256: String + public let size: Int64 + + public init( + token: String = GlossHomebrewInstallationDetector.caskToken, + url: URL, + sha256: String, + size: Int64 + ) { + self.token = token + self.url = url + self.sha256 = sha256 + self.size = size + } + } + + public let schemaVersion: Int + public let channel: String + public let version: String + public let releaseTag: String + public let publishedAt: String + public let minimumMacOSVersion: String + public let assets: [Asset] + public let homebrewCask: HomebrewCask + + public init( + schemaVersion: Int = 2, + channel: String = "stable", + version: String, + releaseTag: String, + publishedAt: String, + minimumMacOSVersion: String, + assets: [Asset], + homebrewCask: HomebrewCask + ) { + self.schemaVersion = schemaVersion + self.channel = channel + self.version = version + self.releaseTag = releaseTag + self.publishedAt = publishedAt + self.minimumMacOSVersion = minimumMacOSVersion + self.assets = assets + self.homebrewCask = homebrewCask + } +} + +public enum GlossAppArchitecture { + public static var current: String { + #if arch(arm64) + "arm64" + #elseif arch(x86_64) + "x86_64" + #else + "unsupported" + #endif + } +} + +public enum GlossAppReleaseEndpoint { + public static let manifestURL = URL( + string: + "https://github.com/SunChJ/gloss-releases/releases/latest/download/gloss-release-manifest.json" + )! + public static let signatureURL = URL( + string: + "https://github.com/SunChJ/gloss-releases/releases/latest/download/gloss-release-manifest.json.sig" + )! + public static let releasesURL = URL( + string: "https://github.com/SunChJ/gloss-releases/releases" + )! + + public static func releasePageURL(for tag: String) -> URL { + releasesURL.appendingPathComponent("tag").appendingPathComponent(tag) + } +} + +public enum GlossAppUpdateError: LocalizedError, Equatable, Sendable { + case invalidCurrentVersion(String) + case invalidSigningKey + case invalidManifestSignature + case invalidManifest(String) + + public var errorDescription: String? { + switch self { + case .invalidCurrentVersion(let version): + "当前 Gloss 版本无效:\(version)" + case .invalidSigningKey: + "Gloss 内置的应用更新 manifest 签名公钥无效。" + case .invalidManifestSignature: + "Gloss 应用更新 manifest 的 Ed25519 签名无效。" + case .invalidManifest(let reason): + "Gloss 应用更新 manifest 无效:\(reason)" + } + } +} + +public struct GlossAppReleaseManifestValidator: Sendable { + public static let pinnedManifestSigningPublicKey = Data( + base64Encoded: "FtPLO0dyvSMP4BUSZtk4ROgObX6F2GAToLeNfOygcVY=" + )! + + private let publicKey: Curve25519.Signing.PublicKey + + public init( + manifestSigningPublicKey: Data = Self.pinnedManifestSigningPublicKey + ) throws { + do { + publicKey = try Curve25519.Signing.PublicKey( + rawRepresentation: manifestSigningPublicKey + ) + } catch { + throw GlossAppUpdateError.invalidSigningKey + } + } + + public func validate( + manifestData: Data, + detachedSignatureData: Data + ) throws -> GlossAppReleaseManifest { + let signature = try Self.decodeSignature(detachedSignatureData) + guard publicKey.isValidSignature(signature, for: manifestData) else { + throw GlossAppUpdateError.invalidManifestSignature + } + + let manifest: GlossAppReleaseManifest + do { + manifest = try JSONDecoder().decode( + GlossAppReleaseManifest.self, + from: manifestData + ) + } catch { + throw GlossAppUpdateError.invalidManifest("JSON 无法解析") + } + try Self.validateContents(manifest) + return manifest + } + + private static func decodeSignature(_ data: Data) throws -> Data { + if data.count == 64 { + return data + } + if let text = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + let decoded = Data(base64Encoded: text), + decoded.count == 64 + { + return decoded + } + throw GlossAppUpdateError.invalidManifestSignature + } + + private static func validateContents(_ manifest: GlossAppReleaseManifest) throws { + guard manifest.schemaVersion == 2 else { + throw GlossAppUpdateError.invalidManifest("不支持 schemaVersion") + } + guard manifest.channel == "stable" else { + throw GlossAppUpdateError.invalidManifest("仅支持 stable 通道") + } + guard GlossSemanticVersion(manifest.version) != nil else { + throw GlossAppUpdateError.invalidManifest("version 不是有效的语义化版本") + } + guard manifest.releaseTag == "v\(manifest.version)" else { + throw GlossAppUpdateError.invalidManifest("releaseTag 与 version 不匹配") + } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + guard formatter.date(from: manifest.publishedAt) != nil else { + throw GlossAppUpdateError.invalidManifest("publishedAt 不是 ISO 8601 时间") + } + guard + manifest.minimumMacOSVersion.range( + of: #"^[0-9]+(\.[0-9]+){1,2}$"#, + options: .regularExpression + ) != nil + else { + throw GlossAppUpdateError.invalidManifest("minimumMacOSVersion 无效") + } + + var architectures = Set() + for asset in manifest.assets { + guard asset.operatingSystem == "macos", + ["arm64", "x86_64"].contains(asset.architecture), + architectures.insert(asset.architecture).inserted + else { + throw GlossAppUpdateError.invalidManifest("asset 平台无效或重复") + } + guard asset.size > 0 else { + throw GlossAppUpdateError.invalidManifest("asset size 必须大于零") + } + guard + asset.sha256.range( + of: "^[0-9a-f]{64}$", + options: .regularExpression + ) != nil + else { + throw GlossAppUpdateError.invalidManifest("asset SHA-256 无效") + } + try validateOfficialAssetURL( + asset.url, + architecture: asset.architecture, + releaseTag: manifest.releaseTag + ) + } + guard architectures == Set(["arm64", "x86_64"]) else { + throw GlossAppUpdateError.invalidManifest("缺少受支持架构的 asset") + } + guard + manifest.homebrewCask.token + == GlossHomebrewInstallationDetector.caskToken + else { + throw GlossAppUpdateError.invalidManifest( + "homebrewCask token 无效" + ) + } + guard manifest.homebrewCask.size > 0 else { + throw GlossAppUpdateError.invalidManifest( + "homebrewCask size 必须大于零" + ) + } + guard + manifest.homebrewCask.sha256.range( + of: "^[0-9a-f]{64}$", + options: .regularExpression + ) != nil + else { + throw GlossAppUpdateError.invalidManifest( + "homebrewCask SHA-256 无效" + ) + } + try validateOfficialHomebrewCaskURL( + manifest.homebrewCask.url, + releaseTag: manifest.releaseTag + ) + } + + static func validateOfficialAssetURL( + _ url: URL, + architecture: String, + releaseTag: String + ) throws { + guard + let components = URLComponents( + url: url, + resolvingAgainstBaseURL: false + ), + components.scheme == "https", + components.host?.lowercased() == "github.com", + components.port == nil, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil, + components.path + == "/SunChJ/gloss-releases/releases/download/\(releaseTag)/Gloss-macos-\(architecture).zip" + else { + throw GlossAppUpdateError.invalidManifest("asset URL 不是官方发行地址") + } + } + + static func validateOfficialHomebrewCaskURL( + _ url: URL, + releaseTag: String + ) throws { + guard + let components = URLComponents( + url: url, + resolvingAgainstBaseURL: false + ), + components.scheme == "https", + components.host?.lowercased() == "github.com", + components.port == nil, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil, + components.path + == "/SunChJ/gloss-releases/releases/download/\(releaseTag)/gloss.rb" + else { + throw GlossAppUpdateError.invalidManifest( + "homebrewCask URL 不是官方发行地址" + ) + } + } +} + +public struct GlossAppUpdateDataFetcher: Sendable { + public typealias Fetch = @Sendable (URL) async throws -> Data + + private let fetchImplementation: Fetch + + public init(fetch: @escaping Fetch) { + fetchImplementation = fetch + } + + public func fetch(from url: URL) async throws -> Data { + try await fetchImplementation(url) + } + + public static let live = ephemeral() + + public static func ephemeral( + requestTimeout: TimeInterval = 12, + resourceTimeout: TimeInterval = 60 + ) -> Self { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = requestTimeout + configuration.timeoutIntervalForResource = resourceTimeout + configuration.waitsForConnectivity = false + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + let session = URLSession(configuration: configuration) + + return Self { url in + let (data, response) = try await session.data(from: url) + if let response = response as? HTTPURLResponse, + !(200..<300).contains(response.statusCode) + { + throw GlossAppUpdateTransportError.httpFailure( + url: url, + statusCode: response.statusCode + ) + } + return data + } + } +} + +public enum GlossAppUpdateTransportError: LocalizedError, Equatable, Sendable { + case httpFailure(url: URL, statusCode: Int) + + public var errorDescription: String? { + switch self { + case .httpFailure(let url, let statusCode): + "获取 \(url.absoluteString) 失败(HTTP \(statusCode))。" + } + } +} + +public struct GlossAppUpdateCheckHistory: Sendable { + public typealias LastCheck = @Sendable () async -> Date? + public typealias RecordCheck = @Sendable (Date) async -> Void + + private let lastCheckImplementation: LastCheck + private let recordCheckImplementation: RecordCheck + + public init( + lastCheck: @escaping LastCheck, + recordCheck: @escaping RecordCheck + ) { + lastCheckImplementation = lastCheck + recordCheckImplementation = recordCheck + } + + public func lastCheck() async -> Date? { + await lastCheckImplementation() + } + + public func recordCheck(at date: Date) async { + await recordCheckImplementation(date) + } + + public static let transient = Self( + lastCheck: { nil }, + recordCheck: { _ in } + ) +} + +public enum GlossAppUpdateCheckMode: Sendable { + case automatic + case manual +} + +public struct GlossAppUpdateAvailability: Equatable, Sendable { + public let version: String + public let releaseTag: String + public let publishedAt: Date + public let minimumMacOSVersion: String + public let releasePageURL: URL + public let architecture: String + public let assetURL: URL + public let assetSHA256: String + public let assetSize: Int64 + public let homebrewCask: GlossAppReleaseManifest.HomebrewCask + public let manifestData: Data + public let detachedSignatureData: Data + + public init( + version: String, + releaseTag: String, + publishedAt: Date, + minimumMacOSVersion: String, + releasePageURL: URL, + architecture: String, + assetURL: URL, + assetSHA256: String, + assetSize: Int64, + homebrewCask: GlossAppReleaseManifest.HomebrewCask, + manifestData: Data, + detachedSignatureData: Data + ) { + self.version = version + self.releaseTag = releaseTag + self.publishedAt = publishedAt + self.minimumMacOSVersion = minimumMacOSVersion + self.releasePageURL = releasePageURL + self.architecture = architecture + self.assetURL = assetURL + self.assetSHA256 = assetSHA256 + self.assetSize = assetSize + self.homebrewCask = homebrewCask + self.manifestData = manifestData + self.detachedSignatureData = detachedSignatureData + } +} + +public enum GlossAppUpdateCheckResult: Equatable, Sendable { + case throttled(nextCheckAt: Date) + case upToDate(latestVersion: String) + case updateAvailable(GlossAppUpdateAvailability) +} + +public actor GlossAppUpdateDiscovery { + public static let automaticCheckInterval: TimeInterval = 24 * 60 * 60 + + private let currentVersion: GlossSemanticVersion + private let fetcher: GlossAppUpdateDataFetcher + private let history: GlossAppUpdateCheckHistory + private let validator: GlossAppReleaseManifestValidator + + public init( + currentVersion: String, + fetcher: GlossAppUpdateDataFetcher, + history: GlossAppUpdateCheckHistory = .transient, + manifestSigningPublicKey: Data = + GlossAppReleaseManifestValidator.pinnedManifestSigningPublicKey + ) throws { + guard let parsedCurrentVersion = GlossSemanticVersion(currentVersion) else { + throw GlossAppUpdateError.invalidCurrentVersion(currentVersion) + } + self.currentVersion = parsedCurrentVersion + self.fetcher = fetcher + self.history = history + validator = try GlossAppReleaseManifestValidator( + manifestSigningPublicKey: manifestSigningPublicKey + ) + } + + public func check( + mode: GlossAppUpdateCheckMode = .automatic, + now: Date = Date() + ) async throws -> GlossAppUpdateCheckResult { + if mode == .automatic, + let lastCheck = await history.lastCheck() + { + let nextCheckAt = lastCheck.addingTimeInterval( + Self.automaticCheckInterval + ) + if now < nextCheckAt { + return .throttled(nextCheckAt: nextCheckAt) + } + } + + await history.recordCheck(at: now) + let manifestData = try await fetcher.fetch( + from: GlossAppReleaseEndpoint.manifestURL + ) + let signatureData = try await fetcher.fetch( + from: GlossAppReleaseEndpoint.signatureURL + ) + let manifest = try validator.validate( + manifestData: manifestData, + detachedSignatureData: signatureData + ) + guard let availableVersion = GlossSemanticVersion(manifest.version) else { + throw GlossAppUpdateError.invalidManifest( + "version 不是有效的语义化版本" + ) + } + guard availableVersion > currentVersion else { + return .upToDate(latestVersion: manifest.version) + } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + guard let publishedAt = formatter.date(from: manifest.publishedAt) else { + throw GlossAppUpdateError.invalidManifest( + "publishedAt 不是 ISO 8601 时间" + ) + } + guard + let asset = manifest.assets.first(where: { + $0.operatingSystem == "macos" + && $0.architecture == GlossAppArchitecture.current + }) + else { + throw GlossAppUpdateError.invalidManifest( + "缺少当前架构 \(GlossAppArchitecture.current) 的 asset" + ) + } + return .updateAvailable( + GlossAppUpdateAvailability( + version: manifest.version, + releaseTag: manifest.releaseTag, + publishedAt: publishedAt, + minimumMacOSVersion: manifest.minimumMacOSVersion, + releasePageURL: GlossAppReleaseEndpoint.releasePageURL( + for: manifest.releaseTag + ), + architecture: asset.architecture, + assetURL: asset.url, + assetSHA256: asset.sha256, + assetSize: asset.size, + homebrewCask: manifest.homebrewCask, + manifestData: manifestData, + detachedSignatureData: signatureData + ) + ) + } +} diff --git a/Tests/GlossCoreTests/GlossAppUpdateDiscoveryTests.swift b/Tests/GlossCoreTests/GlossAppUpdateDiscoveryTests.swift new file mode 100644 index 0000000..6618831 --- /dev/null +++ b/Tests/GlossCoreTests/GlossAppUpdateDiscoveryTests.swift @@ -0,0 +1,417 @@ +import CryptoKit +import Foundation +import Testing + +@testable import GlossCore + +@Suite("Gloss app update discovery") +struct GlossAppUpdateDiscoveryTests { + private enum TestError: Error { + case offline + } + + private actor FetchRecorder { + var payloads: [URL: Data] + var offline = false + var requestedURLs: [URL] = [] + + init(payloads: [URL: Data] = [:]) { + self.payloads = payloads + } + + func fetch(_ url: URL) throws -> Data { + requestedURLs.append(url) + if offline { + throw TestError.offline + } + return payloads[url] ?? Data() + } + + func setOffline() { + offline = true + } + } + + private actor CheckHistory { + var lastCheck: Date? + var recordedChecks: [Date] = [] + + init(lastCheck: Date? = nil) { + self.lastCheck = lastCheck + } + + func record(_ date: Date) { + lastCheck = date + recordedChecks.append(date) + } + } + + @Test("semantic version precedence compares numeric components") + func comparesSemanticVersions() throws { + let newerPatch = try #require(GlossSemanticVersion("0.8.10")) + let olderPatch = try #require(GlossSemanticVersion("0.8.9")) + let prerelease = try #require(GlossSemanticVersion("0.8.10-rc.2")) + let laterPrerelease = try #require(GlossSemanticVersion("0.8.10-rc.10")) + let buildOne = try #require(GlossSemanticVersion("0.8.10+build.1")) + let buildTwo = try #require(GlossSemanticVersion("0.8.10+build.2")) + + #expect(newerPatch > olderPatch) + #expect(prerelease < laterPrerelease) + #expect(laterPrerelease < newerPatch) + #expect(buildOne == buildTwo) + #expect(GlossSemanticVersion("0.08.10") == nil) + #expect(GlossSemanticVersion("0.8") == nil) + #expect(GlossSemanticVersion("0.8.10-01") == nil) + } + + @Test("official endpoints cannot be redirected by callers") + func usesFixedOfficialEndpoints() { + #expect( + GlossAppReleaseEndpoint.manifestURL.absoluteString + == "https://github.com/SunChJ/gloss-releases/releases/latest/download/gloss-release-manifest.json" + ) + #expect( + GlossAppReleaseEndpoint.signatureURL.absoluteString + == "https://github.com/SunChJ/gloss-releases/releases/latest/download/gloss-release-manifest.json.sig" + ) + } + + @Test("valid signed manifest reports only advisory release metadata") + func discoversSignedUpdate() async throws { + let signed = try signedManifest(version: "0.8.10") + let fetchRecorder = FetchRecorder( + payloads: [ + GlossAppReleaseEndpoint.manifestURL: signed.manifest, + GlossAppReleaseEndpoint.signatureURL: signed.signature, + ] + ) + let discovery = try GlossAppUpdateDiscovery( + currentVersion: "0.8.9", + fetcher: GlossAppUpdateDataFetcher { url in + try await fetchRecorder.fetch(url) + }, + manifestSigningPublicKey: signingPublicKey + ) + + let result = try await discovery.check( + mode: .manual, + now: Date(timeIntervalSince1970: 1_800_000_000) + ) + + guard case .updateAvailable(let update) = result else { + Issue.record("Expected an available update") + return + } + #expect(update.version == "0.8.10") + #expect(update.releaseTag == "v0.8.10") + #expect(update.architecture == GlossAppArchitecture.current) + #expect( + update.assetURL.lastPathComponent + == "Gloss-macos-\(GlossAppArchitecture.current).zip" + ) + #expect( + update.homebrewCask.token + == GlossHomebrewInstallationDetector.caskToken + ) + #expect(update.manifestData == signed.manifest) + #expect(update.detachedSignatureData == signed.signature) + #expect( + update.releasePageURL.absoluteString + == "https://github.com/SunChJ/gloss-releases/releases/tag/v0.8.10" + ) + let requestedURLs = await fetchRecorder.requestedURLs + #expect( + requestedURLs == [ + GlossAppReleaseEndpoint.manifestURL, + GlossAppReleaseEndpoint.signatureURL, + ] + ) + } + + @Test("invalid signature fails closed") + func rejectsInvalidSignature() async throws { + let signed = try signedManifest(version: "0.8.10") + var invalidSignature = signed.signature + invalidSignature[invalidSignature.startIndex] ^= 0xff + let discovery = try discovery( + currentVersion: "0.8.9", + manifestData: signed.manifest, + signatureData: invalidSignature + ) + + await #expect(throws: GlossAppUpdateError.invalidManifestSignature) { + try await discovery.check(mode: .manual) + } + } + + @Test("signed manifest with a foreign asset URL fails closed") + func rejectsForeignManifest() async throws { + var manifest = manifest(version: "0.8.10") + manifest = GlossAppReleaseManifest( + version: manifest.version, + releaseTag: manifest.releaseTag, + publishedAt: manifest.publishedAt, + minimumMacOSVersion: manifest.minimumMacOSVersion, + assets: [ + GlossAppReleaseManifest.Asset( + operatingSystem: "macos", + architecture: "arm64", + url: URL( + string: + "https://attacker.example/Gloss-macos-arm64.zip" + )!, + sha256: String(repeating: "a", count: 64), + size: 100 + ), + manifest.assets[1], + ], + homebrewCask: manifest.homebrewCask + ) + let signed = try sign(manifest) + let discovery = try discovery( + currentVersion: "0.8.9", + manifestData: signed.manifest, + signatureData: signed.signature + ) + + await #expect(throws: GlossAppUpdateError.self) { + try await discovery.check(mode: .manual) + } + } + + @Test("malformed and mismatched official manifests fail closed") + func rejectsMalformedManifest() async throws { + let malformed = Data(#"{"schemaVersion":1}"#.utf8) + let malformedSignature = try signingPrivateKey.signature(for: malformed) + let malformedDiscovery = try discovery( + currentVersion: "0.8.9", + manifestData: malformed, + signatureData: malformedSignature + ) + await #expect(throws: GlossAppUpdateError.self) { + try await malformedDiscovery.check(mode: .manual) + } + + let mismatched = GlossAppReleaseManifest( + version: "0.8.10", + releaseTag: "v9.9.9", + publishedAt: "2026-07-27T00:00:00Z", + minimumMacOSVersion: "14.0", + assets: manifest(version: "0.8.10").assets, + homebrewCask: manifest(version: "0.8.10").homebrewCask + ) + let signedMismatch = try sign(mismatched) + let mismatchDiscovery = try discovery( + currentVersion: "0.8.9", + manifestData: signedMismatch.manifest, + signatureData: signedMismatch.signature + ) + await #expect(throws: GlossAppUpdateError.self) { + try await mismatchDiscovery.check(mode: .manual) + } + } + + @Test("signed manifest cannot redirect the Homebrew cask token") + func rejectsForeignCaskToken() async throws { + let original = manifest(version: "0.8.10") + let redirected = GlossAppReleaseManifest( + version: original.version, + releaseTag: original.releaseTag, + publishedAt: original.publishedAt, + minimumMacOSVersion: original.minimumMacOSVersion, + assets: original.assets, + homebrewCask: GlossAppReleaseManifest.HomebrewCask( + token: "attacker/tap/gloss", + url: original.homebrewCask.url, + sha256: original.homebrewCask.sha256, + size: original.homebrewCask.size + ) + ) + let signed = try sign(redirected) + let discovery = try discovery( + currentVersion: "0.8.9", + manifestData: signed.manifest, + signatureData: signed.signature + ) + + await #expect(throws: GlossAppUpdateError.self) { + try await discovery.check(mode: .manual) + } + } + + @Test("offline failure is surfaced and records the automatic attempt") + func surfacesOfflineFailure() async throws { + let fetchRecorder = FetchRecorder() + await fetchRecorder.setOffline() + let history = CheckHistory() + let discovery = try GlossAppUpdateDiscovery( + currentVersion: "0.8.9", + fetcher: GlossAppUpdateDataFetcher { url in + try await fetchRecorder.fetch(url) + }, + history: makeHistory(history), + manifestSigningPublicKey: signingPublicKey + ) + let now = Date(timeIntervalSince1970: 1_800_000_000) + + await #expect(throws: TestError.offline) { + try await discovery.check(mode: .automatic, now: now) + } + #expect(await history.recordedChecks == [now]) + } + + @Test("automatic checks are throttled for 24 hours") + func throttlesAutomaticChecks() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let previous = now.addingTimeInterval(-60) + let history = CheckHistory(lastCheck: previous) + let fetchRecorder = FetchRecorder() + let discovery = try GlossAppUpdateDiscovery( + currentVersion: "0.8.9", + fetcher: GlossAppUpdateDataFetcher { url in + try await fetchRecorder.fetch(url) + }, + history: makeHistory(history), + manifestSigningPublicKey: signingPublicKey + ) + + let result = try await discovery.check(mode: .automatic, now: now) + + #expect( + result + == .throttled( + nextCheckAt: previous.addingTimeInterval(24 * 60 * 60) + ) + ) + #expect(await fetchRecorder.requestedURLs.isEmpty) + #expect(await history.recordedChecks.isEmpty) + } + + @Test("manual checks bypass the 24-hour throttle") + func manualCheckBypassesThrottle() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let history = CheckHistory(lastCheck: now.addingTimeInterval(-60)) + let signed = try signedManifest(version: "0.8.10") + let fetchRecorder = FetchRecorder( + payloads: [ + GlossAppReleaseEndpoint.manifestURL: signed.manifest, + GlossAppReleaseEndpoint.signatureURL: signed.signature, + ] + ) + let discovery = try GlossAppUpdateDiscovery( + currentVersion: "0.8.9", + fetcher: GlossAppUpdateDataFetcher { url in + try await fetchRecorder.fetch(url) + }, + history: makeHistory(history), + manifestSigningPublicKey: signingPublicKey + ) + + let result = try await discovery.check(mode: .manual, now: now) + + guard case .updateAvailable = result else { + Issue.record("Expected manual check to bypass throttling") + return + } + #expect(await fetchRecorder.requestedURLs.count == 2) + #expect(await history.recordedChecks == [now]) + } + + @Test("same or older official version is up to date") + func reportsUpToDate() async throws { + let signed = try signedManifest(version: "0.8.9") + let discovery = try discovery( + currentVersion: "0.8.10", + manifestData: signed.manifest, + signatureData: signed.signature + ) + + #expect( + try await discovery.check(mode: .manual) + == .upToDate(latestVersion: "0.8.9") + ) + } + + private func discovery( + currentVersion: String, + manifestData: Data, + signatureData: Data + ) throws -> GlossAppUpdateDiscovery { + try GlossAppUpdateDiscovery( + currentVersion: currentVersion, + fetcher: GlossAppUpdateDataFetcher { url in + switch url { + case GlossAppReleaseEndpoint.manifestURL: + manifestData + case GlossAppReleaseEndpoint.signatureURL: + signatureData + default: + throw TestError.offline + } + }, + manifestSigningPublicKey: signingPublicKey + ) + } + + private func makeHistory( + _ history: CheckHistory + ) -> GlossAppUpdateCheckHistory { + GlossAppUpdateCheckHistory( + lastCheck: { await history.lastCheck }, + recordCheck: { date in await history.record(date) } + ) + } + + private func signedManifest( + version: String + ) throws -> (manifest: Data, signature: Data) { + try sign(manifest(version: version)) + } + + private func sign( + _ manifest: GlossAppReleaseManifest + ) throws -> (manifest: Data, signature: Data) { + let data = try JSONEncoder().encode(manifest) + return (data, try signingPrivateKey.signature(for: data)) + } + + private func manifest(version: String) -> GlossAppReleaseManifest { + let releaseTag = "v\(version)" + return GlossAppReleaseManifest( + version: version, + releaseTag: releaseTag, + publishedAt: "2026-07-27T00:00:00Z", + minimumMacOSVersion: "14.0", + assets: ["arm64", "x86_64"].map { architecture in + GlossAppReleaseManifest.Asset( + operatingSystem: "macos", + architecture: architecture, + url: URL( + string: + "https://github.com/SunChJ/gloss-releases/releases/download/\(releaseTag)/Gloss-macos-\(architecture).zip" + )!, + sha256: String(repeating: "a", count: 64), + size: 100 + ) + }, + homebrewCask: GlossAppReleaseManifest.HomebrewCask( + url: URL( + string: + "https://github.com/SunChJ/gloss-releases/releases/download/\(releaseTag)/gloss.rb" + )!, + sha256: String(repeating: "b", count: 64), + size: 200 + ) + ) + } + + private var signingPrivateKey: Curve25519.Signing.PrivateKey { + try! Curve25519.Signing.PrivateKey( + rawRepresentation: Data(0..<32) + ) + } + + private var signingPublicKey: Data { + signingPrivateKey.publicKey.rawRepresentation + } +} From e7d00d733524f821ce7e82449b512caef753017e Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Mon, 27 Jul 2026 01:04:27 -0700 Subject: [PATCH 4/9] feat: perform recoverable Homebrew app upgrades --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 55 +- Package.swift | 12 +- Scripts/build_app.sh | 3 + Scripts/generate_homebrew_cask.sh | 6 + Scripts/generate_release_metadata.sh | 51 +- Scripts/validate_homebrew_cask.sh | 3 + .../GlossCore/GlossHomebrewInstallation.swift | 428 ++++ Sources/GlossCore/GlossHomebrewUpgrade.swift | 1717 +++++++++++++++++ Sources/GlossUpdateHelper/main.swift | 36 + .../GlossHomebrewInstallationTests.swift | 366 ++++ .../GlossHomebrewUpgradeTests.swift | 998 ++++++++++ docs/runtime-distribution.md | 16 +- 13 files changed, 3661 insertions(+), 32 deletions(-) create mode 100644 Sources/GlossCore/GlossHomebrewInstallation.swift create mode 100644 Sources/GlossCore/GlossHomebrewUpgrade.swift create mode 100644 Sources/GlossUpdateHelper/main.swift create mode 100644 Tests/GlossCoreTests/GlossHomebrewInstallationTests.swift create mode 100644 Tests/GlossCoreTests/GlossHomebrewUpgradeTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd1d993..b152cf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: "$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" + "$temporary_directory/release/gloss.rb" ( cd "$temporary_directory/release" shasum -a 256 --check SHA256SUMS diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f49483..cc554aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,10 +38,12 @@ jobs: env: DISTRIBUTION_TOKEN: ${{ secrets.GLOSS_DISTRIBUTION_TOKEN }} EXTENSION_SSH_KEY: ${{ secrets.GLOSS_EXTENSION_SSH_KEY }} + MANIFEST_SIGNING_KEY: ${{ secrets.GLOSS_APP_UPDATE_MANIFEST_SIGNING_KEY }} PUBLISH_RELEASE: ${{ github.event_name == 'push' || inputs.publish_release }} run: | missing=() [[ -n "$EXTENSION_SSH_KEY" ]] || missing+=("GLOSS_EXTENSION_SSH_KEY") + [[ -n "$MANIFEST_SIGNING_KEY" ]] || missing+=("GLOSS_APP_UPDATE_MANIFEST_SIGNING_KEY") if [[ "$PUBLISH_RELEASE" == "true" ]]; then [[ -n "$DISTRIBUTION_TOKEN" ]] || missing+=("GLOSS_DISTRIBUTION_TOKEN") fi @@ -69,12 +71,12 @@ jobs: GLOSS_SIGN_IDENTITY: "-" steps: - name: Check out Gloss - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: path: gloss ref: ${{ github.event_name == 'push' && github.ref || inputs.release_tag }} - name: Check out browser extensions - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: SunChJ/personal-immersive-translator ref: 3e9c7c8cb75ce4b08e56a714ee0e4eb7ebaa652e @@ -115,7 +117,7 @@ jobs: GLOSS_RELEASE_ARCHITECTURE: ${{ matrix.architecture }} run: Scripts/package_release.sh - name: Upload architecture artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: Gloss-${{ matrix.architecture }} path: gloss/dist/release/Gloss-macos-${{ matrix.architecture }}.zip @@ -131,12 +133,12 @@ jobs: RELEASE_TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.release_tag }} steps: - name: Check out Gloss - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: path: gloss ref: ${{ github.event_name == 'push' && github.ref || inputs.release_tag }} - name: Download architecture artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: Gloss-* path: release-input @@ -155,8 +157,43 @@ jobs: dist/release \ "$RELEASE_TAG" \ "$GLOSS_RELEASE_REPOSITORY" + - name: Sign and verify update manifest + working-directory: gloss + env: + GLOSS_APP_UPDATE_MANIFEST_SIGNING_KEY: ${{ secrets.GLOSS_APP_UPDATE_MANIFEST_SIGNING_KEY }} + run: | + swift Scripts/sign_release_manifest.swift dist/release/gloss-release-manifest.json + signature_path=dist/release/gloss-release-manifest.json.sig + test -f "$signature_path" + test "$(base64 -D <"$signature_path" | wc -c | tr -d '[:space:]')" = "64" + ( + cd dist/release + shasum -a 256 --check SHA256SUMS + ) + python3 - <<'PY' + import hashlib + import json + import pathlib + + release = pathlib.Path("dist/release") + manifest = json.loads( + (release / "gloss-release-manifest.json").read_text(encoding="utf-8") + ) + cask = release / "gloss.rb" + payload = cask.read_bytes() + metadata = manifest["homebrewCask"] + expected_url = ( + "https://github.com/SunChJ/gloss-releases/releases/download/" + f"{manifest['releaseTag']}/gloss.rb" + ) + assert manifest["schemaVersion"] == 2 + assert metadata["token"] == "sunchj/tap/gloss" + assert metadata["url"] == expected_url + assert metadata["size"] == len(payload) + assert metadata["sha256"] == hashlib.sha256(payload).hexdigest() + PY - name: Upload combined workflow artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: Gloss-release-${{ env.RELEASE_TAG }} path: | @@ -164,7 +201,8 @@ jobs: 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 + gloss/dist/release/gloss-release-manifest.json.sig + gloss/dist/release/gloss.rb if-no-files-found: error - name: Publish GitHub release assets if: github.event_name == 'push' || inputs.publish_release @@ -207,7 +245,8 @@ jobs: dist/release/Gloss-macos-x86_64.zip \ dist/release/SHA256SUMS \ dist/release/gloss-release-manifest.json \ - dist/release/Casks/gloss.rb \ + dist/release/gloss-release-manifest.json.sig \ + dist/release/gloss.rb \ --clobber gh release edit "$RELEASE_TAG" \ --repo "$GLOSS_RELEASE_REPOSITORY" \ diff --git a/Package.swift b/Package.swift index e7ca097..d60d6fa 100644 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,11 @@ let package = Package( products: [ .library(name: "GlossCore", targets: ["GlossCore"]), .executable(name: "Gloss", targets: ["Gloss"]), - .executable(name: "gloss-cli", targets: ["GlossCLI"]) + .executable(name: "gloss-cli", targets: ["GlossCLI"]), + .executable( + name: "gloss-update-helper", + targets: ["GlossUpdateHelper"] + ), ], targets: [ .target(name: "GlossCore"), @@ -23,6 +27,10 @@ let package = Package( name: "GlossCLI", dependencies: ["GlossCore"] ), + .executableTarget( + name: "GlossUpdateHelper", + dependencies: ["GlossCore"] + ), .testTarget( name: "GlossCoreTests", dependencies: ["GlossCore"] @@ -34,6 +42,6 @@ let package = Package( .testTarget( name: "GlossAppTests", dependencies: ["Gloss"] - ) + ), ] ) diff --git a/Scripts/build_app.sh b/Scripts/build_app.sh index 647292d..b9b1d06 100755 --- a/Scripts/build_app.sh +++ b/Scripts/build_app.sh @@ -72,6 +72,7 @@ fi install -d "$MACOS_DIR" "$HELPERS_DIR" "$RESOURCES_DIR" "$PLUGINS_DIR" install -m 755 "$BIN_DIR/Gloss" "$MACOS_DIR/Gloss" install -m 755 "$BIN_DIR/gloss-cli" "$HELPERS_DIR/gloss-cli" +install -m 755 "$BIN_DIR/gloss-update-helper" "$HELPERS_DIR/gloss-update-helper" install -m 644 "$ROOT_DIR/Resources/Info.plist" "$CONTENTS_DIR/Info.plist" install -m 644 "$ROOT_DIR/Resources/Gloss.icns" "$RESOURCES_DIR/Gloss.icns" if [[ "$CODEX_RUNTIME_MODE" == "bundled" ]]; then @@ -94,9 +95,11 @@ if [[ "$CODEX_RUNTIME_MODE" == "bundled" ]]; then codesign "${SIGN_ARGS[@]}" "$HELPERS_DIR/gloss-codex-app-server" fi codesign "${SIGN_ARGS[@]}" "$HELPERS_DIR/gloss-cli" +codesign "${SIGN_ARGS[@]}" "$HELPERS_DIR/gloss-update-helper" codesign "${SIGN_ARGS[@]}" --entitlements "$APP_ENTITLEMENTS" "$APP_DIR" if [[ "$CODEX_RUNTIME_MODE" == "bundled" ]]; then codesign --verify --strict "$HELPERS_DIR/gloss-codex-app-server" fi +codesign --verify --strict "$HELPERS_DIR/gloss-update-helper" codesign --verify --deep --strict "$APP_DIR" echo "$APP_DIR" diff --git a/Scripts/generate_homebrew_cask.sh b/Scripts/generate_homebrew_cask.sh index 3cb94e8..0d8b098 100755 --- a/Scripts/generate_homebrew_cask.sh +++ b/Scripts/generate_homebrew_cask.sh @@ -37,6 +37,10 @@ if [[ ! "$RELEASE_TAG" =~ ^v[0-9A-Za-z][0-9A-Za-z.+_-]*$ ]]; then echo "Invalid release tag: $RELEASE_TAG" >&2 exit 65 fi +if [[ "$RELEASE_TAG" != "v$VERSION" ]]; then + echo "Release tag $RELEASE_TAG does not match version $VERSION." >&2 + exit 65 +fi for archive in "$ARM64_ARCHIVE" "$X86_64_ARCHIVE"; do if [[ "$archive" == *"/"* || -z "$archive" ]]; then echo "Invalid archive name: $archive" >&2 @@ -75,6 +79,7 @@ cask "gloss" do depends_on macos: :sonoma app "Gloss.app" + binary "#{appdir}/Gloss.app/Contents/Helpers/gloss-cli", target: "gloss-cli" postflight do app_path = "#{appdir}/Gloss.app" @@ -91,6 +96,7 @@ cask "gloss" do extension_path, "#{app_path}/Contents/Helpers/gloss-codex-app-server", "#{app_path}/Contents/Helpers/gloss-cli", + "#{app_path}/Contents/Helpers/gloss-update-helper", app_path, ] code_paths.each do |code_path| diff --git a/Scripts/generate_release_metadata.sh b/Scripts/generate_release_metadata.sh index 2c16166..2713d6c 100755 --- a/Scripts/generate_release_metadata.sh +++ b/Scripts/generate_release_metadata.sh @@ -32,6 +32,10 @@ if [[ ! "$RELEASE_TAG" =~ ^v[0-9A-Za-z][0-9A-Za-z.+_-]*$ ]]; then echo "Invalid release tag: $RELEASE_TAG" >&2 exit 65 fi +if [[ "$RELEASE_TAG" != "v$VERSION" ]]; then + echo "Release tag $RELEASE_TAG does not match version $VERSION." >&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 @@ -77,6 +81,23 @@ MANIFEST_PATH="$OUTPUT_DIRECTORY/gloss-release-manifest.json" CHECKSUMS_PATH="$OUTPUT_DIRECTORY/SHA256SUMS" ASSET_BASE_URL="${ASSET_BASE_URL%/}" +GLOSS_CASK_DOWNLOAD_BASE_URL="$ASSET_BASE_URL" \ + bash "$(dirname "$0")/generate_homebrew_cask.sh" \ + "$VERSION" \ + "$ARM64_SHA256" \ + "$X86_64_SHA256" \ + "$OUTPUT_DIRECTORY/gloss.rb" \ + "$RELEASE_TAG" \ + "$REPOSITORY" \ + "$ARM64_NAME" \ + "$X86_64_NAME" +bash "$(dirname "$0")/validate_homebrew_cask.sh" \ + "$OUTPUT_DIRECTORY/gloss.rb" +CASK_PATH="$OUTPUT_DIRECTORY/gloss.rb" +CASK_SHA256="$(shasum -a 256 "$CASK_PATH" | awk '{print $1}')" +CASK_SIZE="$(stat -f '%z' "$CASK_PATH")" +CASK_URL="$ASSET_BASE_URL/gloss.rb" + python3 - \ "$MANIFEST_PATH" \ "$VERSION" \ @@ -88,7 +109,10 @@ python3 - \ "$ARM64_SIZE" \ "$X86_64_NAME" \ "$X86_64_SHA256" \ - "$X86_64_SIZE" <<'PY' + "$X86_64_SIZE" \ + "$CASK_URL" \ + "$CASK_SHA256" \ + "$CASK_SIZE" <<'PY' import json import pathlib import sys @@ -105,9 +129,12 @@ import sys x86_64_name, x86_64_sha256, x86_64_size, + cask_url, + cask_sha256, + cask_size, ) = sys.argv[1:] manifest = { - "schemaVersion": 1, + "schemaVersion": 2, "channel": "stable", "version": version, "releaseTag": release_tag, @@ -129,6 +156,12 @@ manifest = { "size": int(x86_64_size), }, ], + "homebrewCask": { + "token": "sunchj/tap/gloss", + "url": cask_url, + "sha256": cask_sha256, + "size": int(cask_size), + }, } pathlib.Path(output).write_text( json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n", @@ -140,21 +173,9 @@ 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' "$CASK_SHA256" "gloss.rb" printf '%s %s\n' "$MANIFEST_SHA256" "$(basename "$MANIFEST_PATH")" } >"$CHECKSUMS_PATH" -GLOSS_CASK_DOWNLOAD_BASE_URL="$ASSET_BASE_URL" \ - bash "$(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" -bash "$(dirname "$0")/validate_homebrew_cask.sh" \ - "$OUTPUT_DIRECTORY/Casks/gloss.rb" - echo "$MANIFEST_PATH" echo "$CHECKSUMS_PATH" diff --git a/Scripts/validate_homebrew_cask.sh b/Scripts/validate_homebrew_cask.sh index 514c751..c815c7b 100755 --- a/Scripts/validate_homebrew_cask.sh +++ b/Scripts/validate_homebrew_cask.sh @@ -25,12 +25,14 @@ required = [ %r{^ url "https://[^"]+/Gloss-macos-arm64\.zip"$}, %r{^ url "https://[^"]+/Gloss-macos-x86_64\.zip"$}, /^ app "Gloss\.app"$/, + %r{^ binary "#\{appdir\}/Gloss\.app/Contents/Helpers/gloss-cli", target: "gloss-cli"$}, /^ 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}, + %r{#\{app_path\}/Contents/Helpers/gloss-update-helper}, /^ code_paths\.each do \|code_path\|$/, /entitlements_before = entitlement_paths\.map/, /entitlements_after = entitlement_paths\.map/, @@ -53,6 +55,7 @@ expected_order = [ "extension_path,", '"#{app_path}/Contents/Helpers/gloss-codex-app-server",', '"#{app_path}/Contents/Helpers/gloss-cli",', + '"#{app_path}/Contents/Helpers/gloss-update-helper",', "app_path,", ] cursor = -1 diff --git a/Sources/GlossCore/GlossHomebrewInstallation.swift b/Sources/GlossCore/GlossHomebrewInstallation.swift new file mode 100644 index 0000000..9daadd1 --- /dev/null +++ b/Sources/GlossCore/GlossHomebrewInstallation.swift @@ -0,0 +1,428 @@ +import Darwin +import Foundation + +public struct GlossCommandOutput: Equatable, Sendable { + public let terminationStatus: Int32 + public let standardOutput: Data + public let standardError: Data + + public init( + terminationStatus: Int32, + standardOutput: Data, + standardError: Data = Data() + ) { + self.terminationStatus = terminationStatus + self.standardOutput = standardOutput + self.standardError = standardError + } +} + +public enum GlossCommandRunnerError: LocalizedError, Equatable, Sendable { + case timedOut(executablePath: String) + + public var errorDescription: String? { + switch self { + case .timedOut(let executablePath): + "命令执行超时:\(executablePath)" + } + } +} + +public struct GlossCommandRunner: Sendable { + public typealias Run = + @Sendable (URL, [String], [String: String], Duration?) async throws + -> GlossCommandOutput + + private let runImplementation: Run + + public init( + run: + @escaping @Sendable (URL, [String]) async throws + -> GlossCommandOutput + ) { + runImplementation = { executableURL, arguments, _, _ in + try await run(executableURL, arguments) + } + } + + public init(runWithTimeout: @escaping Run) { + runImplementation = runWithTimeout + } + + public func run( + executableURL: URL, + arguments: [String], + environment: [String: String] = [:], + timeout: Duration? = nil + ) async throws -> GlossCommandOutput { + try await runImplementation( + executableURL, + arguments, + environment, + timeout + ) + } + + public static let live = Self(runWithTimeout: { + executableURL, + arguments, + environment, + timeout in + let process = Process() + let standardOutput = Pipe() + let standardError = Pipe() + process.executableURL = executableURL + process.arguments = arguments + if !environment.isEmpty { + process.environment = ProcessInfo.processInfo.environment + .merging(environment) { _, newValue in newValue } + } + process.standardOutput = standardOutput + process.standardError = standardError + process.standardInput = FileHandle.nullDevice + try process.run() + + let outputTask = Task.detached(priority: .utility) { + standardOutput.fileHandleForReading.readDataToEndOfFile() + } + let errorTask = Task.detached(priority: .utility) { + standardError.fileHandleForReading.readDataToEndOfFile() + } + let clock = ContinuousClock() + let deadline = timeout.map { clock.now.advanced(by: $0) } + + do { + while process.isRunning { + try Task.checkCancellation() + if let deadline, clock.now >= deadline { + process.terminate() + try? await Task.sleep( + for: .milliseconds(500) + ) + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + } + standardOutput.fileHandleForReading.closeFile() + standardError.fileHandleForReading.closeFile() + outputTask.cancel() + errorTask.cancel() + throw GlossCommandRunnerError.timedOut( + executablePath: executableURL.path + ) + } + try await Task.sleep(for: .milliseconds(50)) + } + } catch { + if process.isRunning { + process.terminate() + } + throw error + } + + return GlossCommandOutput( + terminationStatus: process.terminationStatus, + standardOutput: await outputTask.value, + standardError: await errorTask.value + ) + }) +} + +public struct GlossHomebrewCaskRelease: Equatable, Sendable { + public let version: String + public let url: URL + public let sha256: String + + public init(version: String, url: URL, sha256: String) { + self.version = version + self.url = url + self.sha256 = sha256 + } +} + +public struct GlossPathInspector: Sendable { + public typealias IsExecutable = @Sendable (URL) -> Bool + public typealias FileExists = @Sendable (URL) -> Bool + public typealias PathsReferToSameItem = @Sendable (URL, URL) -> Bool + + private let isExecutableImplementation: IsExecutable + private let fileExistsImplementation: FileExists + private let pathsReferToSameItemImplementation: PathsReferToSameItem + + public init( + isExecutable: @escaping IsExecutable, + fileExists: @escaping FileExists, + pathsReferToSameItem: @escaping PathsReferToSameItem + ) { + isExecutableImplementation = isExecutable + fileExistsImplementation = fileExists + pathsReferToSameItemImplementation = pathsReferToSameItem + } + + public func isExecutable(_ url: URL) -> Bool { + isExecutableImplementation(url) + } + + public func fileExists(_ url: URL) -> Bool { + fileExistsImplementation(url) + } + + public func pathsReferToSameItem(_ first: URL, _ second: URL) -> Bool { + pathsReferToSameItemImplementation(first, second) + } + + public static let live = Self( + isExecutable: { url in + FileManager.default.isExecutableFile(atPath: url.path) + }, + fileExists: { url in + FileManager.default.fileExists(atPath: url.path) + }, + pathsReferToSameItem: { first, second in + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: first.path), + fileManager.fileExists(atPath: second.path) + else { + return false + } + return first.resolvingSymlinksInPath().standardizedFileURL + == second.resolvingSymlinksInPath().standardizedFileURL + } + ) +} + +public struct GlossHomebrewInstallation: Equatable, Sendable { + public let brewExecutableURL: URL + public let caskToken: String + public let installedVersion: String + public let availableVersion: String + public let managedAppURL: URL + public let installedAppTargetURL: URL + + public init( + brewExecutableURL: URL, + caskToken: String, + installedVersion: String, + availableVersion: String, + managedAppURL: URL, + installedAppTargetURL: URL + ) { + self.brewExecutableURL = brewExecutableURL + self.caskToken = caskToken + self.installedVersion = installedVersion + self.availableVersion = availableVersion + self.managedAppURL = managedAppURL + self.installedAppTargetURL = installedAppTargetURL + } +} + +public enum GlossHomebrewDetectionError: LocalizedError, Equatable, Sendable { + case malformedInfo + case invalidCaskIdentity + case invalidCaskVersion + + public var errorDescription: String? { + switch self { + case .malformedInfo: + "Homebrew 返回了无法解析的 Gloss cask 信息。" + case .invalidCaskIdentity: + "Homebrew 返回的 cask 不是 sunchj/tap/gloss。" + case .invalidCaskVersion: + "Homebrew 返回了无效的 Gloss 版本。" + } + } +} + +public struct GlossHomebrewInstallationDetector: Sendable { + public static let caskToken = "sunchj/tap/gloss" + public static let brewExecutableURLs = [ + URL(fileURLWithPath: "/opt/homebrew/bin/brew"), + URL(fileURLWithPath: "/usr/local/bin/brew"), + ] + public static let infoArguments = [ + "info", + "--cask", + "--json=v2", + caskToken, + ] + + private struct InfoResponse: Decodable { + let casks: [Cask] + } + + private struct Cask: Decodable { + let token: String + let fullToken: String + let tap: String + let version: String + let installed: String? + let url: URL? + let sha256: String? + let artifacts: [JSONValue] + + enum CodingKeys: String, CodingKey { + case token + case fullToken = "full_token" + case tap + case version + case installed + case url + case sha256 + case artifacts + } + } + + public static func parseCaskRelease( + _ data: Data + ) throws -> GlossHomebrewCaskRelease { + let cask = try decodeCask(from: data) + try validateIdentity(cask) + guard GlossSemanticVersion(cask.version) != nil else { + throw GlossHomebrewDetectionError.invalidCaskVersion + } + guard let url = cask.url, + url.scheme == "https", + let sha256 = cask.sha256, + sha256.range( + of: "^[0-9a-fA-F]{64}$", + options: .regularExpression + ) != nil + else { + throw GlossHomebrewDetectionError.malformedInfo + } + return GlossHomebrewCaskRelease( + version: cask.version, + url: url, + sha256: sha256.lowercased() + ) + } + + private let commandRunner: GlossCommandRunner + private let pathInspector: GlossPathInspector + + public init( + commandRunner: GlossCommandRunner, + pathInspector: GlossPathInspector = .live + ) { + self.commandRunner = commandRunner + self.pathInspector = pathInspector + } + + public func detect( + currentBundleURL: URL + ) async throws -> GlossHomebrewInstallation? { + for brewURL in Self.brewExecutableURLs + where pathInspector.isExecutable(brewURL) { + if let installation = try await detect( + brewExecutableURL: brewURL, + currentBundleURL: currentBundleURL + ) { + return installation + } + } + return nil + } + + public func detect( + brewExecutableURL: URL, + currentBundleURL: URL + ) async throws -> GlossHomebrewInstallation? { + guard Self.brewExecutableURLs.contains(brewExecutableURL), + pathInspector.isExecutable(brewExecutableURL) + else { + return nil + } + let output = try await commandRunner.run( + executableURL: brewExecutableURL, + arguments: Self.infoArguments + ) + guard output.terminationStatus == 0 else { + return nil + } + return try parseManagedInstallation( + output.standardOutput, + brewURL: brewExecutableURL, + currentBundleURL: currentBundleURL + ) + } + + private func parseManagedInstallation( + _ data: Data, + brewURL: URL, + currentBundleURL: URL + ) throws -> GlossHomebrewInstallation? { + let cask = try Self.decodeCask(from: data) + try Self.validateIdentity(cask) + guard let installedVersion = cask.installed else { + return nil + } + guard GlossSemanticVersion(installedVersion) != nil, + GlossSemanticVersion(cask.version) != nil + else { + throw GlossHomebrewDetectionError.invalidCaskVersion + } + + guard + let targetPath = cask.artifacts.compactMap({ artifact -> String? in + guard artifact["app"] != nil else { + return nil + } + return artifact["target"]?.stringValue + }).first + else { + throw GlossHomebrewDetectionError.malformedInfo + } + let targetURL = URL(fileURLWithPath: targetPath) + let prefixURL = + brewURL + .deletingLastPathComponent() + .deletingLastPathComponent() + let managedAppURL = + prefixURL + .appendingPathComponent("Caskroom", isDirectory: true) + .appendingPathComponent("gloss", isDirectory: true) + .appendingPathComponent(installedVersion, isDirectory: true) + .appendingPathComponent("Gloss.app", isDirectory: true) + + guard pathInspector.fileExists(managedAppURL), + pathInspector.fileExists(targetURL), + pathInspector.fileExists(currentBundleURL), + pathInspector.pathsReferToSameItem(targetURL, currentBundleURL), + pathInspector.pathsReferToSameItem(managedAppURL, currentBundleURL) + else { + return nil + } + + return GlossHomebrewInstallation( + brewExecutableURL: brewURL, + caskToken: Self.caskToken, + installedVersion: installedVersion, + availableVersion: cask.version, + managedAppURL: managedAppURL, + installedAppTargetURL: targetURL + ) + } + + private static func decodeCask(from data: Data) throws -> Cask { + let response: InfoResponse + do { + response = try JSONDecoder().decode(InfoResponse.self, from: data) + } catch { + throw GlossHomebrewDetectionError.malformedInfo + } + + guard response.casks.count == 1, let cask = response.casks.first + else { + throw GlossHomebrewDetectionError.malformedInfo + } + return cask + } + + private static func validateIdentity(_ cask: Cask) throws { + guard cask.token == "gloss", + cask.fullToken == Self.caskToken, + cask.tap == "sunchj/tap" + else { + throw GlossHomebrewDetectionError.invalidCaskIdentity + } + } +} diff --git a/Sources/GlossCore/GlossHomebrewUpgrade.swift b/Sources/GlossCore/GlossHomebrewUpgrade.swift new file mode 100644 index 0000000..2c1fe23 --- /dev/null +++ b/Sources/GlossCore/GlossHomebrewUpgrade.swift @@ -0,0 +1,1717 @@ +import CryptoKit +import Darwin +import Foundation + +public struct GlossHomebrewUpgradeRequest: Codable, Equatable, Sendable { + public static let currentSchemaVersion = 2 + + public let schemaVersion: Int + public let brewExecutablePath: String + public let caskToken: String + public let previousVersion: String + public let expectedVersion: String + public let expectedReleaseTag: String + public let expectedArchitecture: String + public let expectedAssetURL: URL + public let expectedAssetSHA256: String + public let expectedAssetSize: Int64 + public let expectedHomebrewCaskURL: URL + public let expectedHomebrewCaskSHA256: String + public let expectedHomebrewCaskSize: Int64 + public let parentProcessIdentifier: Int32 + public let currentBundlePath: String + public let resultPath: String + public let readinessPath: String + public let manifestPath: String + public let manifestSignaturePath: String + public let recoveryBundlePath: String + public let requestIdentifier: UUID + + public init( + schemaVersion: Int = Self.currentSchemaVersion, + brewExecutablePath: String, + caskToken: String, + previousVersion: String, + expectedVersion: String, + expectedReleaseTag: String, + expectedArchitecture: String, + expectedAssetURL: URL, + expectedAssetSHA256: String, + expectedAssetSize: Int64, + expectedHomebrewCaskURL: URL, + expectedHomebrewCaskSHA256: String, + expectedHomebrewCaskSize: Int64, + parentProcessIdentifier: Int32, + currentBundlePath: String, + resultPath: String, + readinessPath: String, + manifestPath: String, + manifestSignaturePath: String, + recoveryBundlePath: String, + requestIdentifier: UUID = UUID() + ) { + self.schemaVersion = schemaVersion + self.brewExecutablePath = brewExecutablePath + self.caskToken = caskToken + self.previousVersion = previousVersion + self.expectedVersion = expectedVersion + self.expectedReleaseTag = expectedReleaseTag + self.expectedArchitecture = expectedArchitecture + self.expectedAssetURL = expectedAssetURL + self.expectedAssetSHA256 = expectedAssetSHA256 + self.expectedAssetSize = expectedAssetSize + self.expectedHomebrewCaskURL = expectedHomebrewCaskURL + self.expectedHomebrewCaskSHA256 = expectedHomebrewCaskSHA256 + self.expectedHomebrewCaskSize = expectedHomebrewCaskSize + self.parentProcessIdentifier = parentProcessIdentifier + self.currentBundlePath = currentBundlePath + self.resultPath = resultPath + self.readinessPath = readinessPath + self.manifestPath = manifestPath + self.manifestSignaturePath = manifestSignaturePath + self.recoveryBundlePath = recoveryBundlePath + self.requestIdentifier = requestIdentifier + } + + public init( + installation: GlossHomebrewInstallation, + update: GlossAppUpdateAvailability, + parentProcessIdentifier: Int32, + currentBundleURL: URL, + resultURL: URL, + readinessURL: URL, + manifestURL: URL, + manifestSignatureURL: URL, + recoveryBundleURL: URL, + requestIdentifier: UUID = UUID() + ) { + self.init( + brewExecutablePath: installation.brewExecutableURL.path, + caskToken: installation.caskToken, + previousVersion: installation.installedVersion, + expectedVersion: update.version, + expectedReleaseTag: update.releaseTag, + expectedArchitecture: update.architecture, + expectedAssetURL: update.assetURL, + expectedAssetSHA256: update.assetSHA256, + expectedAssetSize: update.assetSize, + expectedHomebrewCaskURL: update.homebrewCask.url, + expectedHomebrewCaskSHA256: update.homebrewCask.sha256, + expectedHomebrewCaskSize: update.homebrewCask.size, + parentProcessIdentifier: parentProcessIdentifier, + currentBundlePath: currentBundleURL.path, + resultPath: resultURL.path, + readinessPath: readinessURL.path, + manifestPath: manifestURL.path, + manifestSignaturePath: manifestSignatureURL.path, + recoveryBundlePath: recoveryBundleURL.path, + requestIdentifier: requestIdentifier + ) + } + + public var brewExecutableURL: URL { + URL(fileURLWithPath: brewExecutablePath) + } + + public var currentBundleURL: URL { + URL(fileURLWithPath: currentBundlePath, isDirectory: true) + } + + public var resultURL: URL { + URL(fileURLWithPath: resultPath) + } + + public var readinessURL: URL { + URL(fileURLWithPath: readinessPath) + } + + public var manifestURL: URL { + URL(fileURLWithPath: manifestPath) + } + + public var manifestSignatureURL: URL { + URL(fileURLWithPath: manifestSignaturePath) + } + + public var recoveryBundleURL: URL { + URL(fileURLWithPath: recoveryBundlePath, isDirectory: true) + } + + public func validate() throws { + guard schemaVersion == Self.currentSchemaVersion else { + throw GlossHomebrewUpgradeError.invalidRequest( + "unsupported schema version" + ) + } + guard + GlossHomebrewInstallationDetector.brewExecutableURLs.map(\.path) + .contains(brewExecutablePath) + else { + throw GlossHomebrewUpgradeError.invalidRequest( + "brew executable is not trusted" + ) + } + guard caskToken == GlossHomebrewInstallationDetector.caskToken else { + throw GlossHomebrewUpgradeError.invalidRequest( + "cask token is not trusted" + ) + } + guard let parsedPreviousVersion = GlossSemanticVersion(previousVersion) + else { + throw GlossHomebrewUpgradeError.invalidRequest( + "previous version is invalid" + ) + } + guard let parsedExpectedVersion = GlossSemanticVersion(expectedVersion) + else { + throw GlossHomebrewUpgradeError.invalidRequest( + "expected version is invalid" + ) + } + guard parsedPreviousVersion < parsedExpectedVersion else { + throw GlossHomebrewUpgradeError.invalidRequest( + "expected version must be newer than the installed version" + ) + } + guard expectedReleaseTag == "v\(expectedVersion)" else { + throw GlossHomebrewUpgradeError.invalidRequest( + "release tag does not match expected version" + ) + } + guard expectedArchitecture == GlossAppArchitecture.current else { + throw GlossHomebrewUpgradeError.invalidRequest( + "asset architecture does not match this Mac" + ) + } + guard expectedAssetSize > 0, + expectedAssetSHA256.range( + of: "^[0-9a-f]{64}$", + options: .regularExpression + ) != nil + else { + throw GlossHomebrewUpgradeError.invalidRequest( + "signed app asset metadata is invalid" + ) + } + guard expectedHomebrewCaskSize > 0, + expectedHomebrewCaskSHA256.range( + of: "^[0-9a-f]{64}$", + options: .regularExpression + ) != nil + else { + throw GlossHomebrewUpgradeError.invalidRequest( + "signed Homebrew cask metadata is invalid" + ) + } + do { + try GlossAppReleaseManifestValidator.validateOfficialAssetURL( + expectedAssetURL, + architecture: expectedArchitecture, + releaseTag: expectedReleaseTag + ) + try GlossAppReleaseManifestValidator + .validateOfficialHomebrewCaskURL( + expectedHomebrewCaskURL, + releaseTag: expectedReleaseTag + ) + } catch { + throw GlossHomebrewUpgradeError.invalidRequest( + "signed release URLs are invalid" + ) + } + guard parentProcessIdentifier > 1 else { + throw GlossHomebrewUpgradeError.invalidRequest( + "parent process identifier is invalid" + ) + } + guard currentBundlePath == "/Applications/Gloss.app", + currentBundleURL.standardizedFileURL.path == currentBundlePath, + currentBundleURL.lastPathComponent == "Gloss.app" + else { + throw GlossHomebrewUpgradeError.invalidRequest( + "current bundle path is invalid" + ) + } + guard resultPath.hasPrefix("/"), + resultURL.standardizedFileURL.path == resultPath, + resultURL.pathExtension == "json" + else { + throw GlossHomebrewUpgradeError.invalidRequest( + "result path is invalid" + ) + } + guard readinessPath.hasPrefix("/"), + readinessURL.standardizedFileURL.path == readinessPath, + readinessURL.pathExtension == "json", + readinessURL != resultURL + else { + throw GlossHomebrewUpgradeError.invalidRequest( + "readiness path is invalid" + ) + } + guard manifestPath.hasPrefix("/"), + manifestURL.standardizedFileURL.path == manifestPath, + manifestURL.lastPathComponent == "release-manifest.json", + manifestSignaturePath.hasPrefix("/"), + manifestSignatureURL.standardizedFileURL.path + == manifestSignaturePath, + manifestSignatureURL.lastPathComponent + == "release-manifest.json.sig", + manifestURL.deletingLastPathComponent() + == readinessURL.deletingLastPathComponent(), + manifestSignatureURL.deletingLastPathComponent() + == readinessURL.deletingLastPathComponent() + else { + throw GlossHomebrewUpgradeError.invalidRequest( + "signed manifest paths are invalid" + ) + } + let expectedRecoveryURL = + readinessURL.deletingLastPathComponent() + .appendingPathComponent("recovery", isDirectory: true) + .appendingPathComponent("Gloss.app", isDirectory: true) + guard recoveryBundlePath.hasPrefix("/"), + recoveryBundleURL.standardizedFileURL.path == recoveryBundlePath, + recoveryBundleURL == expectedRecoveryURL + else { + throw GlossHomebrewUpgradeError.invalidRequest( + "recovery bundle path is invalid" + ) + } + } +} + +public struct GlossHomebrewUpgradeResult: Codable, Equatable, Sendable { + public enum Outcome: String, Codable, Sendable { + case succeeded + case failed + } + + public static let currentSchemaVersion = 2 + + public let schemaVersion: Int + public let outcome: Outcome + public let expectedVersion: String + public let installedVersion: String? + public let errorCode: String? + public let message: String? + public let recoveredPreviousInstallation: Bool + public let recoveryError: String? + public let completedAt: Date + + public init( + schemaVersion: Int = Self.currentSchemaVersion, + outcome: Outcome, + expectedVersion: String, + installedVersion: String? = nil, + errorCode: String? = nil, + message: String? = nil, + recoveredPreviousInstallation: Bool = false, + recoveryError: String? = nil, + completedAt: Date + ) { + self.schemaVersion = schemaVersion + self.outcome = outcome + self.expectedVersion = expectedVersion + self.installedVersion = installedVersion + self.errorCode = errorCode + self.message = message + self.recoveredPreviousInstallation = recoveredPreviousInstallation + self.recoveryError = recoveryError + self.completedAt = completedAt + } + + public var succeeded: Bool { + outcome == .succeeded + } +} + +public enum GlossUpdateHelperArguments { + public static let requestFileFlag = "--request" + + public static func requestFileURL( + from arguments: [String] + ) throws -> URL { + guard arguments.count == 2, + arguments[0] == requestFileFlag, + arguments[1].hasPrefix("/") + else { + throw GlossHomebrewUpgradeError.invalidArguments + } + return URL(fileURLWithPath: arguments[1]) + } +} + +public enum GlossHomebrewUpgradeError: LocalizedError, Equatable, Sendable { + case invalidArguments + case invalidRequest(String) + case helperReadinessTimedOut + case helperExitedBeforeReady + case parentProcessDidNotExit + case recoveryPreparationFailed(String) + case recoveryFailed(String) + case releaseBindingFailed(String) + case homebrewUpdateFailed(status: Int32, detail: String) + case homebrewUpgradeFailed(status: Int32, detail: String) + case installationVerificationFailed(String) + case relaunchFailed(status: Int32, detail: String) + + public var errorDescription: String? { + switch self { + case .invalidArguments: + "更新 helper 参数无效。" + case .invalidRequest(let reason): + "更新请求无效:\(reason)" + case .helperReadinessTimedOut: + "更新 helper 未在限定时间内完成启动验证。" + case .helperExitedBeforeReady: + "更新 helper 在完成启动验证前退出。" + case .parentProcessDidNotExit: + "Gloss 未在限定时间内退出。" + case .recoveryPreparationFailed(let reason): + "无法准备 Gloss 恢复副本:\(reason)" + case .recoveryFailed(let reason): + "Gloss 更新失败,且恢复上一版本失败:\(reason)" + case .releaseBindingFailed(let reason): + "Homebrew Cask 未通过签名发行绑定验证:\(reason)" + case .homebrewUpdateFailed(let status, let detail): + "Homebrew 更新失败(\(status)):\(detail)" + case .homebrewUpgradeFailed(let status, let detail): + "Gloss 的 Homebrew 升级失败(\(status)):\(detail)" + case .installationVerificationFailed(let reason): + "升级后的 Gloss 未通过验证:\(reason)" + case .relaunchFailed(let status, let detail): + "Gloss 升级完成,但重新打开失败(\(status)):\(detail)" + } + } + + public var resultCode: String { + switch self { + case .invalidArguments: + "invalid_arguments" + case .invalidRequest: + "invalid_request" + case .helperReadinessTimedOut: + "helper_readiness_timeout" + case .helperExitedBeforeReady: + "helper_exited_before_ready" + case .parentProcessDidNotExit: + "parent_exit_timeout" + case .recoveryPreparationFailed: + "recovery_preparation_failed" + case .recoveryFailed: + "recovery_failed" + case .releaseBindingFailed: + "release_binding_failed" + case .homebrewUpdateFailed: + "homebrew_update_failed" + case .homebrewUpgradeFailed: + "homebrew_upgrade_failed" + case .installationVerificationFailed: + "installation_verification_failed" + case .relaunchFailed: + "relaunch_failed" + } + } +} + +public struct GlossUpdateHelperReadiness: Codable, Equatable, Sendable { + public static let currentSchemaVersion = 1 + + public let schemaVersion: Int + public let requestIdentifier: UUID + public let helperProcessIdentifier: Int32 + + public init( + schemaVersion: Int = Self.currentSchemaVersion, + requestIdentifier: UUID, + helperProcessIdentifier: Int32 + ) { + self.schemaVersion = schemaVersion + self.requestIdentifier = requestIdentifier + self.helperProcessIdentifier = helperProcessIdentifier + } +} + +public enum GlossUpdateHelperReadinessStore { + public static func load(from url: URL) throws -> GlossUpdateHelperReadiness { + try JSONDecoder().decode( + GlossUpdateHelperReadiness.self, + from: Data(contentsOf: url) + ) + } + + public static func write( + _ readiness: GlossUpdateHelperReadiness, + to url: URL + ) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(readiness) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try data.write(to: url, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) + } +} + +public struct GlossSignedAppUpdateRequestVerifier: Sendable { + private let validator: GlossAppReleaseManifestValidator + + public init( + manifestSigningPublicKey: Data = + GlossAppReleaseManifestValidator.pinnedManifestSigningPublicKey + ) throws { + validator = try GlossAppReleaseManifestValidator( + manifestSigningPublicKey: manifestSigningPublicKey + ) + } + + public func verify(_ request: GlossHomebrewUpgradeRequest) throws { + let manifest: GlossAppReleaseManifest + do { + manifest = try validator.validate( + manifestData: Data(contentsOf: request.manifestURL), + detachedSignatureData: Data( + contentsOf: request.manifestSignatureURL + ) + ) + } catch { + throw GlossHomebrewUpgradeError.invalidRequest( + "signed release manifest could not be reverified" + ) + } + guard manifest.version == request.expectedVersion, + manifest.releaseTag == request.expectedReleaseTag, + let asset = manifest.assets.first(where: { + $0.operatingSystem == "macos" + && $0.architecture == request.expectedArchitecture + }), + asset.url == request.expectedAssetURL, + asset.sha256 == request.expectedAssetSHA256, + asset.size == request.expectedAssetSize, + manifest.homebrewCask.token == request.caskToken, + manifest.homebrewCask.url + == request.expectedHomebrewCaskURL, + manifest.homebrewCask.sha256 + == request.expectedHomebrewCaskSHA256, + manifest.homebrewCask.size + == request.expectedHomebrewCaskSize + else { + throw GlossHomebrewUpgradeError.invalidRequest( + "update request does not match the signed release manifest" + ) + } + } +} + +public enum GlossHomebrewUpgradeRequestStore { + public static func load(from url: URL) throws -> GlossHomebrewUpgradeRequest { + try JSONDecoder().decode( + GlossHomebrewUpgradeRequest.self, + from: Data(contentsOf: url) + ) + } + + public static func write( + _ request: GlossHomebrewUpgradeRequest, + to url: URL + ) throws { + try writeJSON(request, to: url) + } + + private static func writeJSON( + _ value: T, + to url: URL + ) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(value) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try data.write(to: url, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) + } +} + +public enum GlossHomebrewUpgradeResultStore { + public static func load(from url: URL) throws -> GlossHomebrewUpgradeResult { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode( + GlossHomebrewUpgradeResult.self, + from: Data(contentsOf: url) + ) + } + + public static func write( + _ result: GlossHomebrewUpgradeResult, + to url: URL + ) throws { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(result) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try data.write(to: url, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) + } +} + +public struct GlossParentProcessWaiter: Sendable { + public typealias Wait = @Sendable (Int32, Duration) async throws -> Void + + private let waitImplementation: Wait + + public init(wait: @escaping Wait) { + waitImplementation = wait + } + + public func wait( + for processIdentifier: Int32, + timeout: Duration + ) async throws { + try await waitImplementation(processIdentifier, timeout) + } + + public static let live = Self { processIdentifier, timeout in + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while Self.isRunning(processIdentifier) { + guard clock.now < deadline else { + throw GlossHomebrewUpgradeError.parentProcessDidNotExit + } + try await Task.sleep(for: .milliseconds(250)) + } + } + + private static func isRunning(_ processIdentifier: Int32) -> Bool { + if kill(pid_t(processIdentifier), 0) == 0 { + return true + } + return errno == EPERM + } +} + +public struct GlossHomebrewUpgradeVerifier: Sendable { + public typealias Verify = + @Sendable ( + GlossHomebrewUpgradeRequest + ) async throws -> String + + private let verifyImplementation: Verify + + public init(verify: @escaping Verify) { + verifyImplementation = verify + } + + public func verify( + _ request: GlossHomebrewUpgradeRequest + ) async throws -> String { + try await verifyImplementation(request) + } + + public static func live( + commandRunner: GlossCommandRunner = .live, + pathInspector: GlossPathInspector = .live, + bundleVersionReader: GlossBundleVersionReader = .live + ) -> Self { + Self { request in + let detector = GlossHomebrewInstallationDetector( + commandRunner: commandRunner, + pathInspector: pathInspector + ) + guard + let installation = try await detector.detect( + brewExecutableURL: request.brewExecutableURL, + currentBundleURL: request.currentBundleURL + ) + else { + throw + GlossHomebrewUpgradeError + .installationVerificationFailed( + "Homebrew 不再管理当前 App" + ) + } + guard installation.caskToken == request.caskToken, + Self.installedVersion( + installation.installedVersion, + matchesExpectedVersion: request.expectedVersion + ) + else { + throw + GlossHomebrewUpgradeError + .installationVerificationFailed( + "Homebrew 安装版本与签名发行版本不一致" + ) + } + + let bundleVersion = try bundleVersionReader.version( + at: request.currentBundleURL + ) + guard bundleVersion == installation.installedVersion else { + throw + GlossHomebrewUpgradeError + .installationVerificationFailed( + "App bundle 与 Homebrew 安装版本不一致" + ) + } + + let appExecutableURL = + request.currentBundleURL + .appendingPathComponent("Contents", isDirectory: true) + .appendingPathComponent("MacOS", isDirectory: true) + .appendingPathComponent("Gloss") + let architecture = try await commandRunner.run( + executableURL: URL(fileURLWithPath: "/usr/bin/lipo"), + arguments: [ + appExecutableURL.path, + "-verify_arch", + Self.runningArchitecture, + ] + ) + guard architecture.terminationStatus == 0 else { + throw + GlossHomebrewUpgradeError + .installationVerificationFailed( + "App 不包含当前 Mac 所需的架构" + ) + } + + let verification = try await commandRunner.run( + executableURL: URL(fileURLWithPath: "/usr/bin/codesign"), + arguments: [ + "--verify", + "--deep", + "--strict", + request.currentBundlePath, + ] + ) + guard verification.terminationStatus == 0 else { + throw + GlossHomebrewUpgradeError + .installationVerificationFailed( + "代码签名完整性检查失败" + ) + } + + let signatureDetails = try await commandRunner.run( + executableURL: URL(fileURLWithPath: "/usr/bin/codesign"), + arguments: [ + "--display", + "--verbose=4", + request.currentBundlePath, + ] + ) + let signatureText = Self.commandText(signatureDetails) + guard signatureDetails.terminationStatus == 0, + signatureText.split(separator: "\n").contains( + where: { $0.trimmingCharacters(in: .whitespaces) == "Signature=adhoc" } + ) + else { + throw + GlossHomebrewUpgradeError + .installationVerificationFailed( + "App 不是预期的 ad-hoc 签名" + ) + } + + let quarantine = try await commandRunner.run( + executableURL: URL(fileURLWithPath: "/usr/bin/xattr"), + arguments: [ + "-p", + "com.apple.quarantine", + request.currentBundlePath, + ] + ) + let quarantineText = Self.commandText(quarantine) + guard quarantine.terminationStatus != 0, + quarantineText.contains("No such xattr") + else { + throw + GlossHomebrewUpgradeError + .installationVerificationFailed( + quarantine.terminationStatus == 0 + ? "App 仍带有 quarantine 属性" + : "无法确认 App 的 quarantine 状态" + ) + } + return installation.installedVersion + } + } + + private static func commandText(_ output: GlossCommandOutput) -> String { + String( + decoding: output.standardOutput + output.standardError, + as: UTF8.self + ) + } + + static func installedVersion( + _ installedVersion: String, + matchesExpectedVersion expectedVersion: String + ) -> Bool { + guard let installed = GlossSemanticVersion(installedVersion), + let expected = GlossSemanticVersion(expectedVersion) + else { + return false + } + return installed == expected + } + + private static var runningArchitecture: String { + #if arch(arm64) + "arm64" + #elseif arch(x86_64) + "x86_64" + #else + "unsupported" + #endif + } +} + +public struct GlossBundleVersionReader: Sendable { + public typealias Read = @Sendable (URL) throws -> String + + private let readImplementation: Read + + public init(read: @escaping Read) { + readImplementation = read + } + + public func version(at bundleURL: URL) throws -> String { + try readImplementation(bundleURL) + } + + public static let live = Self { bundleURL in + let infoURL = + bundleURL + .appendingPathComponent("Contents", isDirectory: true) + .appendingPathComponent("Info.plist") + let data = try Data(contentsOf: infoURL) + guard + let propertyList = try PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil + ) as? [String: Any], + let version = propertyList["CFBundleShortVersionString"] as? String, + GlossSemanticVersion(version) != nil + else { + throw GlossHomebrewUpgradeError.installationVerificationFailed( + "无法读取 App 版本" + ) + } + return version + } +} + +public struct GlossRecoveryBundleValidator: Sendable { + public typealias Validate = + @Sendable (URL, Set) async throws -> Void + + private let validateImplementation: Validate + + public init(validate: @escaping Validate) { + validateImplementation = validate + } + + public func validate( + _ bundleURL: URL, + allowedVersions: Set + ) async throws { + try await validateImplementation(bundleURL, allowedVersions) + } + + public static func live( + commandRunner: GlossCommandRunner = .live, + bundleVersionReader: GlossBundleVersionReader = .live + ) -> Self { + Self { bundleURL, allowedVersions in + guard Self.isDirectoryWithoutSymlinks(bundleURL) else { + throw GlossHomebrewUpgradeError.recoveryFailed( + "恢复 App 不是常规目录,或包含符号链接路径" + ) + } + let version = try bundleVersionReader.version(at: bundleURL) + guard allowedVersions.contains(version) else { + throw GlossHomebrewUpgradeError.recoveryFailed( + "恢复 App 版本不在允许范围内" + ) + } + let executableURL = + bundleURL + .appendingPathComponent("Contents/MacOS/Gloss") + let architecture = try await commandRunner.run( + executableURL: URL(fileURLWithPath: "/usr/bin/lipo"), + arguments: [ + executableURL.path, + "-verify_arch", + GlossAppArchitecture.current, + ], + timeout: .seconds(60) + ) + guard architecture.terminationStatus == 0 else { + throw GlossHomebrewUpgradeError.recoveryFailed( + "恢复 App 不包含当前 Mac 架构" + ) + } + let signature = try await commandRunner.run( + executableURL: URL(fileURLWithPath: "/usr/bin/codesign"), + arguments: [ + "--verify", + "--deep", + "--strict", + bundleURL.path, + ], + timeout: .seconds(60) + ) + guard signature.terminationStatus == 0 else { + throw GlossHomebrewUpgradeError.recoveryFailed( + "恢复 App 的代码签名无效" + ) + } + guard try !Self.hasQuarantineAttribute(bundleURL) else { + throw GlossHomebrewUpgradeError.recoveryFailed( + "恢复 App 仍带有 quarantine 属性" + ) + } + } + } + + private static func isDirectoryWithoutSymlinks(_ url: URL) -> Bool { + guard url.resolvingSymlinksInPath() == url.standardizedFileURL else { + return false + } + var metadata = stat() + guard lstat(url.path, &metadata) == 0 else { + return false + } + return metadata.st_mode & S_IFMT == S_IFDIR + } + + private static func hasQuarantineAttribute(_ url: URL) throws -> Bool { + let result = url.path.withCString { path in + "com.apple.quarantine".withCString { name in + getxattr(path, name, nil, 0, 0, 0) + } + } + if result >= 0 { + return true + } + if errno == ENOATTR { + return false + } + throw GlossHomebrewUpgradeError.recoveryFailed( + "无法确认恢复 App 的 quarantine 状态" + ) + } +} + +public struct GlossAppUpdateRecoveryManager: Sendable { + public typealias Prepare = + @Sendable (GlossHomebrewUpgradeRequest) async throws -> Void + public typealias Restore = + @Sendable (GlossHomebrewUpgradeRequest) async throws -> Bool + + private let prepareImplementation: Prepare + private let restoreImplementation: Restore + + public init( + prepare: @escaping Prepare, + restoreIfNeeded: @escaping Restore + ) { + prepareImplementation = prepare + restoreImplementation = restoreIfNeeded + } + + public func prepare(_ request: GlossHomebrewUpgradeRequest) async throws { + try await prepareImplementation(request) + } + + public func restoreIfNeeded( + _ request: GlossHomebrewUpgradeRequest + ) async throws -> Bool { + try await restoreImplementation(request) + } + + public static func live( + validator: GlossRecoveryBundleValidator = .live() + ) -> Self { + Self( + prepare: { request in + let fileManager = FileManager.default + do { + try await validator.validate( + request.currentBundleURL, + allowedVersions: [request.previousVersion] + ) + let recoveryRoot = + request.recoveryBundleURL.deletingLastPathComponent() + try fileManager.createDirectory( + at: recoveryRoot, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + if fileManager.fileExists( + atPath: request.recoveryBundlePath + ) { + try fileManager.removeItem( + at: request.recoveryBundleURL + ) + } + try fileManager.copyItem( + at: request.currentBundleURL, + to: request.recoveryBundleURL + ) + try await validator.validate( + request.recoveryBundleURL, + allowedVersions: [request.previousVersion] + ) + } catch { + throw + GlossHomebrewUpgradeError + .recoveryPreparationFailed( + error.localizedDescription + ) + } + }, + restoreIfNeeded: { request in + let fileManager = FileManager.default + if fileManager.fileExists( + atPath: request.currentBundlePath + ) { + do { + try await validator.validate( + request.currentBundleURL, + allowedVersions: [ + request.previousVersion, + request.expectedVersion, + ] + ) + return false + } catch { + // The installed target is partial or invalid; restore + // only from the already-validated private backup. + } + } + do { + try await validator.validate( + request.recoveryBundleURL, + allowedVersions: [request.previousVersion] + ) + let replacementURL = + request.currentBundleURL.deletingLastPathComponent() + .appendingPathComponent( + ".Gloss-recovery-\(UUID().uuidString).app", + isDirectory: true + ) + defer { + try? fileManager.removeItem(at: replacementURL) + } + try fileManager.copyItem( + at: request.recoveryBundleURL, + to: replacementURL + ) + try await validator.validate( + replacementURL, + allowedVersions: [request.previousVersion] + ) + if fileManager.fileExists( + atPath: request.currentBundlePath + ) { + try fileManager.removeItem( + at: request.currentBundleURL + ) + } + try fileManager.moveItem( + at: replacementURL, + to: request.currentBundleURL + ) + try await validator.validate( + request.currentBundleURL, + allowedVersions: [request.previousVersion] + ) + return true + } catch { + throw GlossHomebrewUpgradeError.recoveryFailed( + error.localizedDescription + ) + } + } + ) + } +} + +public struct GlossHomebrewReleaseBindingVerifier: Sendable { + public static let repositoryArguments = [ + "--repository", + "sunchj/tap", + ] + + public typealias Verify = + @Sendable (GlossHomebrewUpgradeRequest) async throws -> Void + + private let verifyImplementation: Verify + + public init(verify: @escaping Verify) { + verifyImplementation = verify + } + + public func verify(_ request: GlossHomebrewUpgradeRequest) async throws { + try await verifyImplementation(request) + } + + public static func live( + commandRunner: GlossCommandRunner = .live, + commandTimeout: Duration = .seconds(60), + fileLoader: @escaping @Sendable (URL) throws -> Data = { + try Data(contentsOf: $0, options: [.mappedIfSafe]) + } + ) -> Self { + Self { request in + let repository = try await commandRunner.run( + executableURL: request.brewExecutableURL, + arguments: Self.repositoryArguments, + environment: GlossHomebrewUpgradeWorkflow + .noAutomaticUpdateEnvironment, + timeout: commandTimeout + ) + guard repository.terminationStatus == 0 else { + throw GlossHomebrewUpgradeError.releaseBindingFailed( + "无法定位 sunchj/tap" + ) + } + let repositoryText = String( + decoding: repository.standardOutput, + as: UTF8.self + ).trimmingCharacters(in: .whitespacesAndNewlines) + let repositoryLines = repositoryText.split(whereSeparator: \.isNewline) + guard repositoryLines.count == 1, + repositoryText.hasPrefix("/") + else { + throw GlossHomebrewUpgradeError.releaseBindingFailed( + "tap 仓库路径无效" + ) + } + let repositoryURL = URL(fileURLWithPath: repositoryText) + .standardizedFileURL + guard repositoryURL.lastPathComponent == "homebrew-tap", + repositoryURL.deletingLastPathComponent().lastPathComponent + == "sunchj", + repositoryURL.resolvingSymlinksInPath() == repositoryURL + else { + throw GlossHomebrewUpgradeError.releaseBindingFailed( + "tap 仓库身份无效" + ) + } + + let caskURL = + repositoryURL + .appendingPathComponent("Casks", isDirectory: true) + .appendingPathComponent("gloss.rb") + guard + caskURL.standardizedFileURL.path.hasPrefix( + repositoryURL.path + "/" + ), + caskURL.resolvingSymlinksInPath() == caskURL, + Self.isRegularFile(caskURL) + else { + throw GlossHomebrewUpgradeError.releaseBindingFailed( + "tap 中的 Casks/gloss.rb 不是受限目录内的常规文件" + ) + } + let caskData: Data + do { + caskData = try fileLoader(caskURL) + } catch { + throw GlossHomebrewUpgradeError.releaseBindingFailed( + "无法读取 tap 中的 Casks/gloss.rb" + ) + } + let caskSHA256 = SHA256.hash(data: caskData) + .map { String(format: "%02x", $0) } + .joined() + guard caskData.count == request.expectedHomebrewCaskSize, + caskSHA256 == request.expectedHomebrewCaskSHA256 + else { + throw GlossHomebrewUpgradeError.releaseBindingFailed( + "tap 中的 Casks/gloss.rb 与签名 manifest 不一致" + ) + } + + let info = try await commandRunner.run( + executableURL: request.brewExecutableURL, + arguments: GlossHomebrewInstallationDetector.infoArguments, + environment: GlossHomebrewUpgradeWorkflow + .noAutomaticUpdateEnvironment, + timeout: commandTimeout + ) + guard info.terminationStatus == 0 else { + throw GlossHomebrewUpgradeError.releaseBindingFailed( + "无法读取 Gloss cask metadata" + ) + } + let release: GlossHomebrewCaskRelease + do { + release = + try GlossHomebrewInstallationDetector + .parseCaskRelease(info.standardOutput) + } catch { + throw GlossHomebrewUpgradeError.releaseBindingFailed( + "Gloss cask metadata 无效" + ) + } + guard release.version == request.expectedVersion else { + throw GlossHomebrewUpgradeError.releaseBindingFailed( + "cask version 与签名 manifest 不一致" + ) + } + guard release.url == request.expectedAssetURL else { + throw GlossHomebrewUpgradeError.releaseBindingFailed( + "当前架构 cask URL 与签名 manifest 不一致" + ) + } + guard + release.sha256 + == request.expectedAssetSHA256.lowercased() + else { + throw GlossHomebrewUpgradeError.releaseBindingFailed( + "当前架构 cask SHA-256 与签名 manifest 不一致" + ) + } + } + } + + private static func isRegularFile(_ url: URL) -> Bool { + var metadata = stat() + guard lstat(url.path, &metadata) == 0 else { + return false + } + return metadata.st_mode & S_IFMT == S_IFREG + } +} + +public struct GlossHomebrewUpgradeWorkflow: Sendable { + public static let noAutomaticUpdateEnvironment = [ + "HOMEBREW_NO_AUTO_UPDATE": "1" + ] + public static let homebrewUpdateArguments = ["update", "--quiet"] + public static let homebrewUpgradeArguments = [ + "upgrade", + "--cask", + "--require-sha", + GlossHomebrewInstallationDetector.caskToken, + ] + public static let openExecutableURL = URL(fileURLWithPath: "/usr/bin/open") + + private let commandRunner: GlossCommandRunner + private let parentProcessWaiter: GlossParentProcessWaiter + private let recoveryManager: GlossAppUpdateRecoveryManager + private let releaseBindingVerifier: GlossHomebrewReleaseBindingVerifier + private let verifier: GlossHomebrewUpgradeVerifier + private let parentExitTimeout: Duration + private let commandTimeout: Duration + private let now: @Sendable () -> Date + + public init( + commandRunner: GlossCommandRunner, + parentProcessWaiter: GlossParentProcessWaiter, + recoveryManager: GlossAppUpdateRecoveryManager = .live(), + releaseBindingVerifier: GlossHomebrewReleaseBindingVerifier? = nil, + verifier: GlossHomebrewUpgradeVerifier, + parentExitTimeout: Duration = .seconds(120), + commandTimeout: Duration = .seconds(10 * 60), + now: @escaping @Sendable () -> Date = Date.init + ) { + self.commandRunner = commandRunner + self.parentProcessWaiter = parentProcessWaiter + self.recoveryManager = recoveryManager + self.releaseBindingVerifier = + releaseBindingVerifier + ?? .live( + commandRunner: commandRunner, + commandTimeout: commandTimeout + ) + self.verifier = verifier + self.parentExitTimeout = parentExitTimeout + self.commandTimeout = commandTimeout + self.now = now + } + + public static func live() -> Self { + let commandRunner = GlossCommandRunner.live + return Self( + commandRunner: commandRunner, + parentProcessWaiter: .live, + recoveryManager: .live(), + releaseBindingVerifier: .live( + commandRunner: commandRunner + ), + verifier: .live(commandRunner: commandRunner) + ) + } + + @discardableResult + public func runAndPersist( + _ request: GlossHomebrewUpgradeRequest, + afterValidation: @escaping @Sendable () throws -> Void = {} + ) async throws -> GlossHomebrewUpgradeResult { + var installedVersion: String? + var requestIsValid = false + var recoveryPrepared = false + do { + try request.validate() + requestIsValid = true + guard + request.parentProcessIdentifier + != Int32(ProcessInfo.processInfo.processIdentifier) + else { + throw GlossHomebrewUpgradeError.invalidRequest( + "helper cannot wait for itself" + ) + } + try await recoveryManager.prepare(request) + recoveryPrepared = true + try afterValidation() + try await parentProcessWaiter.wait( + for: request.parentProcessIdentifier, + timeout: parentExitTimeout + ) + + let update: GlossCommandOutput + do { + update = try await commandRunner.run( + executableURL: request.brewExecutableURL, + arguments: Self.homebrewUpdateArguments, + timeout: commandTimeout + ) + } catch { + throw GlossHomebrewUpgradeError.homebrewUpdateFailed( + status: -1, + detail: error.localizedDescription + ) + } + guard update.terminationStatus == 0 else { + throw GlossHomebrewUpgradeError.homebrewUpdateFailed( + status: update.terminationStatus, + detail: Self.failureDetail(update) + ) + } + + try await releaseBindingVerifier.verify(request) + + let upgrade: GlossCommandOutput + do { + upgrade = try await commandRunner.run( + executableURL: request.brewExecutableURL, + arguments: Self.homebrewUpgradeArguments, + environment: Self.noAutomaticUpdateEnvironment, + timeout: commandTimeout + ) + } catch { + throw GlossHomebrewUpgradeError.homebrewUpgradeFailed( + status: -1, + detail: error.localizedDescription + ) + } + guard upgrade.terminationStatus == 0 else { + throw GlossHomebrewUpgradeError.homebrewUpgradeFailed( + status: upgrade.terminationStatus, + detail: Self.failureDetail(upgrade) + ) + } + + installedVersion = try await verifier.verify(request) + let success = GlossHomebrewUpgradeResult( + outcome: .succeeded, + expectedVersion: request.expectedVersion, + installedVersion: installedVersion, + completedAt: now() + ) + try GlossHomebrewUpgradeResultStore.write( + success, + to: request.resultURL + ) + + try await relaunch(request) + return success + } catch { + guard requestIsValid else { + throw error + } + var recoveredPreviousInstallation = false + var recoveryError: String? + var reportedError = error + if recoveryPrepared { + do { + recoveredPreviousInstallation = + try await recoveryManager.restoreIfNeeded(request) + } catch { + recoveryError = error.localizedDescription + reportedError = GlossHomebrewUpgradeError.recoveryFailed( + "原始错误:\(reportedError.localizedDescription);\(error.localizedDescription)" + ) + } + } + var failure = failureResult( + request: request, + installedVersion: installedVersion, + error: reportedError, + recoveredPreviousInstallation: + recoveredPreviousInstallation, + recoveryError: recoveryError + ) + var resultWriteError: Error? + do { + try GlossHomebrewUpgradeResultStore.write( + failure, + to: request.resultURL + ) + } catch { + resultWriteError = error + } + do { + try await relaunch(request) + } catch { + let originalMessage = + failure.message ?? "原始更新失败原因未知" + let relaunchError = GlossHomebrewUpgradeError.relaunchFailed( + status: (error as? GlossHomebrewUpgradeError) + .flatMap { upgradeError -> Int32? in + if case .relaunchFailed(let status, _) = upgradeError { + return status + } + return nil + } ?? -1, + detail: + "\(originalMessage);重新打开也失败:\(error.localizedDescription)" + ) + failure = failureResult( + request: request, + installedVersion: installedVersion, + error: relaunchError, + recoveredPreviousInstallation: + recoveredPreviousInstallation, + recoveryError: recoveryError + ) + do { + try GlossHomebrewUpgradeResultStore.write( + failure, + to: request.resultURL + ) + } catch { + resultWriteError = resultWriteError ?? error + } + } + if let resultWriteError { + throw resultWriteError + } + return failure + } + } + + private func failureResult( + request: GlossHomebrewUpgradeRequest, + installedVersion: String?, + error: Error, + recoveredPreviousInstallation: Bool = false, + recoveryError: String? = nil + ) -> GlossHomebrewUpgradeResult { + let upgradeError = error as? GlossHomebrewUpgradeError + return GlossHomebrewUpgradeResult( + outcome: .failed, + expectedVersion: request.expectedVersion, + installedVersion: installedVersion, + errorCode: upgradeError?.resultCode ?? "unexpected_error", + message: error.localizedDescription, + recoveredPreviousInstallation: + recoveredPreviousInstallation, + recoveryError: recoveryError, + completedAt: now() + ) + } + + private static func failureDetail( + _ output: GlossCommandOutput + ) -> String { + let data = + output.standardError.isEmpty + ? output.standardOutput + : output.standardError + let text = String(decoding: data, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + return "no diagnostic output" + } + return String(text.prefix(2_000)) + } + + private func relaunch( + _ request: GlossHomebrewUpgradeRequest + ) async throws { + let relaunch: GlossCommandOutput + do { + relaunch = try await commandRunner.run( + executableURL: Self.openExecutableURL, + arguments: [request.currentBundlePath], + timeout: .seconds(30) + ) + } catch { + throw GlossHomebrewUpgradeError.relaunchFailed( + status: -1, + detail: error.localizedDescription + ) + } + guard relaunch.terminationStatus == 0 else { + throw GlossHomebrewUpgradeError.relaunchFailed( + status: relaunch.terminationStatus, + detail: Self.failureDetail(relaunch) + ) + } + } +} + +public struct GlossUpdateHelperLaunch: Equatable, Sendable { + public let processIdentifier: Int32 + public let stagingDirectoryURL: URL + public let requestURL: URL + public let resultURL: URL + public let readinessURL: URL + + public init( + processIdentifier: Int32, + stagingDirectoryURL: URL, + requestURL: URL, + resultURL: URL, + readinessURL: URL + ) { + self.processIdentifier = processIdentifier + self.stagingDirectoryURL = stagingDirectoryURL + self.requestURL = requestURL + self.resultURL = resultURL + self.readinessURL = readinessURL + } +} + +public struct GlossUpdateHelperReadinessWaiter: Sendable { + public typealias Wait = + @Sendable (URL, UUID, Int32, Duration) async throws -> Void + + private let waitImplementation: Wait + + public init(wait: @escaping Wait) { + waitImplementation = wait + } + + public func wait( + for readinessURL: URL, + requestIdentifier: UUID, + helperProcessIdentifier: Int32, + timeout: Duration + ) async throws { + try await waitImplementation( + readinessURL, + requestIdentifier, + helperProcessIdentifier, + timeout + ) + } + + public static let live = Self { + readinessURL, + requestIdentifier, + helperProcessIdentifier, + timeout in + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if FileManager.default.fileExists(atPath: readinessURL.path) { + let readiness = try GlossUpdateHelperReadinessStore.load( + from: readinessURL + ) + guard + readiness.schemaVersion + == GlossUpdateHelperReadiness.currentSchemaVersion, + readiness.requestIdentifier == requestIdentifier, + readiness.helperProcessIdentifier + == helperProcessIdentifier + else { + throw GlossHomebrewUpgradeError.invalidRequest( + "helper readiness marker does not match the request" + ) + } + return + } + guard Self.isRunning(helperProcessIdentifier) else { + throw GlossHomebrewUpgradeError.helperExitedBeforeReady + } + try await Task.sleep(for: .milliseconds(50)) + } + if Self.isRunning(helperProcessIdentifier) { + kill(pid_t(helperProcessIdentifier), SIGTERM) + } + throw GlossHomebrewUpgradeError.helperReadinessTimedOut + } + + private static func isRunning(_ processIdentifier: Int32) -> Bool { + if kill(pid_t(processIdentifier), 0) == 0 { + return true + } + return errno == EPERM + } +} + +public struct GlossAppUpdateHelperLauncher: Sendable { + public static let helperName = "gloss-update-helper" + + private let readinessWaiter: GlossUpdateHelperReadinessWaiter + + public init( + readinessWaiter: GlossUpdateHelperReadinessWaiter = .live + ) { + self.readinessWaiter = readinessWaiter + } + + public func launch( + bundledHelperURL: URL, + installation: GlossHomebrewInstallation, + update: GlossAppUpdateAvailability, + parentProcessIdentifier: Int32, + currentBundleURL: URL, + cacheRootURL: URL, + resultURL: URL, + readinessTimeout: Duration = .seconds(30) + ) async throws -> GlossUpdateHelperLaunch { + let fileManager = FileManager.default + guard fileManager.isExecutableFile(atPath: bundledHelperURL.path) else { + throw GlossHomebrewUpgradeError.invalidRequest( + "bundled update helper is missing" + ) + } + + let stagingDirectoryURL = cacheRootURL.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + let stagedHelperURL = stagingDirectoryURL.appendingPathComponent( + Self.helperName + ) + let requestURL = stagingDirectoryURL.appendingPathComponent( + "request.json" + ) + let readinessURL = stagingDirectoryURL.appendingPathComponent( + "ready.json" + ) + let manifestURL = stagingDirectoryURL.appendingPathComponent( + "release-manifest.json" + ) + let manifestSignatureURL = stagingDirectoryURL.appendingPathComponent( + "release-manifest.json.sig" + ) + let recoveryBundleURL = + stagingDirectoryURL + .appendingPathComponent("recovery", isDirectory: true) + .appendingPathComponent("Gloss.app", isDirectory: true) + let requestIdentifier = UUID() + let request = GlossHomebrewUpgradeRequest( + installation: installation, + update: update, + parentProcessIdentifier: parentProcessIdentifier, + currentBundleURL: currentBundleURL, + resultURL: resultURL, + readinessURL: readinessURL, + manifestURL: manifestURL, + manifestSignatureURL: manifestSignatureURL, + recoveryBundleURL: recoveryBundleURL, + requestIdentifier: requestIdentifier + ) + try request.validate() + + var process: Process? + do { + try fileManager.createDirectory( + at: stagingDirectoryURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try fileManager.copyItem( + at: bundledHelperURL, + to: stagedHelperURL + ) + try fileManager.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: stagedHelperURL.path + ) + if fileManager.fileExists(atPath: resultURL.path) { + try fileManager.removeItem(at: resultURL) + } + try update.manifestData.write(to: manifestURL, options: .atomic) + try update.detachedSignatureData.write( + to: manifestSignatureURL, + options: .atomic + ) + for protectedURL in [manifestURL, manifestSignatureURL] { + try fileManager.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: protectedURL.path + ) + } + try GlossHomebrewUpgradeRequestStore.write( + request, + to: requestURL + ) + + let helperProcess = Process() + process = helperProcess + helperProcess.executableURL = stagedHelperURL + helperProcess.arguments = [ + GlossUpdateHelperArguments.requestFileFlag, + requestURL.path, + ] + helperProcess.standardInput = FileHandle.nullDevice + helperProcess.standardOutput = FileHandle.nullDevice + helperProcess.standardError = FileHandle.nullDevice + try helperProcess.run() + try await readinessWaiter.wait( + for: readinessURL, + requestIdentifier: requestIdentifier, + helperProcessIdentifier: helperProcess.processIdentifier, + timeout: readinessTimeout + ) + + return GlossUpdateHelperLaunch( + processIdentifier: helperProcess.processIdentifier, + stagingDirectoryURL: stagingDirectoryURL, + requestURL: requestURL, + resultURL: resultURL, + readinessURL: readinessURL + ) + } catch { + if let process, process.isRunning { + process.terminate() + } + try? fileManager.removeItem(at: stagingDirectoryURL) + throw error + } + } +} diff --git a/Sources/GlossUpdateHelper/main.swift b/Sources/GlossUpdateHelper/main.swift new file mode 100644 index 0000000..123f39d --- /dev/null +++ b/Sources/GlossUpdateHelper/main.swift @@ -0,0 +1,36 @@ +import Foundation +import GlossCore + +@main +struct GlossUpdateHelperMain { + static func main() async { + do { + let requestURL = try GlossUpdateHelperArguments.requestFileURL( + from: Array(CommandLine.arguments.dropFirst()) + ) + let request = try GlossHomebrewUpgradeRequestStore.load( + from: requestURL + ) + let signedRequestVerifier = + try GlossSignedAppUpdateRequestVerifier() + let result = try await GlossHomebrewUpgradeWorkflow.live() + .runAndPersist(request) { + try signedRequestVerifier.verify(request) + try GlossUpdateHelperReadinessStore.write( + GlossUpdateHelperReadiness( + requestIdentifier: request.requestIdentifier, + helperProcessIdentifier: + Int32(ProcessInfo.processInfo.processIdentifier) + ), + to: request.readinessURL + ) + } + exit(result.succeeded ? EXIT_SUCCESS : EXIT_FAILURE) + } catch { + FileHandle.standardError.write( + Data("gloss-update-helper: \(error.localizedDescription)\n".utf8) + ) + exit(EXIT_FAILURE) + } + } +} diff --git a/Tests/GlossCoreTests/GlossHomebrewInstallationTests.swift b/Tests/GlossCoreTests/GlossHomebrewInstallationTests.swift new file mode 100644 index 0000000..3f5c8fc --- /dev/null +++ b/Tests/GlossCoreTests/GlossHomebrewInstallationTests.swift @@ -0,0 +1,366 @@ +import Foundation +import Testing + +@testable import GlossCore + +@Suite("Gloss Homebrew installation detection") +struct GlossHomebrewInstallationTests { + private actor RunnerRecorder { + struct Invocation: Equatable, Sendable { + let executableURL: URL + let arguments: [String] + } + + var outputs: [URL: GlossCommandOutput] + var invocations: [Invocation] = [] + + init(outputs: [URL: GlossCommandOutput]) { + self.outputs = outputs + } + + func run( + executableURL: URL, + arguments: [String] + ) -> GlossCommandOutput { + invocations.append( + Invocation( + executableURL: executableURL, + arguments: arguments + ) + ) + return outputs[executableURL] + ?? GlossCommandOutput(terminationStatus: 127, standardOutput: Data()) + } + } + + @Test("detects an arm64 Homebrew-managed app from cask JSON") + func detectsManagedArmInstallation() async throws { + let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/brew") + let currentBundleURL = URL(fileURLWithPath: "/Applications/Gloss.app") + let managedURL = URL( + fileURLWithPath: + "/opt/homebrew/Caskroom/gloss/0.8.2/Gloss.app" + ) + let runner = RunnerRecorder( + outputs: [ + brewURL: GlossCommandOutput( + terminationStatus: 0, + standardOutput: infoJSON() + ) + ] + ) + let detector = GlossHomebrewInstallationDetector( + commandRunner: GlossCommandRunner { executableURL, arguments in + await runner.run( + executableURL: executableURL, + arguments: arguments + ) + }, + pathInspector: pathInspector( + executableURLs: [brewURL], + existingURLs: [currentBundleURL, managedURL], + canonicalPaths: [ + currentBundleURL.path: currentBundleURL.path, + managedURL.path: currentBundleURL.path, + ] + ) + ) + + let installation = try #require( + try await detector.detect(currentBundleURL: currentBundleURL) + ) + + #expect(installation.brewExecutableURL == brewURL) + #expect(installation.caskToken == "sunchj/tap/gloss") + #expect(installation.installedVersion == "0.8.2") + #expect(installation.availableVersion == "0.8.10") + #expect(installation.managedAppURL.path == managedURL.path) + #expect(installation.installedAppTargetURL == currentBundleURL) + #expect( + await runner.invocations == [ + RunnerRecorder.Invocation( + executableURL: brewURL, + arguments: [ + "info", + "--cask", + "--json=v2", + "sunchj/tap/gloss", + ] + ) + ] + ) + } + + @Test("only the two fixed Homebrew executable paths are eligible") + func usesOnlyFixedBrewPaths() async throws { + #expect( + GlossHomebrewInstallationDetector.brewExecutableURLs.map(\.path) + == [ + "/opt/homebrew/bin/brew", + "/usr/local/bin/brew", + ] + ) + + let runner = RunnerRecorder(outputs: [:]) + let detector = GlossHomebrewInstallationDetector( + commandRunner: GlossCommandRunner { executableURL, arguments in + await runner.run( + executableURL: executableURL, + arguments: arguments + ) + }, + pathInspector: pathInspector( + executableURLs: [ + URL(fileURLWithPath: "/tmp/untrusted/bin/brew") + ] + ) + ) + + #expect( + try await detector.detect( + currentBundleURL: URL( + fileURLWithPath: "/Applications/Gloss.app" + ) + ) == nil + ) + #expect(await runner.invocations.isEmpty) + } + + @Test("falls back to the fixed Intel Homebrew path") + func detectsManagedIntelInstallation() async throws { + let brewURL = URL(fileURLWithPath: "/usr/local/bin/brew") + let currentBundleURL = URL(fileURLWithPath: "/Applications/Gloss.app") + let managedURL = URL( + fileURLWithPath: + "/usr/local/Caskroom/gloss/0.8.2/Gloss.app" + ) + let runner = RunnerRecorder( + outputs: [ + brewURL: GlossCommandOutput( + terminationStatus: 0, + standardOutput: infoJSON() + ) + ] + ) + let detector = GlossHomebrewInstallationDetector( + commandRunner: GlossCommandRunner { executableURL, arguments in + await runner.run( + executableURL: executableURL, + arguments: arguments + ) + }, + pathInspector: pathInspector( + executableURLs: [brewURL], + existingURLs: [currentBundleURL, managedURL], + canonicalPaths: [ + currentBundleURL.path: currentBundleURL.path, + managedURL.path: currentBundleURL.path, + ] + ) + ) + + let installation = try #require( + try await detector.detect(currentBundleURL: currentBundleURL) + ) + + #expect(installation.brewExecutableURL == brewURL) + #expect(installation.managedAppURL.path == managedURL.path) + } + + @Test("malformed brew JSON fails explicitly") + func rejectsMalformedJSON() async throws { + let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/brew") + let detector = detector( + brewURL: brewURL, + output: GlossCommandOutput( + terminationStatus: 0, + standardOutput: Data("not JSON".utf8) + ) + ) + + await #expect(throws: GlossHomebrewDetectionError.malformedInfo) { + try await detector.detect( + currentBundleURL: URL( + fileURLWithPath: "/Applications/Gloss.app" + ) + ) + } + } + + @Test("foreign cask identity fails explicitly") + func rejectsForeignCaskIdentity() async throws { + let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/brew") + let foreignJSON = infoJSON( + fullToken: "attacker/tap/gloss", + tap: "attacker/tap" + ) + let detector = detector( + brewURL: brewURL, + output: GlossCommandOutput( + terminationStatus: 0, + standardOutput: foreignJSON + ) + ) + + await #expect( + throws: GlossHomebrewDetectionError.invalidCaskIdentity + ) { + try await detector.detect( + currentBundleURL: URL( + fileURLWithPath: "/Applications/Gloss.app" + ) + ) + } + } + + @Test("a copied app is not treated as the Homebrew-managed instance") + func rejectsNonManagedCopy() async throws { + let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/brew") + let copiedBundleURL = URL( + fileURLWithPath: "/Users/test/Applications/Gloss.app" + ) + let installedTargetURL = URL( + fileURLWithPath: "/Applications/Gloss.app" + ) + let managedURL = URL( + fileURLWithPath: + "/opt/homebrew/Caskroom/gloss/0.8.2/Gloss.app" + ) + let runner = RunnerRecorder( + outputs: [ + brewURL: GlossCommandOutput( + terminationStatus: 0, + standardOutput: infoJSON() + ) + ] + ) + let detector = GlossHomebrewInstallationDetector( + commandRunner: GlossCommandRunner { executableURL, arguments in + await runner.run( + executableURL: executableURL, + arguments: arguments + ) + }, + pathInspector: pathInspector( + executableURLs: [brewURL], + existingURLs: [ + copiedBundleURL, + installedTargetURL, + managedURL, + ], + canonicalPaths: [ + copiedBundleURL.path: copiedBundleURL.path, + installedTargetURL.path: installedTargetURL.path, + managedURL.path: installedTargetURL.path, + ] + ) + ) + + #expect( + try await detector.detect(currentBundleURL: copiedBundleURL) == nil + ) + } + + @Test("an available but uninstalled cask is not a managed install") + func ignoresUninstalledCask() async throws { + let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/brew") + let detector = detector( + brewURL: brewURL, + output: GlossCommandOutput( + terminationStatus: 0, + standardOutput: infoJSON(installedVersion: nil) + ) + ) + + #expect( + try await detector.detect( + currentBundleURL: URL( + fileURLWithPath: "/Applications/Gloss.app" + ) + ) == nil + ) + } + + @Test("a failed brew info command does not claim ownership") + func handlesBrewInfoFailure() async throws { + let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/brew") + let detector = detector( + brewURL: brewURL, + output: GlossCommandOutput( + terminationStatus: 1, + standardOutput: Data(), + standardError: Data("not installed".utf8) + ) + ) + + #expect( + try await detector.detect( + currentBundleURL: URL( + fileURLWithPath: "/Applications/Gloss.app" + ) + ) == nil + ) + } + + private func detector( + brewURL: URL, + output: GlossCommandOutput + ) -> GlossHomebrewInstallationDetector { + GlossHomebrewInstallationDetector( + commandRunner: GlossCommandRunner { _, _ in output }, + pathInspector: pathInspector( + executableURLs: [brewURL] + ) + ) + } + + private func pathInspector( + executableURLs: Set = [], + existingURLs: Set = [], + canonicalPaths: [String: String] = [:] + ) -> GlossPathInspector { + let executablePaths = Set(executableURLs.map(\.path)) + let existingPaths = Set(existingURLs.map(\.path)) + return GlossPathInspector( + isExecutable: { executablePaths.contains($0.path) }, + fileExists: { existingPaths.contains($0.path) }, + pathsReferToSameItem: { first, second in + let firstPath = canonicalPaths[first.path] ?? first.path + let secondPath = canonicalPaths[second.path] ?? second.path + return firstPath == secondPath + } + ) + } + + private func infoJSON( + token: String = "gloss", + fullToken: String = "sunchj/tap/gloss", + tap: String = "sunchj/tap", + version: String = "0.8.10", + installedVersion: String? = "0.8.2" + ) -> Data { + let installedJSON = installedVersion.map { "\"\($0)\"" } ?? "null" + return Data( + """ + { + "formulae": [], + "casks": [ + { + "token": "\(token)", + "full_token": "\(fullToken)", + "tap": "\(tap)", + "version": "\(version)", + "installed": \(installedJSON), + "artifacts": [ + { + "app": ["Gloss.app"], + "target": "/Applications/Gloss.app" + } + ] + } + ] + } + """.utf8 + ) + } +} diff --git a/Tests/GlossCoreTests/GlossHomebrewUpgradeTests.swift b/Tests/GlossCoreTests/GlossHomebrewUpgradeTests.swift new file mode 100644 index 0000000..1343b25 --- /dev/null +++ b/Tests/GlossCoreTests/GlossHomebrewUpgradeTests.swift @@ -0,0 +1,998 @@ +import CryptoKit +import Foundation +import Testing + +@testable import GlossCore + +@Suite("Gloss Homebrew upgrade helper") +struct GlossHomebrewUpgradeTests { + private actor CommandPolicyRecorder { + var updateTimeout: Duration? + var upgradeTimeout: Duration? + var upgradeEnvironment: [String: String]? + + func record( + arguments: [String], + environment: [String: String], + timeout: Duration? + ) { + if arguments == GlossHomebrewUpgradeWorkflow.homebrewUpdateArguments { + updateTimeout = timeout + } else if arguments + == GlossHomebrewUpgradeWorkflow.homebrewUpgradeArguments + { + upgradeTimeout = timeout + upgradeEnvironment = environment + } + } + } + + private actor Recorder { + struct Invocation: Equatable, Sendable { + let executableURL: URL + let arguments: [String] + } + + var invocations: [Invocation] = [] + var output: @Sendable (URL, [String]) -> GlossCommandOutput + + init( + output: + @escaping @Sendable ( + URL, + [String] + ) -> GlossCommandOutput + ) { + self.output = output + } + + func run( + executableURL: URL, + arguments: [String] + ) -> GlossCommandOutput { + invocations.append( + Invocation( + executableURL: executableURL, + arguments: arguments + ) + ) + return output(executableURL, arguments) + } + } + + @Test("request and result stores round-trip their wire formats") + func storesRoundTrip() throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let requestURL = directory.appendingPathComponent("request.json") + let resultURL = directory.appendingPathComponent("result.json") + let request = makeRequest(resultURL: resultURL) + let completedAt = Date(timeIntervalSince1970: 1_785_160_000) + let result = GlossHomebrewUpgradeResult( + outcome: .succeeded, + expectedVersion: "0.8.3", + installedVersion: "0.8.4", + completedAt: completedAt + ) + + try GlossHomebrewUpgradeRequestStore.write(request, to: requestURL) + try GlossHomebrewUpgradeResultStore.write(result, to: resultURL) + + #expect( + try GlossHomebrewUpgradeRequestStore.load(from: requestURL) + == request + ) + #expect( + try GlossHomebrewUpgradeResultStore.load(from: resultURL) + == result + ) + } + + @Test("workflow runs only the fixed Homebrew commands and relaunches") + func workflowSucceeds() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let resultURL = directory.appendingPathComponent("result.json") + let request = makeRequest(resultURL: resultURL) + let brewURL = request.brewExecutableURL + let recorder = Recorder { _, _ in + GlossCommandOutput( + terminationStatus: 0, + standardOutput: Data() + ) + } + let runner = GlossCommandRunner { executableURL, arguments in + await recorder.run( + executableURL: executableURL, + arguments: arguments + ) + } + let workflow = GlossHomebrewUpgradeWorkflow( + commandRunner: runner, + parentProcessWaiter: GlossParentProcessWaiter { processID, timeout in + #expect(processID == 98_765) + #expect(timeout == .seconds(120)) + }, + recoveryManager: noOpRecoveryManager(), + releaseBindingVerifier: GlossHomebrewReleaseBindingVerifier { _ in }, + verifier: GlossHomebrewUpgradeVerifier { _ in "0.8.4" }, + now: { Date(timeIntervalSince1970: 1_785_160_000) } + ) + + let result = try await workflow.runAndPersist(request) + + #expect(result.succeeded) + #expect(result.installedVersion == "0.8.4") + #expect( + await recorder.invocations == [ + Recorder.Invocation( + executableURL: brewURL, + arguments: ["update", "--quiet"] + ), + Recorder.Invocation( + executableURL: brewURL, + arguments: [ + "upgrade", + "--cask", + "--require-sha", + "sunchj/tap/gloss", + ] + ), + Recorder.Invocation( + executableURL: URL(fileURLWithPath: "/usr/bin/open"), + arguments: ["/Applications/Gloss.app"] + ), + ] + ) + #expect( + try GlossHomebrewUpgradeResultStore.load(from: resultURL) + == result + ) + } + + @Test("a failed Homebrew command persists failure and reopens Gloss") + func workflowPersistsFailureAndRelaunches() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let resultURL = directory.appendingPathComponent("result.json") + let request = makeRequest(resultURL: resultURL) + let recorder = Recorder { executableURL, arguments in + if executableURL == request.brewExecutableURL, + arguments == GlossHomebrewUpgradeWorkflow.homebrewUpgradeArguments + { + return GlossCommandOutput( + terminationStatus: 1, + standardOutput: Data(), + standardError: Data("upgrade failed".utf8) + ) + } + return GlossCommandOutput( + terminationStatus: 0, + standardOutput: Data() + ) + } + let runner = GlossCommandRunner { executableURL, arguments in + await recorder.run( + executableURL: executableURL, + arguments: arguments + ) + } + let workflow = GlossHomebrewUpgradeWorkflow( + commandRunner: runner, + parentProcessWaiter: GlossParentProcessWaiter { _, _ in }, + recoveryManager: noOpRecoveryManager(), + releaseBindingVerifier: GlossHomebrewReleaseBindingVerifier { _ in }, + verifier: GlossHomebrewUpgradeVerifier { _ in + Issue.record("verifier must not run after a failed upgrade") + return "0.8.3" + }, + now: { Date(timeIntervalSince1970: 1_785_160_000) } + ) + + let result = try await workflow.runAndPersist(request) + + #expect(!result.succeeded) + #expect(result.errorCode == "homebrew_upgrade_failed") + #expect( + await recorder.invocations.last + == Recorder.Invocation( + executableURL: URL(fileURLWithPath: "/usr/bin/open"), + arguments: ["/Applications/Gloss.app"] + ) + ) + #expect( + try GlossHomebrewUpgradeResultStore.load(from: resultURL) + == result + ) + } + + @Test("a damaged upgrade restores the validated previous App") + func workflowRecordsSuccessfulRecovery() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let request = makeRequest( + resultURL: directory.appendingPathComponent("result.json") + ) + let runner = GlossCommandRunner { executableURL, arguments in + if executableURL == request.brewExecutableURL, + arguments == GlossHomebrewUpgradeWorkflow.homebrewUpgradeArguments + { + return GlossCommandOutput( + terminationStatus: 1, + standardOutput: Data() + ) + } + return GlossCommandOutput( + terminationStatus: 0, + standardOutput: Data() + ) + } + let workflow = GlossHomebrewUpgradeWorkflow( + commandRunner: runner, + parentProcessWaiter: GlossParentProcessWaiter { _, _ in }, + recoveryManager: GlossAppUpdateRecoveryManager( + prepare: { _ in }, + restoreIfNeeded: { _ in true } + ), + releaseBindingVerifier: GlossHomebrewReleaseBindingVerifier { _ in }, + verifier: GlossHomebrewUpgradeVerifier { _ in "0.8.3" } + ) + + let result = try await workflow.runAndPersist(request) + + #expect(!result.succeeded) + #expect(result.recoveredPreviousInstallation) + #expect(result.recoveryError == nil) + #expect(result.errorCode == "homebrew_upgrade_failed") + } + + @Test("recovery failure is persisted and still attempts relaunch") + func workflowRecordsRecoveryFailure() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let request = makeRequest( + resultURL: directory.appendingPathComponent("result.json") + ) + let recorder = Recorder { executableURL, arguments in + if executableURL == request.brewExecutableURL, + arguments == GlossHomebrewUpgradeWorkflow.homebrewUpgradeArguments + { + return GlossCommandOutput( + terminationStatus: 1, + standardOutput: Data() + ) + } + return GlossCommandOutput( + terminationStatus: 0, + standardOutput: Data() + ) + } + let runner = GlossCommandRunner { executableURL, arguments in + await recorder.run( + executableURL: executableURL, + arguments: arguments + ) + } + let workflow = GlossHomebrewUpgradeWorkflow( + commandRunner: runner, + parentProcessWaiter: GlossParentProcessWaiter { _, _ in }, + recoveryManager: GlossAppUpdateRecoveryManager( + prepare: { _ in }, + restoreIfNeeded: { _ in + throw GlossHomebrewUpgradeError.recoveryFailed( + "backup unavailable" + ) + } + ), + releaseBindingVerifier: GlossHomebrewReleaseBindingVerifier { _ in }, + verifier: GlossHomebrewUpgradeVerifier { _ in "0.8.3" } + ) + + let result = try await workflow.runAndPersist(request) + + #expect(result.errorCode == "recovery_failed") + #expect(result.recoveryError?.contains("backup unavailable") == true) + #expect( + await recorder.invocations.last + == Recorder.Invocation( + executableURL: URL(fileURLWithPath: "/usr/bin/open"), + arguments: ["/Applications/Gloss.app"] + ) + ) + } + + @Test("brew commands are bounded and upgrade cannot implicitly update") + func workflowPinsCommandPolicy() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let request = makeRequest( + resultURL: directory.appendingPathComponent("result.json") + ) + let recorder = CommandPolicyRecorder() + let runner = GlossCommandRunner(runWithTimeout: { + _, + arguments, + environment, + timeout in + await recorder.record( + arguments: arguments, + environment: environment, + timeout: timeout + ) + return GlossCommandOutput( + terminationStatus: 0, + standardOutput: Data() + ) + }) + let workflow = GlossHomebrewUpgradeWorkflow( + commandRunner: runner, + parentProcessWaiter: GlossParentProcessWaiter { _, _ in }, + recoveryManager: noOpRecoveryManager(), + releaseBindingVerifier: GlossHomebrewReleaseBindingVerifier { _ in }, + verifier: GlossHomebrewUpgradeVerifier { _ in "0.8.3" }, + commandTimeout: .seconds(42) + ) + + #expect(try await workflow.runAndPersist(request).succeeded) + #expect(await recorder.updateTimeout == .seconds(42)) + #expect(await recorder.upgradeTimeout == .seconds(42)) + #expect( + await recorder.upgradeEnvironment + == GlossHomebrewUpgradeWorkflow.noAutomaticUpdateEnvironment + ) + } + + @Test("live verifier accepts the exact managed ad-hoc build") + func liveVerifierAcceptsExactVersion() async throws { + let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/brew") + let currentBundleURL = URL(fileURLWithPath: "/Applications/Gloss.app") + let managedBundleURL = URL( + fileURLWithPath: "/opt/homebrew/Caskroom/gloss/0.8.4/Gloss.app" + ) + let resultURL = temporaryDirectory() + .appendingPathComponent("result.json") + let request = makeRequest( + resultURL: resultURL, + expectedVersion: "0.8.4" + ) + #if arch(arm64) + let runningArchitecture = "arm64" + #else + let runningArchitecture = "x86_64" + #endif + let recorder = Recorder { executableURL, arguments in + switch (executableURL.path, arguments) { + case ( + brewURL.path, + GlossHomebrewInstallationDetector.infoArguments + ): + return GlossCommandOutput( + terminationStatus: 0, + standardOutput: Self.infoJSON(version: "0.8.4") + ) + case ( + "/usr/bin/lipo", + [ + currentBundleURL + .appendingPathComponent("Contents/MacOS/Gloss").path, + "-verify_arch", + runningArchitecture, + ] + ): + return GlossCommandOutput( + terminationStatus: 0, + standardOutput: Data() + ) + case ( + "/usr/bin/codesign", + ["--verify", "--deep", "--strict", currentBundleURL.path] + ): + return GlossCommandOutput( + terminationStatus: 0, + standardOutput: Data() + ) + case ( + "/usr/bin/codesign", + ["--display", "--verbose=4", currentBundleURL.path] + ): + return GlossCommandOutput( + terminationStatus: 0, + standardOutput: Data(), + standardError: Data( + "Executable=Gloss\nSignature=adhoc\n".utf8 + ) + ) + case ( + "/usr/bin/xattr", + [ + "-p", + "com.apple.quarantine", + currentBundleURL.path, + ] + ): + return GlossCommandOutput( + terminationStatus: 1, + standardOutput: Data(), + standardError: Data( + "No such xattr: com.apple.quarantine".utf8 + ) + ) + default: + return GlossCommandOutput( + terminationStatus: 127, + standardOutput: Data() + ) + } + } + let runner = GlossCommandRunner { executableURL, arguments in + await recorder.run( + executableURL: executableURL, + arguments: arguments + ) + } + let existingPaths = Set([ + brewURL.path, + currentBundleURL.path, + managedBundleURL.path, + ]) + let verifier = GlossHomebrewUpgradeVerifier.live( + commandRunner: runner, + pathInspector: GlossPathInspector( + isExecutable: { $0 == brewURL }, + fileExists: { existingPaths.contains($0.path) }, + pathsReferToSameItem: { first, second in + let canonical: [String: String] = [ + currentBundleURL.path: currentBundleURL.path, + managedBundleURL.path: currentBundleURL.path, + ] + return (canonical[first.path] ?? first.path) + == (canonical[second.path] ?? second.path) + } + ), + bundleVersionReader: GlossBundleVersionReader { _ in "0.8.4" } + ) + + #expect(try await verifier.verify(request) == "0.8.4") + #expect(await recorder.invocations.count == 5) + } + + @Test("post-upgrade verification requires the exact signed version") + func postUpgradeVersionMustMatchExactly() { + #expect( + GlossHomebrewUpgradeVerifier.installedVersion( + "0.8.3", + matchesExpectedVersion: "0.8.3" + ) + ) + #expect( + !GlossHomebrewUpgradeVerifier.installedVersion( + "0.8.4", + matchesExpectedVersion: "0.8.3" + ) + ) + } + + @Test("helper request rejects same-version updates and downgrades") + func requestRequiresStrictlyNewerVersion() { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + for version in ["0.8.2", "0.8.1"] { + let request = makeRequest( + resultURL: directory.appendingPathComponent( + "\(version)-result.json" + ), + expectedVersion: version + ) + #expect(throws: GlossHomebrewUpgradeError.self) { + try request.validate() + } + } + } + + @Test("signed cask and current architecture metadata bind before upgrade") + func releaseBindingSucceeds() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let repository = + directory + .appendingPathComponent("sunchj/homebrew-tap", isDirectory: true) + let caskURL = repository.appendingPathComponent("Casks/gloss.rb") + let caskData = Data("trusted cask".utf8) + try FileManager.default.createDirectory( + at: caskURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try caskData.write(to: caskURL) + let request = makeRequest( + resultURL: directory.appendingPathComponent("result.json"), + expectedHomebrewCaskSHA256: Self.sha256(caskData), + expectedHomebrewCaskSize: Int64(caskData.count) + ) + let runner = bindingRunner( + repository: repository, + version: request.expectedVersion, + assetURL: request.expectedAssetURL, + assetSHA256: request.expectedAssetSHA256 + ) + + try await GlossHomebrewReleaseBindingVerifier.live( + commandRunner: runner + ).verify(request) + } + + @Test("cask version URL and SHA mismatches fail closed") + func releaseBindingRejectsMetadataMismatches() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let repository = + directory + .appendingPathComponent("sunchj/homebrew-tap", isDirectory: true) + let caskURL = repository.appendingPathComponent("Casks/gloss.rb") + let caskData = Data("trusted cask".utf8) + try FileManager.default.createDirectory( + at: caskURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try caskData.write(to: caskURL) + let request = makeRequest( + resultURL: directory.appendingPathComponent("result.json"), + expectedHomebrewCaskSHA256: Self.sha256(caskData), + expectedHomebrewCaskSize: Int64(caskData.count) + ) + let cases: [(String, URL, String)] = [ + ("0.8.4", request.expectedAssetURL, request.expectedAssetSHA256), + ( + request.expectedVersion, + URL(string: "https://attacker.example/Gloss.zip")!, + request.expectedAssetSHA256 + ), + ( + request.expectedVersion, + request.expectedAssetURL, + String(repeating: "c", count: 64) + ), + ] + + for (version, assetURL, assetSHA256) in cases { + let verifier = GlossHomebrewReleaseBindingVerifier.live( + commandRunner: bindingRunner( + repository: repository, + version: version, + assetURL: assetURL, + assetSHA256: assetSHA256 + ) + ) + await #expect(throws: GlossHomebrewUpgradeError.self) { + try await verifier.verify(request) + } + } + } + + @Test("tampered tap cask fails signed hash binding") + func releaseBindingRejectsTamperedCask() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let repository = + directory + .appendingPathComponent("sunchj/homebrew-tap", isDirectory: true) + let caskURL = repository.appendingPathComponent("Casks/gloss.rb") + try FileManager.default.createDirectory( + at: caskURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("tampered".utf8).write(to: caskURL) + let request = makeRequest( + resultURL: directory.appendingPathComponent("result.json"), + expectedHomebrewCaskSHA256: Self.sha256(Data("trusted".utf8)), + expectedHomebrewCaskSize: Int64(Data("trusted".utf8).count) + ) + + await #expect(throws: GlossHomebrewUpgradeError.self) { + try await GlossHomebrewReleaseBindingVerifier.live( + commandRunner: bindingRunner( + repository: repository, + version: request.expectedVersion, + assetURL: request.expectedAssetURL, + assetSHA256: request.expectedAssetSHA256 + ) + ).verify(request) + } + } + + @Test("tap cask must be a regular in-repository file") + func releaseBindingRejectsSymlinkedCask() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let repository = + directory + .appendingPathComponent("sunchj/homebrew-tap", isDirectory: true) + let caskURL = repository.appendingPathComponent("Casks/gloss.rb") + let outsideURL = directory.appendingPathComponent("outside.rb") + let caskData = Data("trusted cask".utf8) + try FileManager.default.createDirectory( + at: caskURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try caskData.write(to: outsideURL) + try FileManager.default.createSymbolicLink( + at: caskURL, + withDestinationURL: outsideURL + ) + let request = makeRequest( + resultURL: directory.appendingPathComponent("result.json"), + expectedHomebrewCaskSHA256: Self.sha256(caskData), + expectedHomebrewCaskSize: Int64(caskData.count) + ) + + await #expect(throws: GlossHomebrewUpgradeError.self) { + try await GlossHomebrewReleaseBindingVerifier.live( + commandRunner: bindingRunner( + repository: repository, + version: request.expectedVersion, + assetURL: request.expectedAssetURL, + assetSHA256: request.expectedAssetSHA256 + ) + ).verify(request) + } + } + + @Test("helper independently reverifies the raw signed manifest") + func signedRequestIsReverified() throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let resultURL = directory.appendingPathComponent("result.json") + let request = makeRequest(resultURL: resultURL) + let manifest = GlossAppReleaseManifest( + version: request.expectedVersion, + releaseTag: request.expectedReleaseTag, + publishedAt: "2026-07-27T00:00:00Z", + minimumMacOSVersion: "14.0", + assets: ["arm64", "x86_64"].map { architecture in + GlossAppReleaseManifest.Asset( + operatingSystem: "macos", + architecture: architecture, + url: URL( + string: + "https://github.com/SunChJ/gloss-releases/releases/download/\(request.expectedReleaseTag)/Gloss-macos-\(architecture).zip" + )!, + sha256: String(repeating: "a", count: 64), + size: 100 + ) + }, + homebrewCask: GlossAppReleaseManifest.HomebrewCask( + url: request.expectedHomebrewCaskURL, + sha256: request.expectedHomebrewCaskSHA256, + size: request.expectedHomebrewCaskSize + ) + ) + let privateKey = Curve25519.Signing.PrivateKey() + let manifestData = try JSONEncoder().encode(manifest) + try manifestData.write(to: request.manifestURL) + try privateKey.signature(for: manifestData).write( + to: request.manifestSignatureURL + ) + + try GlossSignedAppUpdateRequestVerifier( + manifestSigningPublicKey: privateKey.publicKey.rawRepresentation + ).verify(request) + + var tampered = manifestData + tampered[tampered.startIndex] ^= 0x01 + try tampered.write(to: request.manifestURL) + #expect(throws: GlossHomebrewUpgradeError.self) { + try GlossSignedAppUpdateRequestVerifier( + manifestSigningPublicKey: + privateKey.publicKey.rawRepresentation + ).verify(request) + } + } + + @Test("readiness marker is required before the App can exit") + func readinessHandshakeSucceedsAndRejectsEarlyExit() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let readinessURL = directory.appendingPathComponent("ready.json") + let requestIdentifier = UUID() + let processIdentifier = + Int32(ProcessInfo.processInfo.processIdentifier) + try GlossUpdateHelperReadinessStore.write( + GlossUpdateHelperReadiness( + requestIdentifier: requestIdentifier, + helperProcessIdentifier: processIdentifier + ), + to: readinessURL + ) + try await GlossUpdateHelperReadinessWaiter.live.wait( + for: readinessURL, + requestIdentifier: requestIdentifier, + helperProcessIdentifier: processIdentifier, + timeout: .seconds(1) + ) + + let exited = Process() + exited.executableURL = URL(fileURLWithPath: "/usr/bin/true") + try exited.run() + exited.waitUntilExit() + await #expect(throws: GlossHomebrewUpgradeError.helperExitedBeforeReady) { + try await GlossUpdateHelperReadinessWaiter.live.wait( + for: directory.appendingPathComponent("missing.json"), + requestIdentifier: UUID(), + helperProcessIdentifier: exited.processIdentifier, + timeout: .seconds(1) + ) + } + } + + @Test("readiness and command execution have bounded timeouts") + func helperAndCommandTimeouts() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let helper = Process() + helper.executableURL = URL(fileURLWithPath: "/bin/sleep") + helper.arguments = ["10"] + try helper.run() + await #expect(throws: GlossHomebrewUpgradeError.helperReadinessTimedOut) { + try await GlossUpdateHelperReadinessWaiter.live.wait( + for: directory.appendingPathComponent("missing.json"), + requestIdentifier: UUID(), + helperProcessIdentifier: helper.processIdentifier, + timeout: .milliseconds(50) + ) + } + + await #expect(throws: GlossCommandRunnerError.self) { + try await GlossCommandRunner.live.run( + executableURL: URL(fileURLWithPath: "/bin/sleep"), + arguments: ["10"], + timeout: .milliseconds(50) + ) + } + } + + @Test("launcher stages an independent helper and canonical request") + func launcherStagesHelper() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let cacheURL = directory.appendingPathComponent( + "AppUpdater", + isDirectory: true + ) + let resultURL = cacheURL.appendingPathComponent("latest-result.json") + try FileManager.default.createDirectory( + at: cacheURL, + withIntermediateDirectories: true + ) + try Data("old".utf8).write(to: resultURL, options: .atomic) + let installation = GlossHomebrewInstallation( + brewExecutableURL: URL( + fileURLWithPath: "/opt/homebrew/bin/brew" + ), + caskToken: "sunchj/tap/gloss", + installedVersion: "0.8.2", + availableVersion: "0.8.3", + managedAppURL: URL( + fileURLWithPath: + "/opt/homebrew/Caskroom/gloss/0.8.2/Gloss.app" + ), + installedAppTargetURL: URL( + fileURLWithPath: "/Applications/Gloss.app" + ) + ) + + let launch = try await GlossAppUpdateHelperLauncher( + readinessWaiter: GlossUpdateHelperReadinessWaiter { + _, + _, + _, + _ in + } + ).launch( + bundledHelperURL: URL(fileURLWithPath: "/usr/bin/true"), + installation: installation, + update: updateAvailability(), + parentProcessIdentifier: 98_765, + currentBundleURL: URL( + fileURLWithPath: "/Applications/Gloss.app" + ), + cacheRootURL: cacheURL, + resultURL: resultURL + ) + + #expect(launch.processIdentifier > 1) + #expect( + FileManager.default.isExecutableFile( + atPath: + launch.stagingDirectoryURL + .appendingPathComponent("gloss-update-helper").path + ) + ) + #expect(!FileManager.default.fileExists(atPath: resultURL.path)) + let request = try GlossHomebrewUpgradeRequestStore.load( + from: launch.requestURL + ) + #expect(request.expectedVersion == "0.8.3") + #expect(request.resultPath == resultURL.path) + #expect(request.expectedArchitecture == GlossAppArchitecture.current) + #expect( + try Data(contentsOf: request.manifestURL) + == updateAvailability().manifestData + ) + } + + private func makeRequest( + resultURL: URL, + expectedVersion: String = "0.8.3", + expectedHomebrewCaskSHA256: String = String( + repeating: "b", + count: 64 + ), + expectedHomebrewCaskSize: Int64 = 200 + ) -> GlossHomebrewUpgradeRequest { + GlossHomebrewUpgradeRequest( + brewExecutablePath: "/opt/homebrew/bin/brew", + caskToken: "sunchj/tap/gloss", + previousVersion: "0.8.2", + expectedVersion: expectedVersion, + expectedReleaseTag: "v\(expectedVersion)", + expectedArchitecture: GlossAppArchitecture.current, + expectedAssetURL: URL( + string: + "https://github.com/SunChJ/gloss-releases/releases/download/v\(expectedVersion)/Gloss-macos-\(GlossAppArchitecture.current).zip" + )!, + expectedAssetSHA256: String(repeating: "a", count: 64), + expectedAssetSize: 100, + expectedHomebrewCaskURL: URL( + string: + "https://github.com/SunChJ/gloss-releases/releases/download/v\(expectedVersion)/gloss.rb" + )!, + expectedHomebrewCaskSHA256: expectedHomebrewCaskSHA256, + expectedHomebrewCaskSize: expectedHomebrewCaskSize, + parentProcessIdentifier: 98_765, + currentBundlePath: "/Applications/Gloss.app", + resultPath: resultURL.path, + readinessPath: + resultURL.deletingLastPathComponent() + .appendingPathComponent("ready.json").path, + manifestPath: + resultURL.deletingLastPathComponent() + .appendingPathComponent("release-manifest.json").path, + manifestSignaturePath: + resultURL.deletingLastPathComponent() + .appendingPathComponent("release-manifest.json.sig").path, + recoveryBundlePath: + resultURL.deletingLastPathComponent() + .appendingPathComponent("recovery/Gloss.app").path + ) + } + + private func updateAvailability() -> GlossAppUpdateAvailability { + GlossAppUpdateAvailability( + version: "0.8.3", + releaseTag: "v0.8.3", + publishedAt: Date(timeIntervalSince1970: 1_000), + minimumMacOSVersion: "14.0", + releasePageURL: URL( + string: + "https://github.com/SunChJ/gloss-releases/releases/tag/v0.8.3" + )!, + architecture: GlossAppArchitecture.current, + assetURL: URL( + string: + "https://github.com/SunChJ/gloss-releases/releases/download/v0.8.3/Gloss-macos-\(GlossAppArchitecture.current).zip" + )!, + assetSHA256: String(repeating: "a", count: 64), + assetSize: 100, + homebrewCask: GlossAppReleaseManifest.HomebrewCask( + url: URL( + string: + "https://github.com/SunChJ/gloss-releases/releases/download/v0.8.3/gloss.rb" + )!, + sha256: String(repeating: "b", count: 64), + size: 200 + ), + manifestData: Data("signed manifest".utf8), + detachedSignatureData: Data("signature".utf8) + ) + } + + private func noOpRecoveryManager() -> GlossAppUpdateRecoveryManager { + GlossAppUpdateRecoveryManager( + prepare: { _ in }, + restoreIfNeeded: { _ in false } + ) + } + + private func bindingRunner( + repository: URL, + version: String, + assetURL: URL, + assetSHA256: String + ) -> GlossCommandRunner { + GlossCommandRunner { executableURL, arguments in + guard executableURL.path == "/opt/homebrew/bin/brew" else { + return GlossCommandOutput( + terminationStatus: 127, + standardOutput: Data() + ) + } + if arguments + == GlossHomebrewReleaseBindingVerifier.repositoryArguments + { + return GlossCommandOutput( + terminationStatus: 0, + standardOutput: Data("\(repository.path)\n".utf8) + ) + } + if arguments == GlossHomebrewInstallationDetector.infoArguments { + return GlossCommandOutput( + terminationStatus: 0, + standardOutput: Self.infoJSON( + version: version, + assetURL: assetURL, + assetSHA256: assetSHA256 + ) + ) + } + return GlossCommandOutput( + terminationStatus: 127, + standardOutput: Data() + ) + } + } + + private static func sha256(_ data: Data) -> String { + SHA256.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() + } + + private func temporaryDirectory() -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent( + "gloss-homebrew-upgrade-tests-\(UUID().uuidString)", + isDirectory: true + ) + try? FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: true + ) + return url + } + + private static func infoJSON( + version: String, + assetURL: URL? = nil, + assetSHA256: String = String(repeating: "a", count: 64) + ) -> Data { + let resolvedAssetURL = + assetURL + ?? URL( + string: + "https://github.com/SunChJ/gloss-releases/releases/download/v\(version)/Gloss-macos-\(GlossAppArchitecture.current).zip" + )! + return Data( + """ + { + "formulae": [], + "casks": [ + { + "token": "gloss", + "full_token": "sunchj/tap/gloss", + "tap": "sunchj/tap", + "version": "\(version)", + "installed": "\(version)", + "url": "\(resolvedAssetURL.absoluteString)", + "sha256": "\(assetSHA256)", + "artifacts": [ + { + "app": ["Gloss.app"], + "target": "/Applications/Gloss.app" + } + ] + } + ] + } + """.utf8 + ) + } +} diff --git a/docs/runtime-distribution.md b/docs/runtime-distribution.md index 6c4eb71..0cf0bbf 100644 --- a/docs/runtime-distribution.md +++ b/docs/runtime-distribution.md @@ -90,11 +90,13 @@ active runtime。 证书,也不执行 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`。Manifest 和 +3. 生成并校验使用 `on_arm` / `on_intel` URL 与 SHA-256 的根级 Release asset + `gloss.rb`。Manifest 还会签名绑定其固定 token、URL、SHA-256 与 size;tap workflow + 再将它落到 `Casks/gloss.rb`。Manifest 和 Cask 中的下载地址固定指向公开仓库 `https://github.com/SunChJ/gloss-releases/releases/download//`。 4. 始终上传私有主仓中的 Actions artifact,便于内部验证。 -5. 仅在 tag 事件中使用跨仓库 token,把 app zip、校验和、manifest 与生成的 Cask 发布到 +5. 仅在 tag 事件中使用跨仓库 token,把 app zip、校验和、manifest 与根级 `gloss.rb` 发布到 公开的 `SunChJ/gloss-releases` GitHub Release。 6. Release 上传成功后,dispatch `SunChJ/homebrew-tap` 的 `update-cask.yml`,由公开 tap 下载并二次校验 Release,再更新 `Casks/gloss.rb`。 @@ -180,7 +182,7 @@ gh workflow run update-cask.yml \ -f release_repository=SunChJ/gloss-releases ``` -公开 tap 合并生成的 Cask 更新后,用户使用标准 tap 名称安装和升级: +公开 tap 的校验全部通过后会自动合并生成的 Cask 更新,用户使用标准 tap 名称安装和升级: ```bash brew tap sunchj/tap @@ -199,9 +201,11 @@ brew upgrade --cask sunchj/tap/gloss 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。 + `gloss-release-manifest.json`、签名文件与根级 `gloss.rb` 均存在且 URL 指向该公开 Release; + tap 会把同一份 `gloss.rb` 落到 `Casks/gloss.rb`。 +7. 等待 `SunChJ/homebrew-tap` 生成的 Cask PR 在 required checks 通过后自动合并;workflow + 会在 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, From 9698ed6d6a4a1a612869c71c46a5d8b93489cfcf Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Mon, 27 Jul 2026 01:04:33 -0700 Subject: [PATCH 5/9] feat: focus the App on browser and PDF scenarios --- README.md | 60 +- Sources/Gloss/AppUpdateController.swift | 245 +++++++ Sources/Gloss/AppUpdateDashboardState.swift | 232 +++++++ Sources/Gloss/GlossAppDelegate.swift | 610 +++++++++++++----- .../Gloss/GlossAppScenarioActivation.swift | 27 + .../PDFTranslationWindowController.swift | 4 + Sources/Gloss/SettingsWindowController.swift | 464 +++++++++---- .../AppUpdateControllerTests.swift | 278 ++++++++ .../AppUpdateDashboardStateTests.swift | 160 +++++ .../GlossAppScenarioActivationTests.swift | 79 +++ 10 files changed, 1867 insertions(+), 292 deletions(-) create mode 100644 Sources/Gloss/AppUpdateController.swift create mode 100644 Sources/Gloss/AppUpdateDashboardState.swift create mode 100644 Sources/Gloss/GlossAppScenarioActivation.swift create mode 100644 Tests/GlossAppTests/AppUpdateControllerTests.swift create mode 100644 Tests/GlossAppTests/AppUpdateDashboardStateTests.swift create mode 100644 Tests/GlossAppTests/GlossAppScenarioActivationTests.swift diff --git a/README.md b/README.md index 58e5c41..4f92083 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,24 @@ # Gloss -Gloss 是一款 macOS 原生、上下文感知的系统级翻译工具:选中任何文本,原地理解、翻译并继续工作。 +Gloss 是一款 macOS 原生翻译工具。当前默认产品面聚焦两条已经闭环的业务场景: +Safari/Chrome 浏览器翻译,以及保留版式的 PDF 批量翻译。 -当前实现已经打通第一条完整链路: +## 核心能力与业务场景 + +Gloss 不再按窗口堆叠功能,而是由可复用核心能力拼装业务场景。App 菜单、后台服务和 +`gloss-cli` 都读取同一份能力注册表;关闭一个场景会同时收起入口并停止它独占的常驻服务, +不会删除底层实现。 + +| 默认场景 | 组合的主要能力 | macOS 入口 | CLI 映射 | +| --- | --- | --- | --- | +| 浏览器翻译 | 文本翻译、Provider/语言路由、本机回环桥接、Safari/Chrome 适配 | Safari 与 Chrome 扩展 | `gloss-cli browser` | +| PDF 翻译 | 文档翻译、版面分析、翻译桥接、BabelDOC runtime、批量队列、PDF 导出 | PDF 模块、Finder 打开与拖拽 | `gloss-cli pdf` | + +可使用 `gloss-cli capabilities --json` 获取稳定、机器可读的核心能力、启用场景和命令映射。 +剪贴板、OCR/截图、系统选区、术语表和历史记录的实现仍保留在代码中,但默认不启动相关监听, +也不在主菜单和设置中展示。 + +底层已经实现的能力包括: - 监听鼠标选区、已选文本长按和可录制的全局快捷键(默认 `⌃⌥G`) - 可全局关闭自动出现,或仅在指定 App 中停用;手动快捷键仍然可用 @@ -52,13 +68,14 @@ tail -f ~/Library/Logs/Gloss/gloss.log 1. GPT 订阅:首次启动时,在 Gloss 设置中点击“登录 ChatGPT”。默认构建不需要单独安装 Codex CLI 或 Node.js;CLI 构建需要用户已安装支持 `app-server` 的 Codex CLI。 2. 本地模型:安装 `llama.cpp`(`brew install llama.cpp`),然后在翻译引擎中选择“本地模型”。首次启动会从 Hugging Face 下载约 1.1 GB 的 Q4 模型。 -3. 首次使用选区翻译时,在系统设置中允许 Gloss 使用“辅助功能”。 +3. 重新启用选区翻译场景后,首次使用时需在系统设置中允许 Gloss 使用“辅助功能”。 4. Chrome:在 Gloss 设置中点“显示扩展”,从 `chrome://extensions` 加载这个已自动配对的目录。 5. Safari:在 Gloss 设置中点“Safari 设置”,启用随 App 内置的 Gloss Extension。 Gloss 的登录状态和 Codex 配置保存在 `~/Library/Application Support/Gloss/Codex/`,不会修改系统 Codex CLI 的数据。 -系统“服务”入口默认由 macOS 管理。可在 Gloss 设置中打开“键盘快捷键”,再到“服务”里启用文本或图片翻译入口。 +重新启用剪贴板或图片翻译场景后,系统“服务”入口仍由 macOS 管理,可在“系统设置 › +键盘 › 键盘快捷键 › 服务”中启用对应入口。 ## 开发 @@ -70,6 +87,20 @@ swift run Gloss 直接验证翻译后端: ```bash +# 查询 App 与 CLI 共用的能力/场景映射 +swift run gloss-cli capabilities --json + +# 浏览器翻译场景(默认 content kind 为 webpage) +swift run gloss-cli browser --target 'Chinese (Simplified)' 'Translate this webpage.' + +# PDF 批量翻译场景 +swift run gloss-cli pdf paper-a.pdf paper-b.pdf \ + --output ./translated \ + --target 'Chinese (Simplified)' \ + --mode mono + +# 向后兼容的文本翻译入口 +swift run gloss-cli text --target 'Chinese (Simplified)' 'Translate this text.' swift run gloss-cli --target 'Chinese (Simplified)' 'Translate this text.' swift run gloss-cli --provider llama --target 'Chinese (Simplified)' 'Translate locally.' swift run gloss-cli --provider codex --model gpt-5.3-codex-spark --reasoning low 'Translate quickly.' @@ -77,6 +108,11 @@ printf 'Translate stdin.\n' | swift run gloss-cli --target Japanese swift run gloss-cli --kind ocr 'Text recognized from an image.' ``` +`browser` 可从参数或 stdin 读取已经提取的网页正文,并使用与 Safari/Chrome 扩展相同的 +网页翻译语义;它不会自动操控浏览器 UI。`pdf` 接受一个或多个 PDF,顺序处理并复用同一 +BabelDOC 会话;进度写入 stderr,最终产物路径以 JSON 写入 stdout,适合脚本调用。默认源语言 +为 `en`、目标语言为 `Chinese (Simplified)`,输出方式为仅译文 PDF。 + 开发时可以覆盖原生 app-server 和独立数据目录: ```bash @@ -168,7 +204,7 @@ atomic state file 防止半安装状态。完整 manifest schema、安全边界 ### GitHub Release 与 Homebrew 推送与 `Resources/Info.plist` 一致的 `v*` tag 会运行 Release workflow,产出 -arm64 与 x86_64 两套 `Gloss.app` zip、`SHA256SUMS`、release manifest 和带 +arm64 与 x86_64 两套 `Gloss.app` zip、`SHA256SUMS`、Ed25519 签名的 release manifest 和带 `on_arm` / `on_intel` 校验的 Homebrew cask。私有 `SunChJ/gloss` 只负责构建;ad-hoc 签名后的资产发布到公开 `SunChJ/gloss-releases`,随后自动 dispatch `SunChJ/homebrew-tap` 更新 Cask。下载 URL 不会指向私有主仓。 @@ -182,6 +218,9 @@ brew update brew upgrade --cask sunchj/tap/gloss ``` +Homebrew 同时把 App 内置的 `gloss-cli` 链接到其 `bin` 目录;安装后可直接运行 +`gloss-cli capabilities --json`,无需从 `.app` 包内手工定位可执行文件。 + Release workflow 使用只读 `GLOSS_EXTENSION_SSH_KEY` 检出私有浏览器扩展;正式 tag 另外 要求跨仓库 `GLOSS_DISTRIBUTION_TOKEN`。缺失时 workflow 会在构建和上传前 fail closed。 手工 workflow 不发布,但仍需要 extension deploy key 才能生成完整 App artifact。 @@ -190,11 +229,20 @@ Gatekeeper 交互;这也意味着 macOS 无法验证 Apple 开发者身份或 fine-grained token 权限、完整安全取舍、发行顺序与恢复步骤见 [发行文档](docs/runtime-distribution.md)。 +正式版 App 启动后会延迟、静默检查签名 manifest,并以 24 小时为自动检查间隔。只有确认 +当前 `Gloss.app` 由 `sunchj/tap/gloss` 管理时,界面才提供“一键更新并重新启动”;独立 helper +会在 App 退出后调用固定的 `brew update` / `brew upgrade --cask --require-sha` 参数。helper +会独立复验原始 manifest 签名,并在执行升级前确认 tap 中的 `gloss.rb`、Cask 版本及当前架构 +URL/SHA 都与签名 manifest 完全一致;升级命令禁用隐式 auto-update,随后再精确验证安装版本、 +当前架构、ad-hoc 签名与 quarantine 状态。App 只在 helper 完成签名复验和旧版恢复副本校验后 +退出;升级损坏安装时会恢复并重新验证上一版本。任何浏览器或 PDF 翻译任务进行中时都不会启动 +升级。非 Homebrew 安装只会打开官方 release 页面。 + ## 代码结构 ```text Sources/GlossCore/ Codex/llama 客户端、provider 路由、翻译模型、缓存与并发合并 Sources/GlossOCR/ 本地 Vision OCR 与版面阅读顺序恢复 Sources/Gloss/ macOS 选区、图片、截图、结果面板、文本替换与浏览器桥接 -Sources/GlossCLI/ 薄命令行入口 +Sources/GlossCLI/ 浏览器/PDF 场景命令与向后兼容的文本入口 ``` diff --git a/Sources/Gloss/AppUpdateController.swift b/Sources/Gloss/AppUpdateController.swift new file mode 100644 index 0000000..0488cf8 --- /dev/null +++ b/Sources/Gloss/AppUpdateController.swift @@ -0,0 +1,245 @@ +import Foundation +import GlossCore + +enum AppUpdateStagingCleaner { + enum CleanupError: Error, Equatable { + case invalidDirectory + } + + static func removeStagedHelpers( + at stagingRootURL: URL, + fileManager: FileManager = .default + ) throws { + let standardizedURL = stagingRootURL.standardizedFileURL + guard standardizedURL.lastPathComponent == "staging", + standardizedURL.deletingLastPathComponent().lastPathComponent + == "AppUpdater" + else { + throw CleanupError.invalidDirectory + } + guard fileManager.fileExists(atPath: standardizedURL.path) else { + return + } + try fileManager.removeItem(at: standardizedURL) + } +} + +@MainActor +final class AppUpdateController { + struct Dependencies { + var check: + (GlossAppUpdateCheckMode) async throws + -> GlossAppUpdateCheckResult + var detectHomebrewInstallation: + () async throws + -> GlossHomebrewInstallation? + var launchHomebrewUpdate: + ( + GlossHomebrewInstallation, + GlossAppUpdateAvailability + ) async throws -> Void + var openReleasePage: (URL) -> Bool + var isBusinessTaskActive: () async -> Bool + var requestApplicationTermination: () -> Void + var operatingSystemVersion: () -> OperatingSystemVersion + } + + private(set) var state: AppUpdateDashboardState { + didSet { + onStateChange?(state) + } + } + + var onStateChange: ((AppUpdateDashboardState) -> Void)? + + private let currentVersion: String + private let dependencies: Dependencies + private var automaticCheckTask: Task? + private var operationInProgress = false + + init( + currentVersion: String, + dependencies: Dependencies, + initialState: AppUpdateDashboardState? = nil + ) { + self.currentVersion = currentVersion + self.dependencies = dependencies + state = initialState ?? .idle(currentVersion: currentVersion) + } + + func startAutomaticCheck( + after delay: Duration = .seconds(4) + ) { + guard automaticCheckTask == nil else { return } + automaticCheckTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: delay) + } catch { + return + } + guard let self, !Task.isCancelled else { return } + await check(mode: .automatic) + } + } + + func cancel() { + automaticCheckTask?.cancel() + automaticCheckTask = nil + } + + func check(mode: GlossAppUpdateCheckMode) async { + guard !operationInProgress else { return } + operationInProgress = true + let previousState = state + state = .checking(currentVersion: currentVersion) + defer { operationInProgress = false } + + do { + let result = try await dependencies.check(mode) + switch result { + case .throttled: + state = + if case .checking = previousState { + .idle(currentVersion: currentVersion) + } else { + previousState + } + case .upToDate(let latestVersion): + state = .upToDate( + currentVersion: currentVersion, + latestVersion: latestVersion + ) + case .updateAvailable(let update): + guard + Self.supports( + minimumMacOSVersion: update.minimumMacOSVersion, + current: dependencies.operatingSystemVersion() + ) + else { + state = .unavailable( + currentVersion: currentVersion, + reason: + "Gloss \(update.version) 需要 macOS \(update.minimumMacOSVersion) 或更高版本" + ) + return + } + + let installation = + try await dependencies.detectHomebrewInstallation() + state = .updateAvailable( + update, + delivery: installation.map(AppUpdateDelivery.homebrew) + ?? .releasePage + ) + } + } catch is CancellationError { + state = previousState + } catch { + state = .failed( + currentVersion: currentVersion, + message: error.localizedDescription + ) + } + } + + func performPrimaryAction() async { + guard let action = state.action else { return } + switch action { + case .check: + await check(mode: .manual) + case .openReleasePage: + guard case .updateAvailable(let update, .releasePage) = state else { + return + } + if !dependencies.openReleasePage(update.releasePageURL) { + state = .failed( + currentVersion: currentVersion, + message: "无法打开官方发布页面。" + ) + } + case .install: + await installAvailableUpdate() + } + } + + static func supports( + minimumMacOSVersion: String, + current: OperatingSystemVersion + ) -> Bool { + let parts = minimumMacOSVersion.split(separator: ".") + guard (2...3).contains(parts.count), + let major = Int(parts[0]), + let minor = Int(parts[1]) + else { + return false + } + let patch: Int + if parts.count == 3 { + guard let parsedPatch = Int(parts[2]) else { return false } + patch = parsedPatch + } else { + patch = 0 + } + let required = OperatingSystemVersion( + majorVersion: major, + minorVersion: minor, + patchVersion: patch + ) + let currentParts = [ + current.majorVersion, + current.minorVersion, + current.patchVersion, + ] + let requiredParts = [ + required.majorVersion, + required.minorVersion, + required.patchVersion, + ] + return !currentParts.lexicographicallyPrecedes(requiredParts) + } + + private func installAvailableUpdate() async { + guard !operationInProgress else { return } + + let update: GlossAppUpdateAvailability + let installation: GlossHomebrewInstallation + switch state { + case .updateAvailable(let available, .homebrew(let managedInstallation)): + update = available + installation = managedInstallation + case .blockedByBusinessTask( + let available, + let managedInstallation + ): + update = available + installation = managedInstallation + default: + return + } + + guard !(await dependencies.isBusinessTaskActive()) else { + state = .blockedByBusinessTask( + update, + installation: installation + ) + return + } + + operationInProgress = true + state = .preparingInstall(version: update.version) + defer { operationInProgress = false } + + do { + try await dependencies.launchHomebrewUpdate( + installation, + update + ) + dependencies.requestApplicationTermination() + } catch { + state = .failed( + currentVersion: currentVersion, + message: error.localizedDescription + ) + } + } +} diff --git a/Sources/Gloss/AppUpdateDashboardState.swift b/Sources/Gloss/AppUpdateDashboardState.swift new file mode 100644 index 0000000..d58fa78 --- /dev/null +++ b/Sources/Gloss/AppUpdateDashboardState.swift @@ -0,0 +1,232 @@ +import Foundation +import GlossCore + +enum AppUpdateDelivery: Equatable { + case homebrew(GlossHomebrewInstallation) + case releasePage +} + +enum AppUpdateDashboardAction: Equatable { + case check + case install + case openReleasePage +} + +enum AppUpdateDashboardState: Equatable { + case unavailable(currentVersion: String, reason: String) + case idle(currentVersion: String) + case checking(currentVersion: String) + case upToDate(currentVersion: String, latestVersion: String) + case updateAvailable( + GlossAppUpdateAvailability, + delivery: AppUpdateDelivery + ) + case blockedByBusinessTask( + GlossAppUpdateAvailability, + installation: GlossHomebrewInstallation + ) + case preparingInstall(version: String) + case failed(currentVersion: String, message: String) + + static func fromHomebrewResult( + _ result: GlossHomebrewUpgradeResult, + currentVersion: String + ) -> Self { + switch result.outcome { + case .succeeded: + return .upToDate( + currentVersion: currentVersion, + latestVersion: + result.installedVersion ?? result.expectedVersion + ) + case .failed: + let baseMessage = + result.message + ?? "Homebrew 更新未完成(\(result.errorCode ?? "未知错误"))" + let recoveryDetail = + if result.recoveredPreviousInstallation { + " 已恢复上一版本。" + } else if let recoveryError = result.recoveryError { + " 恢复失败:\(recoveryError)" + } else { + "" + } + return .failed( + currentVersion: currentVersion, + message: baseMessage + recoveryDetail + ) + } + } + + var action: AppUpdateDashboardAction? { + switch self { + case .unavailable, .checking, .preparingInstall: + nil + case .idle, .upToDate, .failed: + .check + case .updateAvailable(_, let delivery): + switch delivery { + case .homebrew: + .install + case .releasePage: + .openReleasePage + } + case .blockedByBusinessTask: + .install + } + } + + var presentation: AppUpdateDashboardPresentation { + switch self { + case .unavailable(let currentVersion, let reason): + AppUpdateDashboardPresentation( + headline: "应用更新不可用", + detail: "Gloss \(currentVersion) · \(reason)", + tone: .neutral, + actionTitle: "不可用", + actionEnabled: false, + showsProgress: false + ) + case .idle(let currentVersion): + AppUpdateDashboardPresentation( + headline: "自动检查应用更新", + detail: "Gloss \(currentVersion) · 每 24 小时后台检查一次", + tone: .neutral, + actionTitle: "检查更新", + actionEnabled: true, + showsProgress: false + ) + case .checking(let currentVersion): + AppUpdateDashboardPresentation( + headline: "正在检查应用更新…", + detail: "当前版本 Gloss \(currentVersion)", + tone: .neutral, + actionTitle: "正在检查…", + actionEnabled: false, + showsProgress: true + ) + case .upToDate(let currentVersion, let latestVersion): + AppUpdateDashboardPresentation( + headline: "Gloss 已是最新版本", + detail: "当前 \(currentVersion) · 最新 \(latestVersion)", + tone: .positive, + actionTitle: "再次检查", + actionEnabled: true, + showsProgress: false + ) + case .updateAvailable(let update, let delivery): + switch delivery { + case .homebrew: + AppUpdateDashboardPresentation( + headline: "Gloss \(update.version) 可用", + detail: "由 Homebrew 安全升级,完成后自动重新启动", + tone: .warning, + actionTitle: "更新并重新启动", + actionEnabled: true, + showsProgress: false + ) + case .releasePage: + AppUpdateDashboardPresentation( + headline: "Gloss \(update.version) 可用", + detail: "当前 App 不是由 sunchj/tap/gloss 管理", + tone: .warning, + actionTitle: "查看下载", + actionEnabled: true, + showsProgress: false + ) + } + case .blockedByBusinessTask(let update, _): + AppUpdateDashboardPresentation( + headline: "等待当前翻译任务完成", + detail: "完成当前任务后即可安装 Gloss \(update.version)", + tone: .warning, + actionTitle: "重试更新", + actionEnabled: true, + showsProgress: false + ) + case .preparingInstall(let version): + AppUpdateDashboardPresentation( + headline: "正在准备更新到 Gloss \(version)…", + detail: "Gloss 即将退出;Homebrew 完成升级后会自动重新启动", + tone: .neutral, + actionTitle: "正在准备…", + actionEnabled: false, + showsProgress: true + ) + case .failed(let currentVersion, let message): + AppUpdateDashboardPresentation( + headline: "应用更新失败", + detail: "Gloss \(currentVersion) · \(message)", + tone: .negative, + actionTitle: "重试", + actionEnabled: true, + showsProgress: false + ) + } + } + + var menuPresentation: AppUpdateMenuPresentation { + switch self { + case .unavailable: + return AppUpdateMenuPresentation( + title: "检查更新不可用", + isEnabled: false + ) + case .idle, .upToDate: + return AppUpdateMenuPresentation( + title: "检查更新…", + isEnabled: true + ) + case .checking: + return AppUpdateMenuPresentation( + title: "正在检查更新…", + isEnabled: false + ) + case .updateAvailable(let update, let delivery): + let title = + switch delivery { + case .homebrew: + "更新 Gloss 到 \(update.version)…" + case .releasePage: + "下载 Gloss \(update.version)…" + } + return AppUpdateMenuPresentation(title: title, isEnabled: true) + case .blockedByBusinessTask(let update, _): + return AppUpdateMenuPresentation( + title: "完成当前任务后更新到 \(update.version)…", + isEnabled: true + ) + case .preparingInstall: + return AppUpdateMenuPresentation( + title: "正在准备更新…", + isEnabled: false + ) + case .failed: + return AppUpdateMenuPresentation( + title: "重试检查更新…", + isEnabled: true + ) + } + } +} + +struct AppUpdateDashboardPresentation: Equatable { + enum Tone: Equatable { + case neutral + case positive + case warning + case negative + } + + let headline: String + let detail: String + let tone: Tone + let actionTitle: String + let actionEnabled: Bool + let showsProgress: Bool +} + +struct AppUpdateMenuPresentation: Equatable { + let title: String + let isEnabled: Bool +} diff --git a/Sources/Gloss/GlossAppDelegate.swift b/Sources/Gloss/GlossAppDelegate.swift index 18a1aa6..8322e47 100644 --- a/Sources/Gloss/GlossAppDelegate.swift +++ b/Sources/Gloss/GlossAppDelegate.swift @@ -11,8 +11,13 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { static let automaticSelection = 10_001 static let currentApplication = 10_002 static let launchAtLogin = 10_003 + static let appUpdate = 10_004 } + private let capabilityRegistry = GlossCapabilityRegistry.current + private lazy var scenarioActivation = GlossAppScenarioActivation( + registry: capabilityRegistry + ) private let languages = TranslationLanguages.common private let targetLanguageKey = "targetLanguage" private let reverseLanguageKey = "reverseLanguage" @@ -26,6 +31,7 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private let codexReasoningEffortKey = "codexReasoningEffort" private let glossaryRevisionKey = "glossaryRevision" private let welcomeVersionKey = "welcomeVersion" + private let appUpdateLastCheckKey = "appUpdateLastCheck" private let glossaryStore = GlossaryStore() private let runtimeLog = GlossRuntimeLog.shared private let dispatchState = TranslationDispatchState() @@ -54,6 +60,9 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var pdfRuntimeActionTask: Task? private var pdfRuntimeActionID: UUID? private lazy var pdfRuntimeController = PDFRuntimeController() + private var appUpdateActionTask: Task? + private lazy var appUpdateController: AppUpdateController? = + makeAppUpdateController() private var selectionMonitor: SelectionMonitor? private var currentSelection: SelectionSnapshot? private var activeTranslationID: UUID? @@ -88,6 +97,176 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { return trimmed.isEmpty ? "dev" : trimmed } + private var currentAppUpdateState: AppUpdateDashboardState { + appUpdateController?.state + ?? .unavailable( + currentVersion: applicationVersion, + reason: "仅正式版本支持自动更新" + ) + } + + private var appUpdaterRootURL: URL? { + FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + ).first? + .appendingPathComponent("Gloss", isDirectory: true) + .appendingPathComponent("AppUpdater", isDirectory: true) + } + + private func makeAppUpdateController() -> AppUpdateController? { + guard capabilityRegistry.supports(.appUpdates), + GlossSemanticVersion(applicationVersion) != nil, + let updaterRootURL = appUpdaterRootURL + else { + return nil + } + + do { + let historyKey = appUpdateLastCheckKey + let history = GlossAppUpdateCheckHistory( + lastCheck: { + UserDefaults.standard.object(forKey: historyKey) as? Date + }, + recordCheck: { date in + UserDefaults.standard.set(date, forKey: historyKey) + } + ) + let discovery = try GlossAppUpdateDiscovery( + currentVersion: applicationVersion, + fetcher: .live, + history: history + ) + let detector = GlossHomebrewInstallationDetector( + commandRunner: .live + ) + let currentBundleURL = Bundle.main.bundleURL + let bundledHelperURL = + currentBundleURL + .appendingPathComponent("Contents", isDirectory: true) + .appendingPathComponent("Helpers", isDirectory: true) + .appendingPathComponent( + GlossAppUpdateHelperLauncher.helperName + ) + let stagingRootURL = updaterRootURL.appendingPathComponent( + "staging", + isDirectory: true + ) + let resultURL = updaterRootURL.appendingPathComponent( + "latest-result.json" + ) + let launcher = GlossAppUpdateHelperLauncher() + let initialState = consumeAppUpdateResult(at: resultURL) + if initialState != nil { + do { + try AppUpdateStagingCleaner.removeStagedHelpers( + at: stagingRootURL + ) + } catch { + runtimeLog.write( + "app-update", + "staging_cleanup_failed error=\(error.localizedDescription)" + ) + } + } + + let controller = AppUpdateController( + currentVersion: applicationVersion, + dependencies: AppUpdateController.Dependencies( + check: { mode in + try await discovery.check(mode: mode) + }, + detectHomebrewInstallation: { + try await detector.detect( + currentBundleURL: currentBundleURL + ) + }, + launchHomebrewUpdate: { installation, update in + _ = try await launcher.launch( + bundledHelperURL: bundledHelperURL, + installation: installation, + update: update, + parentProcessIdentifier: + Int32(ProcessInfo.processInfo.processIdentifier), + currentBundleURL: currentBundleURL, + cacheRootURL: stagingRootURL, + resultURL: resultURL + ) + }, + openReleasePage: { url in + NSWorkspace.shared.open(url) + }, + isBusinessTaskActive: { [weak self] in + guard let self else { return false } + if pdfTranslationWindowController? + .hasActiveTranslation == true + { + return true + } + if case .translating = + pdfRuntimeController.dashboardState + { + return true + } + let dispatch = await dispatchState.snapshot() + return dispatch.pendingInteractiveJobs > 0 + || dispatch.pendingVisibleJobs > 0 + || dispatch.pendingBackgroundJobs > 0 + || dispatch.activeInteractiveJobs > 0 + || dispatch.activeVisibleJobs > 0 + || dispatch.activeBackgroundJobs > 0 + || dispatch.upstreamBackgroundItems > 0 + }, + requestApplicationTermination: { + NSApp.terminate(nil) + }, + operatingSystemVersion: { + ProcessInfo.processInfo.operatingSystemVersion + } + ), + initialState: initialState + ) + controller.onStateChange = { [weak self] state in + self?.showAppUpdateState(state) + } + return controller + } catch { + runtimeLog.write( + "app-update", + "initialization_failed error=\(error.localizedDescription)" + ) + return nil + } + } + + private func consumeAppUpdateResult( + at resultURL: URL + ) -> AppUpdateDashboardState? { + guard FileManager.default.fileExists(atPath: resultURL.path) else { + return nil + } + do { + let result = try GlossHomebrewUpgradeResultStore.load( + from: resultURL + ) + try FileManager.default.removeItem(at: resultURL) + return .fromHomebrewResult( + result, + currentVersion: applicationVersion + ) + } catch { + runtimeLog.write( + "app-update", + "result_read_failed error=\(error.localizedDescription)" + ) + try? FileManager.default.removeItem(at: resultURL) + return .failed( + currentVersion: applicationVersion, + message: "无法读取上一次更新结果。" + ) + } + } + private var glossBar: GlossBarController { if let glossBarController { return glossBarController } let controller = GlossBarController() @@ -118,7 +297,7 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var settingsWindow: SettingsWindowController { if let settingsWindowController { return settingsWindowController } - let controller = SettingsWindowController() + let controller = SettingsWindowController(capabilityRegistry: capabilityRegistry) controller.onRequestAccessibility = { [weak self] in self?.requestAccessibility() } @@ -155,6 +334,9 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { controller.onPDFRuntimeAction = { [weak self] action in self?.performPDFRuntimeAction(action) } + controller.onAppUpdateAction = { [weak self] in + self?.performAppUpdateAction() + } controller.onRevealLogs = { [weak self] in self?.revealLogs() } @@ -167,12 +349,21 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { controller.onSetGlobalShortcut = { [weak self] shortcut in self?.setGlobalShortcut(shortcut) } - controller.showBridgeState(bridgeDashboardState) - controller.showPDFRuntimeState(pdfRuntimeController.dashboardState) - controller.showBrowserExtensionStatus( - browserExtensionStatus.message, - succeeded: browserExtensionStatus.succeeded - ) + if scenarioActivation.startsTranslationBridge { + controller.showBridgeState(bridgeDashboardState) + } + if scenarioActivation.preparesPDFRuntime { + controller.showPDFRuntimeState(pdfRuntimeController.dashboardState) + } + if capabilityRegistry.supports(.appUpdates) { + controller.showAppUpdateState(currentAppUpdateState) + } + if capabilityRegistry.isEnabled(.browserTranslation) { + controller.showBrowserExtensionStatus( + browserExtensionStatus.message, + succeeded: browserExtensionStatus.succeeded + ) + } controller.showProviderConfiguration(providerConfiguration) if let succeeded = engineStatus.succeeded { controller.showProviderResult(engineStatus.message, succeeded: succeeded) @@ -340,19 +531,36 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { func applicationDidFinishLaunching(_ notification: Notification) { try? runtimeLog.prepare() runtimeLog.write("app", "started version=\(applicationVersion)") - startObservingPDFRuntime() - pdfRuntimeController.prepareAtLaunch() + if scenarioActivation.preparesPDFRuntime { + startObservingPDFRuntime() + pdfRuntimeController.prepareAtLaunch() + } NSApp.setActivationPolicy(.accessory) DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { [weak self] in self?.configureStatusItem() } - configureServices() - configureSelectionMonitor() - configureWorkspaceObservation() - startBrowserBridge() - prewarmProvider() + if scenarioActivation.configuresSystemServices { + configureServices() + } + if capabilityRegistry.isEnabled(.selectionTranslation) { + configureSelectionMonitor() + } + if scenarioActivation.observesWorkspaceApplications { + configureWorkspaceObservation() + } + if scenarioActivation.startsTranslationBridge { + startBrowserBridge() + } + if scenarioActivation.prewarmsTranslationProvider { + prewarmProvider() + } + if capabilityRegistry.supports(.appUpdates) { + appUpdateController?.startAutomaticCheck() + } - if !reconcileAccessibility() { + if capabilityRegistry.isEnabled(.selectionTranslation), + !reconcileAccessibility() + { startAccessibilityPolling() } @@ -365,38 +573,51 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { return URL(fileURLWithPath: rawArguments[index + 1]) } if !pdfURLs.isEmpty { - let requestedPage = - rawArguments.firstIndex(of: "--pdf-page") - .flatMap { index in - rawArguments.indices.contains(index + 1) - ? Int(rawArguments[index + 1]) - : nil - } - .map { max(0, $0 - 1) } ?? 0 - DispatchQueue.main.async { [weak self] in - for (index, url) in pdfURLs.enumerated() { - do { - try self?.pdfTranslationWindow.open( - url, - pageIndex: index == 0 ? requestedPage : 0 - ) - } catch { - self?.showAlert( - title: "无法打开 \(url.lastPathComponent)", - message: error.localizedDescription - ) + if scenarioActivation.acceptsPDFOpenRequests { + let requestedPage = + rawArguments.firstIndex(of: "--pdf-page") + .flatMap { index in + rawArguments.indices.contains(index + 1) + ? Int(rawArguments[index + 1]) + : nil + } + .map { max(0, $0 - 1) } ?? 0 + DispatchQueue.main.async { [weak self] in + for (index, url) in pdfURLs.enumerated() { + do { + try self?.pdfTranslationWindow.open( + url, + pageIndex: index == 0 ? requestedPage : 0 + ) + } catch { + self?.showAlert( + title: "无法打开 \(url.lastPathComponent)", + message: error.localizedDescription + ) + } } } + } else { + runtimeLog.write( + "app", + "ignored_pdf_open count=\(pdfURLs.count) reason=scenario_disabled" + ) } - } else if arguments.contains("--show-history") { + } else if capabilityRegistry.isEnabled(.translationHistory), + arguments.contains("--show-history") + { DispatchQueue.main.async { [weak self] in self?.historyWindow.show() } - } else if arguments.contains("--show-glossary") { + } else if capabilityRegistry.isEnabled(.glossaryManagement), + arguments.contains("--show-glossary") + { DispatchQueue.main.async { [weak self] in self?.glossaryWindow.show() } - } else if arguments.contains("--show-exclusions") { + } else if capabilityRegistry.isEnabled(.selectionTranslation), + arguments.contains("--show-exclusions") + { DispatchQueue.main.async { [weak self] in self?.showAppExclusions() } @@ -442,6 +663,8 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { activeOCRTask?.cancel() pdfRuntimeObservationTask?.cancel() pdfRuntimeActionTask?.cancel() + appUpdateActionTask?.cancel() + appUpdateController?.cancel() stopAccessibilityPolling() selectionMonitor?.stop() NSWorkspace.shared.notificationCenter.removeObserver(self) @@ -449,6 +672,14 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } func application(_ sender: NSApplication, openFiles filenames: [String]) { + guard scenarioActivation.acceptsPDFOpenRequests else { + runtimeLog.write( + "app", + "ignored_pdf_open count=\(filenames.count) reason=scenario_disabled" + ) + sender.reply(toOpenOrPrint: .failure) + return + } let pdfURLs = filenames .map(URL.init(fileURLWithPath:)) @@ -484,14 +715,35 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { pendingPDFRuntimeAction?.cancel() pdfRuntimeActionTask = nil pdfRuntimeActionID = nil - let pdfWindow = pdfTranslationWindowController - let pdfRuntime = pdfRuntimeController - Task { [codex, llama, pendingPDFRuntimeAction, pdfWindow, pdfRuntime] in + let pdfWindow = + scenarioActivation.preparesPDFRuntime + ? pdfTranslationWindowController + : nil + let pdfRuntime = + scenarioActivation.preparesPDFRuntime + ? pdfRuntimeController + : nil + let codexBackend = + scenarioActivation.prewarmsTranslationProvider + ? codex + : nil + let llamaBackend = + scenarioActivation.prewarmsTranslationProvider + ? llama + : nil + Task { + [ + codexBackend, + llamaBackend, + pendingPDFRuntimeAction, + pdfWindow, + pdfRuntime, + ] in await pendingPDFRuntimeAction?.value await pdfWindow?.stopAndWait() - await pdfRuntime.shutdown() - await codex.stop() - await llama.stop() + await pdfRuntime?.shutdown() + await codexBackend?.stop() + await llamaBackend?.stop() NSApp.reply(toApplicationShouldTerminate: true) } return .terminateLater @@ -501,7 +753,7 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { if let button = statusItem.button { button.image = GlossBrand.markImage(pointSize: 18) button.image?.accessibilityDescription = "Gloss" - button.toolTip = "Gloss · 选中,即懂" + button.toolTip = "Gloss · 浏览器与 PDF 翻译" } DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { [weak self] in guard let self else { return } @@ -654,76 +906,94 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { title.isEnabled = false menu.addItem(title) - let clipboard = NSMenuItem( - title: "翻译剪贴板文本", - action: #selector(translateClipboard), - keyEquivalent: "t" - ) - clipboard.keyEquivalentModifierMask = [.command, .option] - clipboard.target = self - menu.addItem(clipboard) - - let clipboardImage = NSMenuItem( - title: "翻译剪贴板图片", - action: #selector(translateClipboardImage), - keyEquivalent: "i" - ) - clipboardImage.keyEquivalentModifierMask = [.command, .option] - clipboardImage.target = self - menu.addItem(clipboardImage) + if capabilityRegistry.isEnabled(.browserTranslation) { + let browser = NSMenuItem( + title: "浏览器翻译与扩展…", + action: #selector(showSettings), + keyEquivalent: "" + ) + browser.target = self + menu.addItem(browser) + } - let screenshot = NSMenuItem( - title: "截图翻译…", - action: #selector(translateScreenshot), - keyEquivalent: "" - ) - screenshot.target = self - menu.addItem(screenshot) + if capabilityRegistry.isEnabled(.pdfTranslation) { + let pdf = NSMenuItem( + title: "打开 PDF 翻译…", + action: #selector(openPDFTranslation), + keyEquivalent: "o" + ) + pdf.keyEquivalentModifierMask = [.command, .option] + pdf.target = self + menu.addItem(pdf) + } - let pdf = NSMenuItem( - title: "打开 PDF 翻译…", - action: #selector(openPDFTranslation), - keyEquivalent: "o" - ) - pdf.keyEquivalentModifierMask = [.command, .option] - pdf.target = self - menu.addItem(pdf) + if capabilityRegistry.isEnabled(.clipboardTranslation) { + let clipboard = NSMenuItem( + title: "翻译剪贴板文本", + action: #selector(translateClipboard), + keyEquivalent: "t" + ) + clipboard.keyEquivalentModifierMask = [.command, .option] + clipboard.target = self + menu.addItem(clipboard) + } - let selectedText = NSMenuItem( - title: "翻译当前选区(\(globalShortcut.displayName))", - action: #selector(translateCurrentSelectionFromMenu), - keyEquivalent: "" - ) - selectedText.target = self - selectedText.isEnabled = SelectionMonitor.isAccessibilityTrusted - menu.addItem(selectedText) + if capabilityRegistry.isEnabled(.imageTranslation) { + let clipboardImage = NSMenuItem( + title: "翻译剪贴板图片", + action: #selector(translateClipboardImage), + keyEquivalent: "i" + ) + clipboardImage.keyEquivalentModifierMask = [.command, .option] + clipboardImage.target = self + menu.addItem(clipboardImage) - let automaticSelection = NSMenuItem( - title: "选中文字后自动显示", - action: #selector(toggleAutomaticSelection(_:)), - keyEquivalent: "" - ) - automaticSelection.tag = MenuTag.automaticSelection - automaticSelection.target = self - menu.addItem(automaticSelection) + let screenshot = NSMenuItem( + title: "截图翻译…", + action: #selector(translateScreenshot), + keyEquivalent: "" + ) + screenshot.target = self + menu.addItem(screenshot) + } - let currentApplication = NSMenuItem( - title: "在当前 App 中自动显示", - action: #selector(toggleCurrentApplication(_:)), - keyEquivalent: "" - ) - currentApplication.tag = MenuTag.currentApplication - currentApplication.target = self - menu.addItem(currentApplication) - let exclusionCount = excludedApplicationBundleIdentifiers.count - let manageApplications = NSMenuItem( - title: exclusionCount == 0 ? "管理 App 例外…" : "管理 App 例外…(\(exclusionCount))", - action: #selector(showAppExclusions), - keyEquivalent: "" - ) - manageApplications.target = self - menu.addItem(manageApplications) - updateSelectionMenuItems(in: menu) + if capabilityRegistry.isEnabled(.selectionTranslation) { + let selectedText = NSMenuItem( + title: "翻译当前选区(\(globalShortcut.displayName))", + action: #selector(translateCurrentSelectionFromMenu), + keyEquivalent: "" + ) + selectedText.target = self + selectedText.isEnabled = SelectionMonitor.isAccessibilityTrusted + menu.addItem(selectedText) + + let automaticSelection = NSMenuItem( + title: "选中文字后自动显示", + action: #selector(toggleAutomaticSelection(_:)), + keyEquivalent: "" + ) + automaticSelection.tag = MenuTag.automaticSelection + automaticSelection.target = self + menu.addItem(automaticSelection) + + let currentApplication = NSMenuItem( + title: "在当前 App 中自动显示", + action: #selector(toggleCurrentApplication(_:)), + keyEquivalent: "" + ) + currentApplication.tag = MenuTag.currentApplication + currentApplication.target = self + menu.addItem(currentApplication) + let exclusionCount = excludedApplicationBundleIdentifiers.count + let manageApplications = NSMenuItem( + title: exclusionCount == 0 ? "管理 App 例外…" : "管理 App 例外…(\(exclusionCount))", + action: #selector(showAppExclusions), + keyEquivalent: "" + ) + manageApplications.target = self + menu.addItem(manageApplications) + updateSelectionMenuItems(in: menu) + } menu.addItem(.separator()) let languageItem = NSMenuItem(title: "目标语言", action: nil, keyEquivalent: "") @@ -812,44 +1082,50 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { profileItem.submenu = profileMenu menu.addItem(profileItem) - let glossary = NSMenuItem( - title: "术语表…", - action: #selector(showGlossary), - keyEquivalent: "" - ) - glossary.target = self - menu.addItem(glossary) - - let history = NSMenuItem( - title: "翻译历史…", - action: #selector(showHistory), - keyEquivalent: "" - ) - history.target = self - menu.addItem(history) + if capabilityRegistry.isEnabled(.glossaryManagement) { + let glossary = NSMenuItem( + title: "术语表…", + action: #selector(showGlossary), + keyEquivalent: "" + ) + glossary.target = self + menu.addItem(glossary) + } - let saveHistory = NSMenuItem( - title: "保存本地翻译历史", - action: #selector(toggleHistory(_:)), - keyEquivalent: "" - ) - saveHistory.target = self - saveHistory.state = historyEnabled ? .on : .off - menu.addItem(saveHistory) - menu.addItem(.separator()) + if capabilityRegistry.isEnabled(.translationHistory) { + let history = NSMenuItem( + title: "翻译历史…", + action: #selector(showHistory), + keyEquivalent: "" + ) + history.target = self + menu.addItem(history) - if SelectionMonitor.isAccessibilityTrusted { - let access = NSMenuItem(title: "选区访问已启用", action: nil, keyEquivalent: "") - access.isEnabled = false - menu.addItem(access) - } else { - let access = NSMenuItem( - title: "启用选区翻译…", - action: #selector(requestAccessibility), + let saveHistory = NSMenuItem( + title: "保存本地翻译历史", + action: #selector(toggleHistory(_:)), keyEquivalent: "" ) - access.target = self - menu.addItem(access) + saveHistory.target = self + saveHistory.state = historyEnabled ? .on : .off + menu.addItem(saveHistory) + } + menu.addItem(.separator()) + + if capabilityRegistry.isEnabled(.selectionTranslation) { + if SelectionMonitor.isAccessibilityTrusted { + let access = NSMenuItem(title: "选区访问已启用", action: nil, keyEquivalent: "") + access.isEnabled = false + menu.addItem(access) + } else { + let access = NSMenuItem( + title: "启用选区翻译…", + action: #selector(requestAccessibility), + keyEquivalent: "" + ) + access.target = self + menu.addItem(access) + } } let loginItem = NSMenuItem( @@ -895,6 +1171,18 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { settings.target = self menu.addItem(settings) + if capabilityRegistry.supports(.appUpdates) { + let update = NSMenuItem( + title: "", + action: #selector(performAppUpdateAction), + keyEquivalent: "" + ) + update.tag = MenuTag.appUpdate + update.target = self + updateAppUpdateMenuItem(update) + menu.addItem(update) + } + let logs = NSMenuItem( title: "查看运行日志…", action: #selector(revealLogs), @@ -921,6 +1209,9 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { if let item = menu.item(withTag: MenuTag.launchAtLogin) { updateLaunchAtLoginMenuItem(item) } + if let item = menu.item(withTag: MenuTag.appUpdate) { + updateAppUpdateMenuItem(item) + } updateLaunchAtLoginStatus() } @@ -1555,15 +1846,40 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { @objc private func showAbout() { showAlert( title: "Gloss", - message: "选中,即懂。\n\n系统级、上下文感知的 macOS 翻译工具。" + message: "浏览器与 PDF 翻译\n\n支持 Safari、Chrome 页面翻译与批量 PDF 翻译。" ) } @objc private func showSettings() { updateLaunchAtLoginStatus() + settingsWindowController?.showAppUpdateState(currentAppUpdateState) settingsWindow.show() } + @objc private func performAppUpdateAction() { + guard appUpdateActionTask == nil, + let controller = appUpdateController + else { return } + appUpdateActionTask = Task { @MainActor [weak self, controller] in + await controller.performPrimaryAction() + self?.appUpdateActionTask = nil + } + } + + private func updateAppUpdateMenuItem(_ item: NSMenuItem) { + let presentation = currentAppUpdateState.menuPresentation + item.title = presentation.title + item.isEnabled = presentation.isEnabled + } + + private func showAppUpdateState(_ state: AppUpdateDashboardState) { + settingsWindowController?.showAppUpdateState(state) + if let item = statusItem.menu?.item(withTag: MenuTag.appUpdate) { + updateAppUpdateMenuItem(item) + } + runtimeLog.write("app-update", "state=\(state)") + } + @objc private func revealLogs() { do { try runtimeLog.prepare() @@ -1648,7 +1964,9 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { let token = try PairingTokenStore.loadOrCreate() pairingToken = token - prepareBrowserExtension(pairingToken: token) + if scenarioActivation.preparesBrowserExtensions { + prepareBrowserExtension(pairingToken: token) + } try Task.checkCancellation() occupant = try await bridgePortManager.inspect(token: token) diff --git a/Sources/Gloss/GlossAppScenarioActivation.swift b/Sources/Gloss/GlossAppScenarioActivation.swift new file mode 100644 index 0000000..09812bb --- /dev/null +++ b/Sources/Gloss/GlossAppScenarioActivation.swift @@ -0,0 +1,27 @@ +import GlossCore + +/// Maps enabled business scenarios to App lifecycle work and external entry points. +/// +/// Keep this policy separate from `GlossAppDelegate` so a scenario being hidden in +/// the UI also prevents its background services and file handlers from starting. +struct GlossAppScenarioActivation: Equatable { + let configuresSystemServices: Bool + let observesWorkspaceApplications: Bool + let startsTranslationBridge: Bool + let preparesBrowserExtensions: Bool + let preparesPDFRuntime: Bool + let prewarmsTranslationProvider: Bool + let acceptsPDFOpenRequests: Bool + + init(registry: GlossCapabilityRegistry) { + configuresSystemServices = + registry.supports(.clipboardText) + || registry.supports(.clipboardImage) + observesWorkspaceApplications = registry.supports(.selectionCapture) + startsTranslationBridge = registry.supports(.translationLoopbackBridge) + preparesBrowserExtensions = registry.isEnabled(.browserTranslation) + preparesPDFRuntime = registry.supports(.pdfRuntime) + prewarmsTranslationProvider = registry.supports(.textTranslation) + acceptsPDFOpenRequests = registry.isEnabled(.pdfTranslation) + } +} diff --git a/Sources/Gloss/PDFTranslationWindowController.swift b/Sources/Gloss/PDFTranslationWindowController.swift index 0ad12c1..c34f716 100644 --- a/Sources/Gloss/PDFTranslationWindowController.swift +++ b/Sources/Gloss/PDFTranslationWindowController.swift @@ -570,6 +570,10 @@ final class PDFTranslationWindowController: NSObject, NSWindowDelegate { refreshInterface() } + var hasActiveTranslation: Bool { + batchCoordinator.isActive + } + func stop() { serviceStartupGeneration &+= 1 serviceStartupTask?.cancel() diff --git a/Sources/Gloss/SettingsWindowController.swift b/Sources/Gloss/SettingsWindowController.swift index 92ee3ec..7e0be01 100644 --- a/Sources/Gloss/SettingsWindowController.swift +++ b/Sources/Gloss/SettingsWindowController.swift @@ -15,11 +15,13 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel var onOpenSafariExtensionSettings: (() -> Void)? var onBridgeAction: (() -> Void)? var onPDFRuntimeAction: ((PDFRuntimeDashboardAction) -> Void)? + var onAppUpdateAction: (() -> Void)? var onRevealLogs: (() -> Void)? var onOpenServicesSettings: (() -> Void)? var onSetLaunchAtLogin: ((Bool) -> Bool)? var onSetGlobalShortcut: ((GlobalShortcut) -> Void)? + private let capabilityRegistry: GlossCapabilityRegistry private let window: NSWindow private let accessibilityStatus = NSTextField(labelWithString: "") private let providerStatus = NSTextField(labelWithString: "正在启动翻译引擎") @@ -31,6 +33,10 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel labelWithString: "正在验证已安装版本与残留进程" ) private let pdfRuntimePath = NSTextField(labelWithString: "") + private let appUpdateStatus = NSTextField(labelWithString: "自动检查应用更新") + private let appUpdateDetail = NSTextField( + labelWithString: "每 24 小时后台检查一次" + ) private let browserExtensionStatus = NSTextField(labelWithString: "正在准备浏览器扩展") private let shortcutStatus = NSTextField(labelWithString: "手动翻译当前选区") private let launchAtLoginStatus = NSTextField(labelWithString: "关闭") @@ -56,10 +62,27 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel ) private let pdfRuntimeProgressIndicator = NSProgressIndicator() private var pdfRuntimeState = PDFRuntimeDashboardState.checking + private let appUpdateActionButton = NSButton( + title: "检查更新", + target: nil, + action: nil + ) + private let appUpdateProgressIndicator = NSProgressIndicator() + private var appUpdateState = AppUpdateDashboardState.unavailable( + currentVersion: "dev", + reason: "开发构建" + ) - override init() { + init(capabilityRegistry: GlossCapabilityRegistry = .current) { + self.capabilityRegistry = capabilityRegistry + let windowHeight: CGFloat = + capabilityRegistry.isEnabled(.selectionTranslation) + || capabilityRegistry.isEnabled(.clipboardTranslation) + || capabilityRegistry.isEnabled(.imageTranslation) + ? 840 + : capabilityRegistry.supports(.appUpdates) ? 720 : 640 window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 560, height: 840), + contentRect: NSRect(x: 0, y: 0, width: 560, height: windowHeight), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: true @@ -69,7 +92,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel } func show() { - updateAccessibilityStatus() + if capabilityRegistry.isEnabled(.selectionTranslation) { + updateAccessibilityStatus() + } window.center() NSApp.activate(ignoringOtherApps: true) window.makeKeyAndOrderFront(nil) @@ -130,7 +155,10 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel launchAtLoginStatus.stringValue = "等待在系统设置中批准" launchAtLoginStatus.textColor = .systemOrange } else { - launchAtLoginStatus.stringValue = "关闭;可让选区翻译随时可用" + launchAtLoginStatus.stringValue = + capabilityRegistry.isEnabled(.selectionTranslation) + ? "关闭;可让选区翻译随时可用" + : "关闭;Gloss 仅在手动启动后可用" launchAtLoginStatus.textColor = .secondaryLabelColor } } @@ -143,7 +171,13 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel window.title = "Gloss" window.isReleasedWhenClosed = false window.delegate = self - window.minSize = NSSize(width: 520, height: 810) + window.minSize = NSSize( + width: 520, + height: + capabilityRegistry.isEnabled(.selectionTranslation) + ? 810 + : capabilityRegistry.supports(.appUpdates) ? 690 : 610 + ) let root = NSView() let scrollView = NSScrollView() @@ -181,7 +215,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel let title = NSTextField(labelWithString: "Gloss") title.font = .systemFont(ofSize: 28, weight: .bold) - let subtitle = NSTextField(labelWithString: "选中,即懂。") + let subtitle = NSTextField( + labelWithString: productSubtitle + ) subtitle.font = .systemFont(ofSize: 15) subtitle.textColor = .secondaryLabelColor let heading = NSStackView(views: [title, subtitle]) @@ -196,151 +232,161 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel header.spacing = 14 header.translatesAutoresizingMaskIntoConstraints = false - let accessibilityCard = makeStatusCard( - symbol: "selection.pin.in.out", - title: "系统选区", - status: accessibilityStatus, - accessory: accessibilityButton - ) - accessibilityButton.target = self - accessibilityButton.action = #selector(requestAccessibility) - - let shortcutCard = makeStatusCard( - symbol: "command", - title: "全局快捷键", - status: shortcutStatus, - accessory: shortcutButton - ) - shortcutButton.onChange = { [weak self] shortcut in - self?.onSetGlobalShortcut?(shortcut) + var cards: [NSView] = [] + if capabilityRegistry.isEnabled(.selectionTranslation) { + let accessibilityCard = makeStatusCard( + symbol: "selection.pin.in.out", + title: "系统选区", + status: accessibilityStatus, + accessory: accessibilityButton + ) + accessibilityButton.target = self + accessibilityButton.action = #selector(requestAccessibility) + cards.append(accessibilityCard) + + let shortcutCard = makeStatusCard( + symbol: "command", + title: "全局快捷键", + status: shortcutStatus, + accessory: shortcutButton + ) + shortcutButton.onChange = { [weak self] shortcut in + self?.onSetGlobalShortcut?(shortcut) + } + cards.append(shortcutCard) } - let servicesStatus = NSTextField( - labelWithString: "在“键盘快捷键 › 服务”中启用文本与图片入口" - ) - let servicesCard = makeStatusCard( - symbol: "keyboard", - title: "系统服务", - status: servicesStatus, - accessory: servicesButton - ) - servicesButton.target = self - servicesButton.action = #selector(openServicesSettings) - - let providerCard = makeProviderCard() - - let browserCard = makeBridgeCard() - let pdfRuntimeCard = makePDFRuntimeCard() - - let launchAtLoginCard = makeStatusCard( - symbol: "power", - title: "登录时启动", - status: launchAtLoginStatus, - accessory: launchAtLoginSwitch - ) - launchAtLoginSwitch.target = self - launchAtLoginSwitch.action = #selector(setLaunchAtLogin(_:)) + if capabilityRegistry.isEnabled(.clipboardTranslation) + || capabilityRegistry.isEnabled(.imageTranslation) + { + let servicesStatus = NSTextField( + labelWithString: "在“键盘快捷键 › 服务”中启用文本与图片入口" + ) + let servicesCard = makeStatusCard( + symbol: "keyboard", + title: "系统服务", + status: servicesStatus, + accessory: servicesButton + ) + servicesButton.target = self + servicesButton.action = #selector(openServicesSettings) + cards.append(servicesCard) + } - let clipboardButton = NSButton(title: "翻译文本", target: self, action: #selector(translateClipboard)) - clipboardButton.bezelStyle = .rounded - clipboardButton.controlSize = .large - clipboardButton.keyEquivalent = "\r" + if capabilityRegistry.supports(.providerConfiguration) { + cards.append(makeProviderCard()) + } + if capabilityRegistry.supports(.translationLoopbackBridge) { + cards.append(makeBridgeCard()) + } + if capabilityRegistry.isEnabled(.pdfTranslation) { + cards.append(makePDFRuntimeCard()) + } + if capabilityRegistry.supports(.appUpdates) { + cards.append(makeAppUpdateCard()) + } + if capabilityRegistry.supports(.launchAtLogin) { + let launchAtLoginCard = makeStatusCard( + symbol: "power", + title: "登录时启动", + status: launchAtLoginStatus, + accessory: launchAtLoginSwitch + ) + launchAtLoginSwitch.target = self + launchAtLoginSwitch.action = #selector(setLaunchAtLogin(_:)) + cards.append(launchAtLoginCard) + } - let imageButton = NSButton( - title: "翻译图片", - target: self, - action: #selector(translateClipboardImage) - ) - imageButton.bezelStyle = .rounded - imageButton.controlSize = .large + let cardsStack = NSStackView(views: cards) + cardsStack.orientation = .vertical + cardsStack.alignment = .leading + cardsStack.spacing = 10 + cardsStack.translatesAutoresizingMaskIntoConstraints = false + for card in cards { + card.widthAnchor.constraint(equalTo: cardsStack.widthAnchor).isActive = true + } - let screenshotButton = NSButton( - title: "截图翻译…", - target: self, - action: #selector(translateScreenshot) - ) - screenshotButton.bezelStyle = .rounded - screenshotButton.controlSize = .large - - let actionButtons = NSStackView(views: [clipboardButton, imageButton, screenshotButton]) - actionButtons.orientation = .horizontal - actionButtons.alignment = .centerY - actionButtons.spacing = 8 - actionButtons.translatesAutoresizingMaskIntoConstraints = false - - let hint = NSTextField( - wrappingLabelWithString: - "选择文本或长按已有选区后 GlossBar 会自动出现;也可以使用全局快捷键,翻译剪贴板图片或截图。图片始终在本机完成 OCR;使用本地模型时,文字也不会离开设备。" - ) - hint.font = .systemFont(ofSize: 12) - hint.textColor = .tertiaryLabelColor - hint.alignment = .center - hint.translatesAutoresizingMaskIntoConstraints = false - - for view in [ - header, - accessibilityCard, - shortcutCard, - servicesCard, - providerCard, - browserCard, - pdfRuntimeCard, - launchAtLoginCard, - actionButtons, - hint, - ] { - content.addSubview(view) + var quickActionButtons: [NSButton] = [] + if capabilityRegistry.isEnabled(.clipboardTranslation) { + let clipboardButton = NSButton( + title: "翻译文本", + target: self, + action: #selector(translateClipboard) + ) + clipboardButton.bezelStyle = .rounded + clipboardButton.controlSize = .large + clipboardButton.keyEquivalent = "\r" + quickActionButtons.append(clipboardButton) + } + if capabilityRegistry.isEnabled(.imageTranslation) { + let imageButton = NSButton( + title: "翻译图片", + target: self, + action: #selector(translateClipboardImage) + ) + imageButton.bezelStyle = .rounded + imageButton.controlSize = .large + quickActionButtons.append(imageButton) + + let screenshotButton = NSButton( + title: "截图翻译…", + target: self, + action: #selector(translateScreenshot) + ) + screenshotButton.bezelStyle = .rounded + screenshotButton.controlSize = .large + quickActionButtons.append(screenshotButton) } - NSLayoutConstraint.activate([ + content.addSubview(header) + content.addSubview(cardsStack) + + var layoutConstraints = [ header.topAnchor.constraint(equalTo: content.topAnchor, constant: 28), header.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 32), header.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -32), icon.widthAnchor.constraint(equalToConstant: 48), icon.heightAnchor.constraint(equalToConstant: 48), - - accessibilityCard.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 26), - accessibilityCard.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 28), - accessibilityCard.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -28), - - shortcutCard.topAnchor.constraint(equalTo: accessibilityCard.bottomAnchor, constant: 10), - shortcutCard.leadingAnchor.constraint(equalTo: accessibilityCard.leadingAnchor), - shortcutCard.trailingAnchor.constraint(equalTo: accessibilityCard.trailingAnchor), - - servicesCard.topAnchor.constraint(equalTo: shortcutCard.bottomAnchor, constant: 10), - servicesCard.leadingAnchor.constraint(equalTo: accessibilityCard.leadingAnchor), - servicesCard.trailingAnchor.constraint(equalTo: accessibilityCard.trailingAnchor), - - providerCard.topAnchor.constraint(equalTo: servicesCard.bottomAnchor, constant: 10), - providerCard.leadingAnchor.constraint(equalTo: servicesCard.leadingAnchor), - providerCard.trailingAnchor.constraint(equalTo: servicesCard.trailingAnchor), - - browserCard.topAnchor.constraint(equalTo: providerCard.bottomAnchor, constant: 10), - browserCard.leadingAnchor.constraint(equalTo: providerCard.leadingAnchor), - browserCard.trailingAnchor.constraint(equalTo: providerCard.trailingAnchor), - - 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), - - actionButtons.topAnchor.constraint(equalTo: launchAtLoginCard.bottomAnchor, constant: 24), - actionButtons.centerXAnchor.constraint(equalTo: content.centerXAnchor), - - 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(equalTo: content.bottomAnchor, constant: -20), - ]) + cardsStack.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 26), + cardsStack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 28), + cardsStack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -28), + ] + + if quickActionButtons.isEmpty { + layoutConstraints.append( + cardsStack.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -24) + ) + } else { + let actionButtons = NSStackView(views: quickActionButtons) + actionButtons.orientation = .horizontal + actionButtons.alignment = .centerY + actionButtons.spacing = 8 + actionButtons.translatesAutoresizingMaskIntoConstraints = false + + let hint = NSTextField( + wrappingLabelWithString: + "辅助翻译入口可随业务场景重新启用;图片始终在本机完成 OCR。" + ) + hint.font = .systemFont(ofSize: 12) + hint.textColor = .tertiaryLabelColor + hint.alignment = .center + hint.translatesAutoresizingMaskIntoConstraints = false + + content.addSubview(actionButtons) + content.addSubview(hint) + layoutConstraints.append(contentsOf: [ + actionButtons.topAnchor.constraint( + equalTo: cardsStack.bottomAnchor, + constant: 24 + ), + actionButtons.centerXAnchor.constraint(equalTo: content.centerXAnchor), + 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(equalTo: content.bottomAnchor, constant: -20), + ]) + } + NSLayoutConstraint.activate(layoutConstraints) } private func makePDFRuntimeCard() -> NSView { @@ -441,6 +487,93 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel return card } + private func makeAppUpdateCard() -> 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: "arrow.triangle.2.circlepath.circle", + accessibilityDescription: "Gloss 更新" + ) + icon.contentTintColor = .secondaryLabelColor + icon.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = NSTextField(labelWithString: "Gloss 更新") + titleLabel.font = .systemFont(ofSize: 13, weight: .semibold) + titleLabel.setContentHuggingPriority(.required, for: .horizontal) + + appUpdateProgressIndicator.style = .spinning + appUpdateProgressIndicator.controlSize = .small + appUpdateProgressIndicator.isHidden = true + + appUpdateStatus.font = .systemFont(ofSize: 12, weight: .medium) + appUpdateStatus.lineBreakMode = .byTruncatingTail + appUpdateStatus.setContentCompressionResistancePriority( + .defaultLow, + for: .horizontal + ) + + let statusStack = NSStackView( + views: [appUpdateProgressIndicator, appUpdateStatus] + ) + statusStack.orientation = .horizontal + statusStack.alignment = .centerY + statusStack.spacing = 5 + + appUpdateActionButton.target = self + appUpdateActionButton.action = #selector(performAppUpdateAction) + appUpdateActionButton.setContentHuggingPriority( + .required, + for: .horizontal + ) + + let topRow = NSStackView( + views: [titleLabel, statusStack, appUpdateActionButton] + ) + topRow.orientation = .horizontal + topRow.alignment = .centerY + topRow.spacing = 10 + topRow.distribution = .fill + + appUpdateDetail.font = .systemFont(ofSize: 11.5) + appUpdateDetail.textColor = .secondaryLabelColor + appUpdateDetail.lineBreakMode = .byTruncatingMiddle + appUpdateDetail.setContentCompressionResistancePriority( + .defaultLow, + for: .horizontal + ) + + let content = NSStackView(views: [topRow, appUpdateDetail]) + content.orientation = .vertical + content.alignment = .leading + content.spacing = 5 + content.translatesAutoresizingMaskIntoConstraints = false + + card.addSubview(icon) + card.addSubview(content) + NSLayoutConstraint.activate([ + card.heightAnchor.constraint(equalToConstant: 76), + 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), + appUpdateDetail.widthAnchor.constraint(equalTo: content.widthAnchor), + ]) + showAppUpdateState(appUpdateState) + return card + } + private func makeBridgeCard() -> NSView { let card = NSVisualEffectView() card.material = .contentBackground @@ -534,6 +667,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel extensionRow.alignment = .centerY extensionRow.spacing = 8 extensionRow.distribution = .fill + extensionRow.isHidden = !capabilityRegistry.isEnabled(.browserTranslation) let content = NSStackView( views: [topRow, bridgeDetail, bridgePath, extensionRow] @@ -546,7 +680,12 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel card.addSubview(icon) card.addSubview(content) NSLayoutConstraint.activate([ - card.heightAnchor.constraint(equalToConstant: 118), + card.heightAnchor.constraint( + equalToConstant: + capabilityRegistry.isEnabled(.browserTranslation) + ? 118 + : 92 + ), icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16), icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 16), icon.widthAnchor.constraint(equalToConstant: 22), @@ -564,6 +703,23 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel return card } + private var productSubtitle: String { + let browser = capabilityRegistry.isEnabled(.browserTranslation) + let pdf = capabilityRegistry.isEnabled(.pdfTranslation) + switch (browser, pdf) { + case (true, true): + return "浏览器与 PDF 翻译" + case (true, false): + return "Safari 与 Chrome 翻译" + case (false, true): + return "批量 PDF 翻译" + case (false, false): + return capabilityRegistry.isEnabled(.selectionTranslation) + ? "选中,即懂。" + : "按需组合的翻译工具" + } + } + private func makeProviderCard() -> NSView { let card = NSVisualEffectView() card.material = .contentBackground @@ -798,6 +954,10 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel onPDFRuntimeAction?(action) } + @objc private func performAppUpdateAction() { + onAppUpdateAction?() + } + @objc private func openServicesSettings() { onOpenServicesSettings?() } @@ -864,6 +1024,30 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel } } + func showAppUpdateState(_ state: AppUpdateDashboardState) { + appUpdateState = state + let presentation = state.presentation + appUpdateStatus.stringValue = presentation.headline + appUpdateStatus.toolTip = presentation.headline + appUpdateDetail.stringValue = presentation.detail + appUpdateDetail.toolTip = presentation.detail + appUpdateActionButton.title = presentation.actionTitle + appUpdateActionButton.isEnabled = presentation.actionEnabled + appUpdateProgressIndicator.isHidden = !presentation.showsProgress + if presentation.showsProgress { + appUpdateProgressIndicator.startAnimation(nil) + } else { + appUpdateProgressIndicator.stopAnimation(nil) + } + appUpdateStatus.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/AppUpdateControllerTests.swift b/Tests/GlossAppTests/AppUpdateControllerTests.swift new file mode 100644 index 0000000..e3c2a9d --- /dev/null +++ b/Tests/GlossAppTests/AppUpdateControllerTests.swift @@ -0,0 +1,278 @@ +import Foundation +import GlossCore +import XCTest + +@testable import Gloss + +@MainActor +final class AppUpdateControllerTests: XCTestCase { + func testAutomaticThrottleReturnsToIdleWithoutUserFacingFailure() async { + let controller = makeController( + check: { _ in + .throttled(nextCheckAt: Date(timeIntervalSince1970: 2_000)) + } + ) + + await controller.check(mode: .automatic) + + XCTAssertEqual( + controller.state, + .idle(currentVersion: "0.8.2") + ) + } + + func testManagedInstallIsOfferedAfterSignedUpdateDiscovery() async { + let expectedInstallation = installation() + let controller = makeController( + check: { _ in .updateAvailable(self.updateAvailability()) }, + detect: { expectedInstallation } + ) + + await controller.check(mode: .manual) + + XCTAssertEqual( + controller.state, + .updateAvailable( + updateAvailability(), + delivery: .homebrew(expectedInstallation) + ) + ) + } + + func testUnmanagedInstallOnlyOpensOfficialReleasePage() async { + var openedURL: URL? + let controller = makeController( + check: { _ in .updateAvailable(self.updateAvailability()) }, + detect: { nil }, + openReleasePage: { + openedURL = $0 + return true + } + ) + + await controller.check(mode: .manual) + await controller.performPrimaryAction() + + XCTAssertEqual(openedURL, updateAvailability().releasePageURL) + } + + func testActivePDFDefersInstallWithoutLaunchingHelper() async { + var helperLaunchCount = 0 + var terminationCount = 0 + let controller = makeController( + check: { _ in .updateAvailable(self.updateAvailability()) }, + detect: { self.installation() }, + launch: { _, _ in helperLaunchCount += 1 }, + isPDFActive: { true }, + terminate: { terminationCount += 1 } + ) + + await controller.check(mode: .manual) + await controller.performPrimaryAction() + + XCTAssertEqual(helperLaunchCount, 0) + XCTAssertEqual(terminationCount, 0) + guard case .blockedByBusinessTask = controller.state else { + return XCTFail("Expected business-task blocked state") + } + } + + func testVerifiedHelperLaunchRequestsTermination() async { + var launchedInstallation: GlossHomebrewInstallation? + var launchedUpdate: GlossAppUpdateAvailability? + var terminationCount = 0 + let controller = makeController( + check: { _ in .updateAvailable(self.updateAvailability()) }, + detect: { self.installation() }, + launch: { installation, update in + launchedInstallation = installation + launchedUpdate = update + }, + terminate: { terminationCount += 1 } + ) + + await controller.check(mode: .manual) + await controller.performPrimaryAction() + + XCTAssertEqual(launchedInstallation, installation()) + XCTAssertEqual(launchedUpdate, updateAvailability()) + XCTAssertEqual(terminationCount, 1) + XCTAssertEqual( + controller.state, + .preparingInstall(version: "0.8.3") + ) + } + + func testUnsupportedMinimumSystemDoesNotInspectHomebrew() async { + var detectionCount = 0 + let controller = makeController( + check: { _ in .updateAvailable(self.updateAvailability(minimum: "15.0")) }, + detect: { + detectionCount += 1 + return self.installation() + }, + operatingSystemVersion: OperatingSystemVersion( + majorVersion: 14, + minorVersion: 7, + patchVersion: 0 + ) + ) + + await controller.check(mode: .manual) + + XCTAssertEqual(detectionCount, 0) + guard case .unavailable(_, let reason) = controller.state else { + return XCTFail("Expected unavailable state") + } + XCTAssertTrue(reason.contains("macOS 15.0")) + } + + func testSystemVersionComparisonUsesAllComponents() { + XCTAssertTrue( + AppUpdateController.supports( + minimumMacOSVersion: "14.1", + current: OperatingSystemVersion( + majorVersion: 14, + minorVersion: 1, + patchVersion: 0 + ) + ) + ) + XCTAssertFalse( + AppUpdateController.supports( + minimumMacOSVersion: "14.1.1", + current: OperatingSystemVersion( + majorVersion: 14, + minorVersion: 1, + patchVersion: 0 + ) + ) + ) + } + + func testStagingCleanupRemovesOnlyTheScopedUpdaterDirectory() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "gloss-app-update-cleanup-\(UUID().uuidString)", + isDirectory: true + ) + .appendingPathComponent("AppUpdater", isDirectory: true) + let staging = root.appendingPathComponent( + "staging", + isDirectory: true + ) + let helper = + staging + .appendingPathComponent(UUID().uuidString, isDirectory: true) + .appendingPathComponent("gloss-update-helper") + try FileManager.default.createDirectory( + at: helper.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("helper".utf8).write(to: helper) + defer { + try? FileManager.default.removeItem( + at: root.deletingLastPathComponent() + ) + } + + try AppUpdateStagingCleaner.removeStagedHelpers(at: staging) + + XCTAssertFalse( + FileManager.default.fileExists(atPath: staging.path) + ) + XCTAssertThrowsError( + try AppUpdateStagingCleaner.removeStagedHelpers( + at: root.deletingLastPathComponent() + ) + ) { error in + XCTAssertEqual( + error as? AppUpdateStagingCleaner.CleanupError, + .invalidDirectory + ) + } + } + + private func makeController( + check: + @escaping (GlossAppUpdateCheckMode) async throws + -> GlossAppUpdateCheckResult, + detect: @escaping () async throws -> GlossHomebrewInstallation? = { + nil + }, + launch: + @escaping ( + GlossHomebrewInstallation, + GlossAppUpdateAvailability + ) async throws -> Void = { _, _ in }, + openReleasePage: @escaping (URL) -> Bool = { _ in true }, + isPDFActive: @escaping () -> Bool = { false }, + terminate: @escaping () -> Void = {}, + operatingSystemVersion: OperatingSystemVersion = OperatingSystemVersion( + majorVersion: 14, + minorVersion: 0, + patchVersion: 0 + ) + ) -> AppUpdateController { + AppUpdateController( + currentVersion: "0.8.2", + dependencies: AppUpdateController.Dependencies( + check: check, + detectHomebrewInstallation: detect, + launchHomebrewUpdate: launch, + openReleasePage: openReleasePage, + isBusinessTaskActive: isPDFActive, + requestApplicationTermination: terminate, + operatingSystemVersion: { operatingSystemVersion } + ) + ) + } + + private func updateAvailability( + minimum: String = "14.0" + ) -> GlossAppUpdateAvailability { + GlossAppUpdateAvailability( + version: "0.8.3", + releaseTag: "v0.8.3", + publishedAt: Date(timeIntervalSince1970: 1_000), + minimumMacOSVersion: minimum, + releasePageURL: URL( + string: + "https://github.com/SunChJ/gloss-releases/releases/tag/v0.8.3" + )!, + architecture: GlossAppArchitecture.current, + assetURL: URL( + string: + "https://github.com/SunChJ/gloss-releases/releases/download/v0.8.3/Gloss-macos-\(GlossAppArchitecture.current).zip" + )!, + assetSHA256: String(repeating: "a", count: 64), + assetSize: 100, + homebrewCask: GlossAppReleaseManifest.HomebrewCask( + url: URL( + string: + "https://github.com/SunChJ/gloss-releases/releases/download/v0.8.3/gloss.rb" + )!, + sha256: String(repeating: "b", count: 64), + size: 200 + ), + manifestData: Data("manifest".utf8), + detachedSignatureData: Data("signature".utf8) + ) + } + + private func installation() -> GlossHomebrewInstallation { + GlossHomebrewInstallation( + brewExecutableURL: URL(fileURLWithPath: "/opt/homebrew/bin/brew"), + caskToken: "sunchj/tap/gloss", + installedVersion: "0.8.2", + availableVersion: "0.8.2", + managedAppURL: URL( + fileURLWithPath: + "/opt/homebrew/Caskroom/gloss/0.8.2/Gloss.app" + ), + installedAppTargetURL: URL( + fileURLWithPath: "/Applications/Gloss.app" + ) + ) + } +} diff --git a/Tests/GlossAppTests/AppUpdateDashboardStateTests.swift b/Tests/GlossAppTests/AppUpdateDashboardStateTests.swift new file mode 100644 index 0000000..c29f342 --- /dev/null +++ b/Tests/GlossAppTests/AppUpdateDashboardStateTests.swift @@ -0,0 +1,160 @@ +import GlossCore +import XCTest + +@testable import Gloss + +final class AppUpdateDashboardStateTests: XCTestCase { + func testManagedUpdateOffersHomebrewRestart() { + let update = updateAvailability() + let state = AppUpdateDashboardState.updateAvailable( + update, + delivery: .homebrew(installation()) + ) + + XCTAssertEqual(state.action, .install) + XCTAssertEqual( + state.presentation.actionTitle, + "更新并重新启动" + ) + XCTAssertTrue(state.presentation.detail.contains("Homebrew")) + XCTAssertEqual( + state.menuPresentation.title, + "更新 Gloss 到 0.8.3…" + ) + } + + func testUnmanagedUpdateOnlyOffersOfficialReleasePage() { + let state = AppUpdateDashboardState.updateAvailable( + updateAvailability(), + delivery: .releasePage + ) + + XCTAssertEqual(state.action, .openReleasePage) + XCTAssertEqual(state.presentation.actionTitle, "查看下载") + XCTAssertTrue(state.presentation.detail.contains("sunchj/tap/gloss")) + XCTAssertEqual( + state.menuPresentation.title, + "下载 Gloss 0.8.3…" + ) + } + + func testBusinessTaskBlockKeepsVerifiedInstallationForRetry() { + let expectedInstallation = installation() + let state = AppUpdateDashboardState.blockedByBusinessTask( + updateAvailability(), + installation: expectedInstallation + ) + + XCTAssertEqual(state.action, .install) + XCTAssertEqual(state.presentation.headline, "等待当前翻译任务完成") + XCTAssertTrue(state.menuPresentation.title.contains("0.8.3")) + guard case .blockedByBusinessTask(_, let actualInstallation) = state else { + return XCTFail("Expected blocked business-task state") + } + XCTAssertEqual(actualInstallation, expectedInstallation) + } + + func testCheckingAndPreparingStatesDisableRepeatedActions() { + let checking = AppUpdateDashboardState.checking( + currentVersion: "0.8.2" + ) + let preparing = AppUpdateDashboardState.preparingInstall( + version: "0.8.3" + ) + + XCTAssertNil(checking.action) + XCTAssertFalse(checking.presentation.actionEnabled) + XCTAssertTrue(checking.presentation.showsProgress) + XCTAssertNil(preparing.action) + XCTAssertFalse(preparing.menuPresentation.isEnabled) + } + + func testFailedHelperResultIsVisibleAndRetryableOnRelaunch() { + let state = AppUpdateDashboardState.fromHomebrewResult( + GlossHomebrewUpgradeResult( + outcome: .failed, + expectedVersion: "0.8.3", + errorCode: "homebrew_upgrade_failed", + message: "tap 尚未同步", + completedAt: Date(timeIntervalSince1970: 2_000) + ), + currentVersion: "0.8.2" + ) + + XCTAssertEqual( + state, + .failed( + currentVersion: "0.8.2", + message: "tap 尚未同步" + ) + ) + XCTAssertEqual(state.action, .check) + XCTAssertEqual(state.presentation.actionTitle, "重试") + } + + func testSuccessfulHelperResultUsesInstalledVersion() { + let state = AppUpdateDashboardState.fromHomebrewResult( + GlossHomebrewUpgradeResult( + outcome: .succeeded, + expectedVersion: "0.8.3", + installedVersion: "0.8.4", + completedAt: Date(timeIntervalSince1970: 2_000) + ), + currentVersion: "0.8.4" + ) + + XCTAssertEqual( + state, + .upToDate( + currentVersion: "0.8.4", + latestVersion: "0.8.4" + ) + ) + } + + private func updateAvailability() -> GlossAppUpdateAvailability { + GlossAppUpdateAvailability( + version: "0.8.3", + releaseTag: "v0.8.3", + publishedAt: Date(timeIntervalSince1970: 1_000), + minimumMacOSVersion: "14.0", + releasePageURL: URL( + string: + "https://github.com/SunChJ/gloss-releases/releases/tag/v0.8.3" + )!, + architecture: GlossAppArchitecture.current, + assetURL: URL( + string: + "https://github.com/SunChJ/gloss-releases/releases/download/v0.8.3/Gloss-macos-\(GlossAppArchitecture.current).zip" + )!, + assetSHA256: String(repeating: "a", count: 64), + assetSize: 100, + homebrewCask: GlossAppReleaseManifest.HomebrewCask( + url: URL( + string: + "https://github.com/SunChJ/gloss-releases/releases/download/v0.8.3/gloss.rb" + )!, + sha256: String(repeating: "b", count: 64), + size: 200 + ), + manifestData: Data("manifest".utf8), + detachedSignatureData: Data("signature".utf8) + ) + } + + private func installation() -> GlossHomebrewInstallation { + GlossHomebrewInstallation( + brewExecutableURL: URL(fileURLWithPath: "/opt/homebrew/bin/brew"), + caskToken: "sunchj/tap/gloss", + installedVersion: "0.8.2", + availableVersion: "0.8.2", + managedAppURL: URL( + fileURLWithPath: + "/opt/homebrew/Caskroom/gloss/0.8.2/Gloss.app" + ), + installedAppTargetURL: URL( + fileURLWithPath: "/Applications/Gloss.app" + ) + ) + } +} diff --git a/Tests/GlossAppTests/GlossAppScenarioActivationTests.swift b/Tests/GlossAppTests/GlossAppScenarioActivationTests.swift new file mode 100644 index 0000000..881c69f --- /dev/null +++ b/Tests/GlossAppTests/GlossAppScenarioActivationTests.swift @@ -0,0 +1,79 @@ +import GlossCore +import XCTest + +@testable import Gloss + +final class GlossAppScenarioActivationTests: XCTestCase { + func testDefaultScenariosStartBrowserAndPDFInfrastructure() { + let activation = GlossAppScenarioActivation(registry: .current) + + XCTAssertTrue(activation.startsTranslationBridge) + XCTAssertTrue(activation.preparesBrowserExtensions) + XCTAssertTrue(activation.preparesPDFRuntime) + XCTAssertTrue(activation.prewarmsTranslationProvider) + XCTAssertTrue(activation.acceptsPDFOpenRequests) + XCTAssertFalse(activation.configuresSystemServices) + XCTAssertFalse(activation.observesWorkspaceApplications) + } + + func testBrowserOnlyDoesNotStartPDFInfrastructure() { + let activation = GlossAppScenarioActivation( + registry: GlossCapabilityRegistry( + enabledScenarios: [.browserTranslation] + ) + ) + + XCTAssertTrue(activation.startsTranslationBridge) + XCTAssertTrue(activation.preparesBrowserExtensions) + XCTAssertTrue(activation.prewarmsTranslationProvider) + XCTAssertFalse(activation.preparesPDFRuntime) + XCTAssertFalse(activation.acceptsPDFOpenRequests) + } + + func testPDFOnlyStillStartsTranslationBridge() { + let activation = GlossAppScenarioActivation( + registry: GlossCapabilityRegistry( + enabledScenarios: [.pdfTranslation] + ) + ) + + XCTAssertTrue(activation.startsTranslationBridge) + XCTAssertFalse(activation.preparesBrowserExtensions) + XCTAssertTrue(activation.preparesPDFRuntime) + XCTAssertTrue(activation.prewarmsTranslationProvider) + XCTAssertTrue(activation.acceptsPDFOpenRequests) + } + + func testNoBusinessScenariosStartNoTranslationServices() { + let activation = GlossAppScenarioActivation( + registry: GlossCapabilityRegistry(enabledScenarios: []) + ) + + XCTAssertFalse(activation.startsTranslationBridge) + XCTAssertFalse(activation.preparesBrowserExtensions) + XCTAssertFalse(activation.preparesPDFRuntime) + XCTAssertFalse(activation.prewarmsTranslationProvider) + XCTAssertFalse(activation.acceptsPDFOpenRequests) + XCTAssertFalse(activation.configuresSystemServices) + XCTAssertFalse(activation.observesWorkspaceApplications) + } + + func testSecondaryScenariosStartOnlyTheirSupportingLifecycleWork() { + let activation = GlossAppScenarioActivation( + registry: GlossCapabilityRegistry( + enabledScenarios: [ + .clipboardTranslation, + .imageTranslation, + .selectionTranslation, + ] + ) + ) + + XCTAssertTrue(activation.configuresSystemServices) + XCTAssertTrue(activation.observesWorkspaceApplications) + XCTAssertTrue(activation.prewarmsTranslationProvider) + XCTAssertFalse(activation.startsTranslationBridge) + XCTAssertFalse(activation.preparesBrowserExtensions) + XCTAssertFalse(activation.preparesPDFRuntime) + } +} From 1bd4ab532104597e55e46a1d8e8e6ed1eedc116f Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Mon, 27 Jul 2026 01:04:37 -0700 Subject: [PATCH 6/9] chore: prepare Gloss 0.8.3 --- Resources/Info.plist | 4 +- docs/release-notes/v0.8.3.md | 74 ++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 docs/release-notes/v0.8.3.md diff --git a/Resources/Info.plist b/Resources/Info.plist index 30d4453..cf7e378 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.8.2 + 0.8.3 CFBundleVersion - 10 + 11 CFBundleDocumentTypes diff --git a/docs/release-notes/v0.8.3.md b/docs/release-notes/v0.8.3.md new file mode 100644 index 0000000..8d42ed7 --- /dev/null +++ b/docs/release-notes/v0.8.3.md @@ -0,0 +1,74 @@ +# Gloss 0.8.3 + +Gloss 0.8.3 focuses the product on browser and PDF translation, makes the same +scenario model available to automation, and closes the Homebrew update loop. + +## Capability and scenario model + +- Composes user-facing workflows from a shared capability registry instead of + starting every implemented feature. +- Enables Safari/Chrome browser translation and PDF translation by default. +- Keeps clipboard, screenshot/OCR, system selection, glossary, and history + implementations available in source without exposing their UI or starting + their listeners. +- Uses the same registry for App lifecycle decisions, menus, settings, external + PDF-open requests, and CLI dispatch. + +## CLI automation + +- Adds `gloss-cli capabilities --json` for stable, machine-readable capability, + scenario, dependency, and command mappings. +- Adds `gloss-cli browser` with webpage translation semantics. +- Adds an explicit `gloss-cli text` mapping for the reusable translation core; + the original flat invocation remains an alias. +- Adds `gloss-cli pdf INPUT... --output DIR` for sequential PDF batches that + reuse one isolated BabelDOC session without taking over the App service. +- Keeps the existing flat text command for compatibility while requiring the + shared text-translation capability. + +## PDF reliability + +- Requires the signed BabelDOC `0.6.4+gloss.5` runtime, which includes the + `o200k_base` tokenizer needed by current translation models. +- Installs a compatible available runtime after the launch-time check instead + of continuing with an incompatible cached version. +- Extends the bounded cold-start window to 180 seconds for a first DocLayout + initialization. + +## Homebrew updates + +- Checks the signed public release manifest silently after launch and at most + once every 24 hours, with manual check and update controls in the App. +- Offers in-App installation only when the running bundle is verified as the + official `sunchj/tap/gloss` Homebrew cask. +- Runs fixed Homebrew commands from a staged helper after Gloss exits, without + a shell or `sudo`, then verifies the version, architecture, ad-hoc signature, + and quarantine state before reopening the App. +- Binds the exact Homebrew Cask bytes and current-architecture App asset to the + signed manifest, waits for a helper readiness acknowledgement, and restores a + verified previous App if an upgrade damages the installed bundle. +- Defers installation while a browser or PDF translation task is active and + surfaces failure results after restart. + +## Release integrity + +- Signs the App update manifest with Ed25519 and publishes its detached + signature with both architecture-specific release archives. +- Packages and signs the CLI, bundled Codex runtime, and update helper as + explicit Homebrew cask components. +- Links the bundled `gloss-cli` into Homebrew's `bin` directory and validates + its capability report in both architecture install smokes. + +## Validation + +- The complete test suite passes: 280 tests, 5 conditionally skipped, and no + failures. +- All three products (`Gloss`, `gloss-cli`, and `gloss-update-helper`) build. +- A real CLI translation of the 15-page *Attention Is All You Need* PDF + completed in 31.56 seconds and produced a 15-page, 2.3 MB PDF with selectable + Chinese text using BabelDOC `0.6.4+gloss.5`. + +## Compatibility + +Gloss 0.8.3 requires macOS 14 or newer. Existing Homebrew installations can +upgrade through the App or with `brew upgrade --cask sunchj/tap/gloss`. From a9be35ae08972dfb9ca915d3602c55fc10b3b84e Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Mon, 27 Jul 2026 01:46:28 -0700 Subject: [PATCH 7/9] fix: terminate updater command process groups --- .../GlossCore/GlossHomebrewInstallation.swift | 769 +++++++++++++++++- .../GlossHomebrewUpgradeTests.swift | 309 +++++++ 2 files changed, 1034 insertions(+), 44 deletions(-) diff --git a/Sources/GlossCore/GlossHomebrewInstallation.swift b/Sources/GlossCore/GlossHomebrewInstallation.swift index 9daadd1..bdbe69c 100644 --- a/Sources/GlossCore/GlossHomebrewInstallation.swift +++ b/Sources/GlossCore/GlossHomebrewInstallation.swift @@ -18,10 +18,19 @@ public struct GlossCommandOutput: Equatable, Sendable { } public enum GlossCommandRunnerError: LocalizedError, Equatable, Sendable { + case outputLimitExceeded( + executablePath: String, + maximumByteCount: Int + ) case timedOut(executablePath: String) public var errorDescription: String? { switch self { + case .outputLimitExceeded( + let executablePath, + let maximumByteCount + ): + "命令输出超过限制(\(maximumByteCount) 字节):\(executablePath)" case .timedOut(let executablePath): "命令执行超时:\(executablePath)" } @@ -29,6 +38,8 @@ public enum GlossCommandRunnerError: LocalizedError, Equatable, Sendable { } public struct GlossCommandRunner: Sendable { + public static let maximumCapturedOutputByteCount = 16 * 1024 * 1024 + public typealias Run = @Sendable (URL, [String], [String: String], Duration?) async throws -> GlossCommandOutput @@ -68,63 +79,733 @@ public struct GlossCommandRunner: Sendable { arguments, environment, timeout in - let process = Process() - let standardOutput = Pipe() - let standardError = Pipe() - process.executableURL = executableURL - process.arguments = arguments - if !environment.isEmpty { - process.environment = ProcessInfo.processInfo.environment - .merging(environment) { _, newValue in newValue } - } - process.standardOutput = standardOutput - process.standardError = standardError - process.standardInput = FileHandle.nullDevice - try process.run() - - let outputTask = Task.detached(priority: .utility) { - standardOutput.fileHandleForReading.readDataToEndOfFile() - } - let errorTask = Task.detached(priority: .utility) { - standardError.fileHandleForReading.readDataToEndOfFile() + let state = GlossCommandProcessState() + return try await withTaskCancellationHandler { + try await Task.detached(priority: .utility) { + try GlossSpawnedCommand.run( + executableURL: executableURL, + arguments: arguments, + environment: environment, + timeout: timeout, + state: state + ) + }.value + } onCancel: { + state.cancel() } + }) +} + +private final class GlossCommandProcessState: @unchecked Sendable { + private let lock = NSLock() + private var cancellationRequested = false + + func cancel() { + lock.lock() + cancellationRequested = true + lock.unlock() + } + + var isCancellationRequested: Bool { + lock.lock() + defer { lock.unlock() } + return cancellationRequested + } +} + +private enum GlossSpawnedCommand { + private enum CompletionReason { + case cancelled + case failed(Int32) + case leaderExited + case outputLimitExceeded + case timedOut + + var checksOutputLimit: Bool { + if case .leaderExited = self { + return true + } + return false + } + + var requiresImmediateKill: Bool { + switch self { + case .failed, .outputLimitExceeded: + return true + case .cancelled, .leaderExited, .timedOut: + return false + } + } + } + + private enum LeaderState { + case exited + case failed(Int32) + case running + } + + private struct CaptureFiles { + let directoryPath: String + let standardOutputPath: String + let standardErrorPath: String + let standardOutputDescriptor: Int32 + let standardErrorDescriptor: Int32 + + static func create() throws -> Self { + var template = Array( + (FileManager.default.temporaryDirectory.path + + "/gloss-command.XXXXXX").utf8CString + ) + let directoryPath = try template.withUnsafeMutableBufferPointer { + buffer in + guard let path = mkdtemp(buffer.baseAddress) else { + throw posixError(errno) + } + return String(cString: path) + } + let standardOutputPath = + directoryPath + "/standard-output" + let standardErrorPath = + directoryPath + "/standard-error" + do { + let standardOutputDescriptor = try openCaptureFile( + at: standardOutputPath + ) + do { + let standardErrorDescriptor = try openCaptureFile( + at: standardErrorPath + ) + return Self( + directoryPath: directoryPath, + standardOutputPath: standardOutputPath, + standardErrorPath: standardErrorPath, + standardOutputDescriptor: standardOutputDescriptor, + standardErrorDescriptor: standardErrorDescriptor + ) + } catch { + Darwin.close(standardOutputDescriptor) + unlink(standardOutputPath) + throw error + } + } catch { + rmdir(directoryPath) + throw error + } + } + + func cleanup() { + Darwin.close(standardOutputDescriptor) + Darwin.close(standardErrorDescriptor) + unlink(standardOutputPath) + unlink(standardErrorPath) + rmdir(directoryPath) + } + + private static func openCaptureFile( + at path: String + ) throws -> Int32 { + let openedDescriptor = Darwin.open( + path, + O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(S_IRUSR | S_IWUSR) + ) + guard openedDescriptor >= 0 else { + throw posixError(errno) + } + guard openedDescriptor <= STDERR_FILENO else { + return openedDescriptor + } + + let duplicatedDescriptor = fcntl( + openedDescriptor, + F_DUPFD_CLOEXEC, + STDERR_FILENO + 1 + ) + let duplicationError = errno + Darwin.close(openedDescriptor) + guard duplicatedDescriptor >= 0 else { + throw posixError(duplicationError) + } + return duplicatedDescriptor + } + + private static func posixError(_ code: Int32) -> Error { + NSError( + domain: NSPOSIXErrorDomain, + code: Int(code), + userInfo: nil + ) + } + } + + private static let supervisionIntervalMicroseconds: UInt32 = 10_000 + private static let terminationGrace: Duration = .milliseconds(250) + private static let forcedCompletionGrace: Duration = .milliseconds(500) + + static func run( + executableURL: URL, + arguments: [String], + environment: [String: String], + timeout: Duration?, + state: GlossCommandProcessState + ) throws -> GlossCommandOutput { + guard !state.isCancellationRequested else { + throw CancellationError() + } + let captureFiles = try CaptureFiles.create() + defer { captureFiles.cleanup() } + guard !state.isCancellationRequested else { + throw CancellationError() + } + let processIdentifier = try spawn( + executableURL: executableURL, + arguments: arguments, + environment: environment, + standardOutputDescriptor: + captureFiles.standardOutputDescriptor, + standardErrorDescriptor: + captureFiles.standardErrorDescriptor + ) + return try supervise( + processIdentifier: processIdentifier, + executableURL: executableURL, + captureFiles: captureFiles, + timeout: timeout, + state: state + ) + } + + private static func signalProcessGroup( + _ identifier: pid_t, + signal: Int32 + ) { + guard identifier > 0 else { + return + } + _ = Darwin.kill(-identifier, signal) + } + + private static func supervise( + processIdentifier: pid_t, + executableURL: URL, + captureFiles: CaptureFiles, + timeout: Duration?, + state: GlossCommandProcessState + ) throws -> GlossCommandOutput { let clock = ContinuousClock() - let deadline = timeout.map { clock.now.advanced(by: $0) } + let timeoutDeadline = timeout.map { clock.now.advanced(by: $0) } + var leaderExited = false + var completionReason: CompletionReason? + var terminationStartedAt: ContinuousClock.Instant? + var forcedCompletionDeadline: ContinuousClock.Instant? + var sentKill = false + var exceededOutputLimit = false - do { - while process.isRunning { - try Task.checkCancellation() - if let deadline, clock.now >= deadline { - process.terminate() - try? await Task.sleep( - for: .milliseconds(500) + while true { + let now = clock.now + if completionReason == nil { + if state.isCancellationRequested { + completionReason = .cancelled + } else if let timeoutDeadline, now >= timeoutDeadline { + completionReason = .timedOut + } + } + + if captureSizeExceedsLimit(captureFiles) { + exceededOutputLimit = true + if completionReason == nil + || completionReason!.checksOutputLimit + { + completionReason = .outputLimitExceeded + } + } + + if !leaderExited { + switch observeLeader(processIdentifier) { + case .exited: + leaderExited = true + case .failed(let code): + guard code != ECHILD else { + // The child may already have been reaped, so its PID no + // longer anchors the process group. Never signal a + // potentially reused numeric PGID. + throw posixError(code) + } + completionReason = .failed(code) + case .running: + break + } + } + + if leaderExited { + let hasDescendants = processGroupHasDescendants( + processIdentifier + ) + if completionReason == nil { + if !hasDescendants { + return try completeNormally( + processIdentifier: processIdentifier, + executableURL: executableURL, + captureFiles: captureFiles + ) + } + completionReason = .leaderExited + } else if !hasDescendants { + if case .leaderExited = completionReason! { + return try completeNormally( + processIdentifier: processIdentifier, + executableURL: executableURL, + captureFiles: captureFiles + ) + } + _ = try reapLeader(processIdentifier) + try finish( + reason: completionReason!, + executableURL: executableURL + ) + } + } + + if let completionReason, terminationStartedAt == nil { + let requiresImmediateKill = + completionReason.requiresImmediateKill + || exceededOutputLimit + terminationStartedAt = now + forcedCompletionDeadline = now.advanced( + by: terminationGrace + forcedCompletionGrace + ) + signalProcessGroup( + processIdentifier, + signal: requiresImmediateKill ? SIGKILL : SIGTERM + ) + sentKill = requiresImmediateKill + } else if exceededOutputLimit, !sentKill { + signalProcessGroup(processIdentifier, signal: SIGKILL) + sentKill = true + } + + if let terminationStartedAt, + !sentKill, + now + >= terminationStartedAt.advanced( + by: terminationGrace ) - if process.isRunning { - kill(process.processIdentifier, SIGKILL) + { + signalProcessGroup(processIdentifier, signal: SIGKILL) + sentKill = true + } + + if sentKill, let completionReason { + if let forcedCompletionDeadline, + now >= forcedCompletionDeadline + { + signalProcessGroup(processIdentifier, signal: SIGKILL) + if case .leaderExited = completionReason, + leaderExited + { + return try completeNormally( + processIdentifier: processIdentifier, + executableURL: executableURL, + captureFiles: captureFiles + ) + } + if leaderExited { + do { + _ = try reapLeader(processIdentifier) + } catch { + reapEventually(processIdentifier) + } + } else { + reapEventually(processIdentifier) } - standardOutput.fileHandleForReading.closeFile() - standardError.fileHandleForReading.closeFile() - outputTask.cancel() - errorTask.cancel() - throw GlossCommandRunnerError.timedOut( - executablePath: executableURL.path + try finish( + reason: completionReason, + executableURL: executableURL ) } - try await Task.sleep(for: .milliseconds(50)) } - } catch { - if process.isRunning { - process.terminate() + + usleep(supervisionIntervalMicroseconds) + } + } + + private static func completeNormally( + processIdentifier: pid_t, + executableURL: URL, + captureFiles: CaptureFiles + ) throws -> GlossCommandOutput { + guard !captureSizeExceedsLimit(captureFiles) else { + _ = try reapLeader(processIdentifier) + throw GlossCommandRunnerError.outputLimitExceeded( + executablePath: executableURL.path, + maximumByteCount: + GlossCommandRunner.maximumCapturedOutputByteCount + ) + } + let sizes = try captureSizes(captureFiles) + let rawWaitStatus = try reapLeader(processIdentifier) + return makeOutput( + rawWaitStatus: rawWaitStatus, + standardOutput: try readCapture( + descriptor: captureFiles.standardOutputDescriptor, + byteCount: sizes.standardOutput + ), + standardError: try readCapture( + descriptor: captureFiles.standardErrorDescriptor, + byteCount: sizes.standardError + ) + ) + } + + private static func finish( + reason: CompletionReason, + executableURL: URL + ) throws -> Never { + switch reason { + case .cancelled: + throw CancellationError() + case .failed(let code): + throw posixError(code) + case .leaderExited: + preconditionFailure("Leader exit completes normally.") + case .outputLimitExceeded: + throw GlossCommandRunnerError.outputLimitExceeded( + executablePath: executableURL.path, + maximumByteCount: + GlossCommandRunner.maximumCapturedOutputByteCount + ) + case .timedOut: + throw GlossCommandRunnerError.timedOut( + executablePath: executableURL.path + ) + } + } + + private static func observeLeader( + _ processIdentifier: pid_t + ) -> LeaderState { + var information = siginfo_t() + while true { + let result = Darwin.waitid( + P_PID, + id_t(processIdentifier), + &information, + WEXITED | WNOHANG | WNOWAIT + ) + if result == 0 { + return information.si_pid == processIdentifier + ? .exited : .running + } + if errno == EINTR { + continue } - throw error + return .failed(errno) + } + } + + private static func processGroupHasDescendants( + _ processIdentifier: pid_t + ) -> Bool { + var managementInformation = [ + Int32(CTL_KERN), + Int32(KERN_PROC), + Int32(KERN_PROC_PGRP), + processIdentifier, + ] + var byteCount: size_t = 0 + guard + managementInformation.withUnsafeMutableBufferPointer({ + sysctl( + $0.baseAddress, + u_int($0.count), + nil, + &byteCount, + nil, + 0 + ) + }) == 0 + else { + return true + } + + let stride = MemoryLayout.stride + var processes = [kinfo_proc]( + repeating: kinfo_proc(), + count: max(1, byteCount / stride + 8) + ) + byteCount = processes.count * stride + let result = managementInformation.withUnsafeMutableBufferPointer { + managementBuffer in + processes.withUnsafeMutableBytes { processBuffer in + sysctl( + managementBuffer.baseAddress, + u_int(managementBuffer.count), + processBuffer.baseAddress, + &byteCount, + nil, + 0 + ) + } + } + guard result == 0 else { + return true } + let processCount = byteCount / stride + var foundLeader = false + for process in processes.prefix(processCount) { + if process.kp_proc.p_pid == processIdentifier { + foundLeader = true + } else { + return true + } + } + return !foundLeader + } + + private static func reapLeader( + _ processIdentifier: pid_t + ) throws -> Int32 { + var status: Int32 = 0 + while true { + let result = Darwin.waitpid( + processIdentifier, + &status, + 0 + ) + if result == processIdentifier { + return status + } + if errno == EINTR { + continue + } + throw posixError(errno) + } + } + + private static func reapEventually(_ processIdentifier: pid_t) { + Task.detached(priority: .utility) { + var status: Int32 = 0 + while Darwin.waitpid(processIdentifier, &status, 0) == -1, + errno == EINTR + {} + } + } + + private static func captureSizeExceedsLimit( + _ captureFiles: CaptureFiles + ) -> Bool { + guard let sizes = try? captureSizes(captureFiles) else { + return true + } + let limit = GlossCommandRunner.maximumCapturedOutputByteCount + return sizes.standardOutput > limit + || sizes.standardError > limit + || sizes.standardOutput > limit - sizes.standardError + } + + private static func captureSizes( + _ captureFiles: CaptureFiles + ) throws -> (standardOutput: Int, standardError: Int) { + var standardOutputStatus = stat() + var standardErrorStatus = stat() + guard + fstat( + captureFiles.standardOutputDescriptor, + &standardOutputStatus + ) == 0, + fstat( + captureFiles.standardErrorDescriptor, + &standardErrorStatus + ) == 0, + standardOutputStatus.st_size >= 0, + standardErrorStatus.st_size >= 0, + standardOutputStatus.st_size <= off_t(Int.max), + standardErrorStatus.st_size <= off_t(Int.max) + else { + throw posixError(errno == 0 ? EOVERFLOW : errno) + } + return ( + Int(standardOutputStatus.st_size), + Int(standardErrorStatus.st_size) + ) + } + + private static func readCapture( + descriptor: Int32, + byteCount: Int + ) throws -> Data { + var data = Data(count: byteCount) + var offset = 0 + while offset < byteCount { + let count = data.withUnsafeMutableBytes { bytes in + Darwin.pread( + descriptor, + bytes.baseAddress!.advanced(by: offset), + byteCount - offset, + off_t(offset) + ) + } + if count > 0 { + offset += count + } else if count == 0 { + data.removeSubrange(offset.. GlossCommandOutput { + let signal = rawWaitStatus & 0x7f + let terminationStatus = + signal == 0 + ? (rawWaitStatus >> 8) & 0xff + : signal return GlossCommandOutput( - terminationStatus: process.terminationStatus, - standardOutput: await outputTask.value, - standardError: await errorTask.value + terminationStatus: terminationStatus, + standardOutput: standardOutput, + standardError: standardError ) - }) + } + + private static func spawn( + executableURL: URL, + arguments: [String], + environment: [String: String], + standardOutputDescriptor: Int32, + standardErrorDescriptor: Int32 + ) throws -> pid_t { + var fileActions: posix_spawn_file_actions_t? + var attributes: posix_spawnattr_t? + try check(posix_spawn_file_actions_init(&fileActions)) + defer { posix_spawn_file_actions_destroy(&fileActions) } + try check(posix_spawnattr_init(&attributes)) + defer { posix_spawnattr_destroy(&attributes) } + + try "/dev/null".withCString { path in + try check( + posix_spawn_file_actions_addopen( + &fileActions, + STDIN_FILENO, + path, + O_RDONLY, + 0 + ) + ) + } + try check( + posix_spawn_file_actions_adddup2( + &fileActions, + standardOutputDescriptor, + STDOUT_FILENO + ) + ) + try check( + posix_spawn_file_actions_adddup2( + &fileActions, + standardErrorDescriptor, + STDERR_FILENO + ) + ) + for descriptor in [ + standardOutputDescriptor, + standardErrorDescriptor, + ] { + try check( + posix_spawn_file_actions_addclose( + &fileActions, + descriptor + ) + ) + } + + try check(posix_spawnattr_setpgroup(&attributes, 0)) + let flags = + Int16(POSIX_SPAWN_SETPGROUP) + | Int16(POSIX_SPAWN_CLOEXEC_DEFAULT) + try check(posix_spawnattr_setflags(&attributes, flags)) + + let executablePath = executableURL.path + let argumentStrings = [executablePath] + arguments + let mergedEnvironment = ProcessInfo.processInfo.environment + .merging(environment) { _, newValue in newValue } + let environmentStrings = + mergedEnvironment + .map { "\($0.key)=\($0.value)" } + .sorted() + var argumentPointers = try makeCStringArray(argumentStrings) + defer { freeCStringArray(&argumentPointers) } + var environmentPointers = try makeCStringArray( + environmentStrings + ) + defer { freeCStringArray(&environmentPointers) } + + var processIdentifier: pid_t = 0 + let spawnResult = argumentPointers.withUnsafeMutableBufferPointer { + argumentBuffer in + environmentPointers.withUnsafeMutableBufferPointer { + environmentBuffer in + executablePath.withCString { path in + posix_spawn( + &processIdentifier, + path, + &fileActions, + &attributes, + argumentBuffer.baseAddress, + environmentBuffer.baseAddress + ) + } + } + } + try check(spawnResult) + return processIdentifier + } + + private static func makeCStringArray( + _ strings: [String] + ) throws -> [UnsafeMutablePointer?] { + var result: [UnsafeMutablePointer?] = [] + result.reserveCapacity(strings.count + 1) + for string in strings { + guard let pointer = strdup(string) else { + freeCStringArray(&result) + throw posixError(ENOMEM) + } + result.append(pointer) + } + result.append(nil) + return result + } + + private static func freeCStringArray( + _ pointers: inout [UnsafeMutablePointer?] + ) { + for pointer in pointers { + free(pointer) + } + pointers.removeAll(keepingCapacity: false) + } + + private static func check(_ result: Int32) throws { + guard result == 0 else { + throw posixError(result) + } + } + + private static func posixError(_ code: Int32) -> Error { + NSError( + domain: NSPOSIXErrorDomain, + code: Int(code), + userInfo: nil + ) + } } public struct GlossHomebrewCaskRelease: Equatable, Sendable { diff --git a/Tests/GlossCoreTests/GlossHomebrewUpgradeTests.swift b/Tests/GlossCoreTests/GlossHomebrewUpgradeTests.swift index 1343b25..10d0279 100644 --- a/Tests/GlossCoreTests/GlossHomebrewUpgradeTests.swift +++ b/Tests/GlossCoreTests/GlossHomebrewUpgradeTests.swift @@ -1,4 +1,5 @@ import CryptoKit +import Darwin import Foundation import Testing @@ -750,6 +751,218 @@ struct GlossHomebrewUpgradeTests { } } + @Test( + "command timeout kills descendants that ignore TERM and hold output" + ) + func commandTimeoutKillsProcessGroup() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let processIdentifierURL = directory.appendingPathComponent( + "timeout-child.pid" + ) + var fixtureIdentity: ProcessGroupFixtureIdentity? + defer { + if let fixtureIdentity { + cleanup(fixtureIdentity) + } + } + + let clock = ContinuousClock() + let startedAt = clock.now + let command = Task { + try await GlossCommandRunner.live.run( + executableURL: URL(fileURLWithPath: "/usr/bin/python3"), + arguments: [ + "-c", + Self.processGroupFixture, + processIdentifierURL.path, + "wait", + ], + timeout: .seconds(1) + ) + } + defer { command.cancel() } + fixtureIdentity = try await waitForProcessIdentity( + at: processIdentifierURL + ) + await #expect(throws: GlossCommandRunnerError.self) { + try await command.value + } + let elapsed = startedAt.duration(to: clock.now) + + #expect(elapsed < .seconds(3)) + let childExited = await waitForProcessToExit( + fixtureIdentity! + ) + #expect(childExited) + if childExited { + fixtureIdentity = nil + } + } + + @Test( + "normal leader exit cleans descendants that keep output handles open" + ) + func commandCompletionCleansOutputHoldingDescendants() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let processIdentifierURL = directory.appendingPathComponent( + "completed-child.pid" + ) + var fixtureIdentity: ProcessGroupFixtureIdentity? + defer { + if let fixtureIdentity { + cleanup(fixtureIdentity) + } + } + + let clock = ContinuousClock() + let startedAt = clock.now + let command = Task { + try await GlossCommandRunner.live.run( + executableURL: URL(fileURLWithPath: "/usr/bin/python3"), + arguments: [ + "-c", + Self.processGroupFixture, + processIdentifierURL.path, + "exit", + ], + timeout: .seconds(3) + ) + } + defer { command.cancel() } + fixtureIdentity = try await waitForProcessIdentity( + at: processIdentifierURL + ) + let output = try await command.value + let elapsed = startedAt.duration(to: clock.now) + #expect(output.terminationStatus == 0) + #expect( + String(decoding: output.standardOutput, as: UTF8.self) + .contains("child-ready") + ) + #expect(elapsed < .seconds(2)) + let childExited = await waitForProcessToExit( + fixtureIdentity! + ) + #expect(childExited) + if childExited { + fixtureIdentity = nil + } + } + + @Test("command cancellation kills its entire process group") + func commandCancellationKillsProcessGroup() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let processIdentifierURL = directory.appendingPathComponent( + "cancelled-child.pid" + ) + var fixtureIdentity: ProcessGroupFixtureIdentity? + defer { + if let fixtureIdentity { + cleanup(fixtureIdentity) + } + } + + let command = Task { + try await GlossCommandRunner.live.run( + executableURL: URL(fileURLWithPath: "/usr/bin/python3"), + arguments: [ + "-c", + Self.processGroupFixture, + processIdentifierURL.path, + "wait", + ] + ) + } + fixtureIdentity = try await waitForProcessIdentity( + at: processIdentifierURL + ) + command.cancel() + + await #expect(throws: CancellationError.self) { + try await command.value + } + let childExited = await waitForProcessToExit( + fixtureIdentity! + ) + #expect(childExited) + if childExited { + fixtureIdentity = nil + } + } + + @Test("continuous output still observes the command timeout") + func continuousOutputStillTimesOut() async { + await #expect( + throws: GlossCommandRunnerError.timedOut( + executablePath: "/usr/bin/python3" + ) + ) { + try await GlossCommandRunner.live.run( + executableURL: URL(fileURLWithPath: "/usr/bin/python3"), + arguments: [ + "-c", + """ + import os + import time + while True: + os.write(1, b"x" * 1024) + time.sleep(0.005) + """, + ], + timeout: .milliseconds(100) + ) + } + } + + @Test("unbounded command output fails at the capture limit") + func commandOutputLimitIsEnforced() async { + await #expect( + throws: GlossCommandRunnerError.outputLimitExceeded( + executablePath: "/usr/bin/python3", + maximumByteCount: + GlossCommandRunner.maximumCapturedOutputByteCount + ) + ) { + try await GlossCommandRunner.live.run( + executableURL: URL(fileURLWithPath: "/usr/bin/python3"), + arguments: [ + "-c", + """ + import os + chunk = b"x" * 65536 + while True: + os.write(1, chunk) + """, + ], + timeout: .seconds(5) + ) + } + } + + @Test("short commands complete without process-group cleanup delay") + func shortCommandsCompleteWithoutCleanupDelay() async throws { + let clock = ContinuousClock() + let startedAt = clock.now + + for value in 0..<64 { + let output = try await GlossCommandRunner.live.run( + executableURL: URL(fileURLWithPath: "/usr/bin/printf"), + arguments: ["%d", String(value)], + timeout: .seconds(1) + ) + #expect(output.terminationStatus == 0) + #expect( + String(decoding: output.standardOutput, as: UTF8.self) + == String(value) + ) + } + + #expect(startedAt.duration(to: clock.now) < .seconds(3)) + } + @Test("launcher stages an independent helper and canonical request") func launcherStagesHelper() async throws { let directory = temporaryDirectory() @@ -959,6 +1172,102 @@ struct GlossHomebrewUpgradeTests { return url } + private static let processGroupFixture = """ + import os + import signal + import sys + import time + + pid_path = sys.argv[1] + child = os.fork() + if child == 0: + signal.signal(signal.SIGTERM, signal.SIG_IGN) + with open(pid_path, "w") as pid_file: + pid_file.write(f"{os.getpid()} {os.getpgrp()}") + print("child-ready", flush=True) + while True: + time.sleep(1) + + while not os.path.exists(pid_path): + time.sleep(0.01) + if sys.argv[2] == "exit": + os._exit(0) + while True: + time.sleep(1) + """ + + private enum ProcessGroupFixtureError: Error { + case didNotStart + } + + private struct ProcessGroupFixtureIdentity { + let childProcessIdentifier: pid_t + let processGroupIdentifier: pid_t + let processStartTime: Double + } + + private func waitForProcessIdentity( + at url: URL, + timeout: Duration = .seconds(2) + ) async throws -> ProcessGroupFixtureIdentity { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if let data = try? Data(contentsOf: url), + let value = String(data: data, encoding: .utf8), + case let components = value.split(separator: " "), + components.count == 2, + let childIdentifier = pid_t(components[0]), + let groupIdentifier = pid_t(components[1]), + let processStartTime = + BabelDOCServiceSession.processStartTime( + childIdentifier + ) + { + return ProcessGroupFixtureIdentity( + childProcessIdentifier: childIdentifier, + processGroupIdentifier: groupIdentifier, + processStartTime: processStartTime + ) + } + try await Task.sleep(for: .milliseconds(20)) + } + throw ProcessGroupFixtureError.didNotStart + } + + private func cleanup(_ identity: ProcessGroupFixtureIdentity) { + guard + Darwin.kill(identity.childProcessIdentifier, 0) == 0, + getpgid(identity.childProcessIdentifier) + == identity.processGroupIdentifier, + BabelDOCServiceSession.processStartTime( + identity.childProcessIdentifier + ) == identity.processStartTime + else { + return + } + _ = Darwin.kill(-identity.processGroupIdentifier, SIGKILL) + } + + private func waitForProcessToExit( + _ identity: ProcessGroupFixtureIdentity, + timeout: Duration = .seconds(2) + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if BabelDOCServiceSession.processStartTime( + identity.childProcessIdentifier + ) != identity.processStartTime { + return true + } + try? await Task.sleep(for: .milliseconds(20)) + } + return BabelDOCServiceSession.processStartTime( + identity.childProcessIdentifier + ) != identity.processStartTime + } + private static func infoJSON( version: String, assetURL: URL? = nil, From 91502adc4b56f5815dc6016db0b93458553ea6c1 Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Mon, 27 Jul 2026 01:46:37 -0700 Subject: [PATCH 8/9] fix: align packaged capabilities with macOS delivery --- .github/workflows/release.yml | 6 ++ README.md | 38 +++++--- Resources/Info.plist | 39 +-------- Scripts/build_app.sh | 61 ++++++++++--- Scripts/generate_homebrew_cask.sh | 4 +- Scripts/validate_homebrew_cask.sh | 5 +- Sources/Gloss/GlossAppDelegate.swift | 12 ++- Sources/Gloss/PairingTokenStore.swift | 23 +++-- Sources/Gloss/SettingsWindowController.swift | 26 ++++-- Sources/GlossCore/GlossCLIInvocation.swift | 15 ++-- .../GlossCore/GlossCapabilityRegistry.swift | 68 +++++++++++++-- .../GlossCore/GlossDistributionProfile.swift | 86 +++++++++++++++++++ .../GlossCapabilityRegistryTests.swift | 37 ++++++++ .../GlossDistributionProfileTests.swift | 74 ++++++++++++++++ docs/release-notes/v0.8.3.md | 11 ++- docs/runtime-distribution.md | 14 +-- 16 files changed, 412 insertions(+), 107 deletions(-) create mode 100644 Sources/GlossCore/GlossDistributionProfile.swift create mode 100644 Tests/GlossCoreTests/GlossDistributionProfileTests.swift diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc554aa..a29cee1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,6 +108,12 @@ jobs: EXPECTED_ARCHITECTURE: ${{ matrix.architecture }} run: | lipo dist/Gloss.app/Contents/MacOS/Gloss -verify_arch "$EXPECTED_ARCHITECTURE" + test ! -e "dist/Gloss.app/Contents/PlugIns/Gloss Extension.appex" + test "$( + /usr/libexec/PlistBuddy \ + -c 'Print :GlossSafariExtensionAvailable' \ + dist/Gloss.app/Contents/Info.plist + )" = "false" codesign --verify --deep --strict --verbose=2 dist/Gloss.app codesign --display --verbose=4 dist/Gloss.app 2>&1 \ | grep -F "Signature=adhoc" diff --git a/README.md b/README.md index 4f92083..a00af03 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Gloss Gloss 是一款 macOS 原生翻译工具。当前默认产品面聚焦两条已经闭环的业务场景: -Safari/Chrome 浏览器翻译,以及保留版式的 PDF 批量翻译。 +Chrome 浏览器翻译(Apple 签名构建同时支持 Safari),以及保留版式的 PDF 批量翻译。 ## 核心能力与业务场景 @@ -11,10 +11,11 @@ Gloss 不再按窗口堆叠功能,而是由可复用核心能力拼装业务 | 默认场景 | 组合的主要能力 | macOS 入口 | CLI 映射 | | --- | --- | --- | --- | -| 浏览器翻译 | 文本翻译、Provider/语言路由、本机回环桥接、Safari/Chrome 适配 | Safari 与 Chrome 扩展 | `gloss-cli browser` | +| 浏览器翻译 | 文本翻译、Provider/语言路由、本机回环桥接、Chrome 适配与 Apple 签名构建的 Safari 适配 | Chrome 扩展;Apple 签名构建另含 Safari 扩展 | `gloss-cli browser` | | PDF 翻译 | 文档翻译、版面分析、翻译桥接、BabelDOC runtime、批量队列、PDF 导出 | PDF 模块、Finder 打开与拖拽 | `gloss-cli pdf` | -可使用 `gloss-cli capabilities --json` 获取稳定、机器可读的核心能力、启用场景和命令映射。 +可使用 `gloss-cli capabilities --json` 获取稳定、机器可读的核心能力、启用场景和命令映射; +结果会反映当前分发包实际可用的浏览器适配器。 剪贴板、OCR/截图、系统选区、术语表和历史记录的实现仍保留在代码中,但默认不启动相关监听, 也不在主菜单和设置中展示。 @@ -31,7 +32,7 @@ Gloss 不再按窗口堆叠功能,而是由可复用核心能力拼装业务 - 本地选项使用 `Hy-MT2-1.8B-GGUF:Q4_K_M`,通过 Metal 运行,原文和译文都留在设备上 - 使用结构化输出、只读沙盒和禁用工具的临时线程 - 译文与原文在同一结果卡片中直接对照,并支持复制译文、替换原文和追加双语 -- 提供分开的 macOS 文本与图片“服务”入口,避免被系统归入错误分类 +- 保留 macOS 文本与图片“服务”的处理实现,但当前聚焦发行不向系统注册入口 - 可翻译剪贴板图片、交互式截图及系统“服务”传入的图片或图片文件 - 截图写入权限隔离的临时目录并在读取后立即删除,不占用系统剪贴板 - 复制式选区与替换回退会恢复普通剪贴板;遇到密码管理器、临时内容、文件承诺或超大内容时不执行破坏性回退 @@ -44,8 +45,8 @@ Gloss 不再按窗口堆叠功能,而是由可复用核心能力拼装业务 - 缓存重复内容,并合并并发的相同请求 - 默认使用低延迟的 `gpt-5.3-codex-spark`,最多并发执行 3 个独立翻译 turn;每次完成后回滚该 turn,避免跨批上下文累积 - 内置只监听 `127.0.0.1` 的浏览器桥接,与扩展共享同一个翻译代理和缓存 -- Chrome 扩展与 Safari Web Extension 均随 `Gloss.app` 打包,共用 WXT 源码 -- 使用每机随机令牌鉴权:Chrome 自动注入 App 管理副本,Safari 通过 App Group 安全配对 +- Chrome 扩展与 Safari Web Extension 共用 WXT 源码;Safari 入口仅在 Apple 签名构建中启用 +- 使用每机随机令牌鉴权:Chrome 自动注入 App 管理副本,Apple 签名构建中的 Safari 通过 App Group 安全配对 - 提供 `gloss-cli` 作为脚本与诊断入口 ## 运行日志 @@ -70,12 +71,13 @@ tail -f ~/Library/Logs/Gloss/gloss.log 2. 本地模型:安装 `llama.cpp`(`brew install llama.cpp`),然后在翻译引擎中选择“本地模型”。首次启动会从 Hugging Face 下载约 1.1 GB 的 Q4 模型。 3. 重新启用选区翻译场景后,首次使用时需在系统设置中允许 Gloss 使用“辅助功能”。 4. Chrome:在 Gloss 设置中点“显示扩展”,从 `chrome://extensions` 加载这个已自动配对的目录。 -5. Safari:在 Gloss 设置中点“Safari 设置”,启用随 App 内置的 Gloss Extension。 +5. Safari(仅 Apple 签名构建):在 Gloss 设置中点“Safari 设置”,启用随 App 内置的 + Gloss Extension。Homebrew 的 ad-hoc 构建不会显示这个入口。 Gloss 的登录状态和 Codex 配置保存在 `~/Library/Application Support/Gloss/Codex/`,不会修改系统 Codex CLI 的数据。 -重新启用剪贴板或图片翻译场景后,系统“服务”入口仍由 macOS 管理,可在“系统设置 › -键盘 › 键盘快捷键 › 服务”中启用对应入口。 +剪贴板或图片翻译场景的处理代码仍保留,但当前构建不会注册系统“服务”;重新发布这些场景时 +需要同步恢复对应的 `NSServices` 构建配置。 ## 开发 @@ -174,7 +176,12 @@ 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`。脚本默认使用 `-` 做 ad-hoc codesign;这种签名没有 Apple 开发者身份,Safari 配对不可用。 +构建脚本会按 `CodexRuntime.lock` 下载并校验固定版本的官方 Rust app-server,把它与许可证一起 +嵌入 App;本地 provider 当前复用系统安装的 `llama-server`。随后脚本在相邻的 +`personal-immersive-translator` 仓库中生成 Chrome 产物。只有显式传入 Apple 签名身份以及 +宿主与扩展的 provisioning profile 时,才会同时构建并嵌入 Safari `.appex`。结果位于 +`dist/Gloss.app`。脚本默认使用 `-` 做 ad-hoc codesign,因此不会把无法完成身份配对的 +Safari 扩展放进 App。 如果不希望下载或嵌入固定 Rust app-server,可构建依赖用户 Codex CLI 的轻量版本: @@ -184,10 +191,14 @@ 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 证书: +本机调试 Safari 配对时,可显式传入钥匙串中的 Apple Development 证书,以及允许 +`group.com.samsoncj.gloss` App Group 的宿主和扩展 provisioning profile: ```bash -GLOSS_SIGN_IDENTITY="Apple Development: Your Name (TEAMID)" ./Scripts/build_app.sh +GLOSS_SIGN_IDENTITY="Apple Development: Your Name (TEAMID)" \ +GLOSS_SAFARI_HOST_PROVISIONING_PROFILE="/path/to/Gloss.provisionprofile" \ +GLOSS_SAFARI_EXTENSION_PROVISIONING_PROFILE="/path/to/Gloss-Extension.provisionprofile" \ +./Scripts/build_app.sh ``` 当前公开 Homebrew 发行也明确使用 ad-hoc 签名,不要求 Developer ID 或 Apple 公证。 @@ -228,6 +239,9 @@ Cask 的 `postflight` 会重新 ad-hoc 签名、移除 quarantine 并验证签 Gatekeeper 交互;这也意味着 macOS 无法验证 Apple 开发者身份或公证票据。公开仓库初始化、 fine-grained token 权限、完整安全取舍、发行顺序与恢复步骤见 [发行文档](docs/runtime-distribution.md)。 +Homebrew 构建的能力报告会启用 Chrome、关闭 Safari;Safari App Extension 仅在使用 Apple +Development 或 distribution identity 且 App Group entitlement 可用的构建中进入能力注册表 +和设置页;运行时会以系统返回的 App Group container 作为最终配对条件。 正式版 App 启动后会延迟、静默检查签名 manifest,并以 24 小时为自动检查间隔。只有确认 当前 `Gloss.app` 由 `sunchj/tap/gloss` 管理时,界面才提供“一键更新并重新启动”;独立 helper diff --git a/Resources/Info.plist b/Resources/Info.plist index cf7e378..58ae526 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -22,6 +22,8 @@ 0.8.3 CFBundleVersion 11 + GlossSafariExtensionAvailable + CFBundleDocumentTypes @@ -37,43 +39,6 @@ - NSServices - - - NSMenuItem - - default - 使用 Gloss 翻译 - - NSMessage - translateTextWithGloss - NSPortName - Gloss - NSSendTypes - - public.utf8-plain-text - - - - NSMenuItem - - default - 使用 Gloss 翻译图片 - - NSMessage - translateImageWithGloss - NSPortName - Gloss - NSSendTypes - - public.image - - NSSendFileTypes - - public.image - - - LSApplicationCategoryType public.app-category.productivity LSMinimumSystemVersion diff --git a/Scripts/build_app.sh b/Scripts/build_app.sh index b9b1d06..0feed6a 100755 --- a/Scripts/build_app.sh +++ b/Scripts/build_app.sh @@ -41,19 +41,33 @@ case "$CODEX_RUNTIME_MODE" in ;; esac SIGN_IDENTITY="${GLOSS_SIGN_IDENTITY:--}" +SAFARI_HOST_PROFILE="${GLOSS_SAFARI_HOST_PROVISIONING_PROFILE:-}" +SAFARI_EXTENSION_PROFILE="${GLOSS_SAFARI_EXTENSION_PROVISIONING_PROFILE:-}" + +if [[ "$SIGN_IDENTITY" != "-" ]]; then + if [[ ! -f "$SAFARI_HOST_PROFILE" || ! -f "$SAFARI_EXTENSION_PROFILE" ]]; then + echo "Apple-signed Safari builds require host and extension provisioning profiles." >&2 + echo "Set GLOSS_SAFARI_HOST_PROVISIONING_PROFILE and GLOSS_SAFARI_EXTENSION_PROVISIONING_PROFILE." >&2 + exit 1 + fi + /usr/bin/security cms -D -i "$SAFARI_HOST_PROFILE" >/dev/null + /usr/bin/security cms -D -i "$SAFARI_EXTENSION_PROFILE" >/dev/null +fi if [[ ! -x "$PLUGIN_DIR/node_modules/.bin/wxt" ]]; then npm --prefix "$PLUGIN_DIR" ci fi npm --prefix "$PLUGIN_DIR" run build -xcodebuild \ - -project "$SAFARI_PROJECT" \ - -scheme Gloss \ - -configuration Release \ - -derivedDataPath "$SAFARI_BUILD_DIR" \ - CODE_SIGNING_ALLOWED=NO \ - build \ - -quiet +if [[ "$SIGN_IDENTITY" != "-" ]]; then + xcodebuild \ + -project "$SAFARI_PROJECT" \ + -scheme Gloss \ + -configuration Release \ + -derivedDataPath "$SAFARI_BUILD_DIR" \ + CODE_SIGNING_ALLOWED=NO \ + build \ + -quiet +fi cd "$ROOT_DIR" swift build -c release @@ -64,7 +78,7 @@ if [[ ! -f "$BROWSER_EXTENSION_DIR/manifest.json" ]]; then echo "Browser extension not found: $BROWSER_EXTENSION_DIR" >&2 exit 1 fi -if [[ ! -d "$SAFARI_EXTENSION" ]]; then +if [[ "$SIGN_IDENTITY" != "-" && ! -d "$SAFARI_EXTENSION" ]]; then echo "Safari extension not found: $SAFARI_EXTENSION" >&2 exit 1 fi @@ -74,6 +88,20 @@ install -m 755 "$BIN_DIR/Gloss" "$MACOS_DIR/Gloss" install -m 755 "$BIN_DIR/gloss-cli" "$HELPERS_DIR/gloss-cli" install -m 755 "$BIN_DIR/gloss-update-helper" "$HELPERS_DIR/gloss-update-helper" install -m 644 "$ROOT_DIR/Resources/Info.plist" "$CONTENTS_DIR/Info.plist" +if [[ "$SIGN_IDENTITY" == "-" ]]; then + /usr/libexec/PlistBuddy \ + -c "Set :GlossSafariExtensionAvailable false" \ + "$CONTENTS_DIR/Info.plist" +else + /usr/libexec/PlistBuddy \ + -c "Set :GlossSafariExtensionAvailable true" \ + "$CONTENTS_DIR/Info.plist" +fi +if [[ "$SIGN_IDENTITY" != "-" ]]; then + install -m 644 \ + "$SAFARI_HOST_PROFILE" \ + "$CONTENTS_DIR/embedded.provisionprofile" +fi install -m 644 "$ROOT_DIR/Resources/Gloss.icns" "$RESOURCES_DIR/Gloss.icns" if [[ "$CODEX_RUNTIME_MODE" == "bundled" ]]; then install -m 755 "$CODEX_RUNTIME" "$HELPERS_DIR/gloss-codex-app-server" @@ -81,16 +109,25 @@ if [[ "$CODEX_RUNTIME_MODE" == "bundled" ]]; then install -m 644 "$ROOT_DIR/CodexRuntime.lock" "$RESOURCES_DIR/CodexRuntime.lock" fi /usr/bin/ditto "$BROWSER_EXTENSION_DIR" "$RESOURCES_DIR/BrowserExtension" -/usr/bin/ditto "$SAFARI_EXTENSION" "$PLUGINS_DIR/Gloss Extension.appex" +if [[ "$SIGN_IDENTITY" != "-" ]]; then + /usr/bin/ditto "$SAFARI_EXTENSION" "$PLUGINS_DIR/Gloss Extension.appex" + install -m 644 \ + "$SAFARI_EXTENSION_PROFILE" \ + "$PLUGINS_DIR/Gloss Extension.appex/Contents/embedded.provisionprofile" +fi SIGN_ARGS=(--force --sign "$SIGN_IDENTITY") if [[ "$SIGN_IDENTITY" != "-" ]]; then SIGN_ARGS+=(--options runtime --timestamp) else - echo "Warning: Safari pairing requires an Apple Development or distribution signature." >&2 + echo "Safari extension omitted: pairing requires an Apple signing identity." >&2 fi -codesign "${SIGN_ARGS[@]}" --entitlements "$SAFARI_ENTITLEMENTS" "$PLUGINS_DIR/Gloss Extension.appex" +if [[ "$SIGN_IDENTITY" != "-" ]]; then + codesign "${SIGN_ARGS[@]}" \ + --entitlements "$SAFARI_ENTITLEMENTS" \ + "$PLUGINS_DIR/Gloss Extension.appex" +fi if [[ "$CODEX_RUNTIME_MODE" == "bundled" ]]; then codesign "${SIGN_ARGS[@]}" "$HELPERS_DIR/gloss-codex-app-server" fi diff --git a/Scripts/generate_homebrew_cask.sh b/Scripts/generate_homebrew_cask.sh index 0d8b098..45eb6ae 100755 --- a/Scripts/generate_homebrew_cask.sh +++ b/Scripts/generate_homebrew_cask.sh @@ -83,8 +83,7 @@ cask "gloss" do postflight do app_path = "#{appdir}/Gloss.app" - extension_path = "#{app_path}/Contents/PlugIns/Gloss Extension.appex" - entitlement_paths = [extension_path, app_path] + entitlement_paths = [app_path] entitlements_before = entitlement_paths.map do |code_path| system_command("/usr/bin/codesign", args: ["--display", "--entitlements", "-", code_path], @@ -93,7 +92,6 @@ cask "gloss" do 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}/Contents/Helpers/gloss-update-helper", diff --git a/Scripts/validate_homebrew_cask.sh b/Scripts/validate_homebrew_cask.sh index c815c7b..7d5440a 100755 --- a/Scripts/validate_homebrew_cask.sh +++ b/Scripts/validate_homebrew_cask.sh @@ -29,7 +29,6 @@ required = [ /^ 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}, %r{#\{app_path\}/Contents/Helpers/gloss-update-helper}, @@ -48,11 +47,13 @@ required = [ ] missing = required.reject { |pattern| content.match?(pattern) } abort "Cask is missing required declarations: #{missing.join(", ")}" unless missing.empty? +if content.include?("Gloss Extension.appex") + abort "Ad-hoc Homebrew cask must not contain a Safari App Extension" +end 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}/Contents/Helpers/gloss-update-helper",', diff --git a/Sources/Gloss/GlossAppDelegate.swift b/Sources/Gloss/GlossAppDelegate.swift index 8322e47..71afa97 100644 --- a/Sources/Gloss/GlossAppDelegate.swift +++ b/Sources/Gloss/GlossAppDelegate.swift @@ -325,8 +325,10 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { controller.onRevealBrowserExtension = { [weak self] in self?.revealBrowserExtension() } - controller.onOpenSafariExtensionSettings = { [weak self] in - self?.openSafariExtensionSettings() + if capabilityRegistry.supports(.safariExtension) { + controller.onOpenSafariExtensionSettings = { [weak self] in + self?.openSafariExtensionSettings() + } } controller.onBridgeAction = { [weak self] in self?.performBridgeDashboardAction() @@ -1844,9 +1846,13 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } @objc private func showAbout() { + let browserSupport = + capabilityRegistry.supports(.safariExtension) + ? "Safari、Chrome 页面翻译" + : "Chrome 页面翻译" showAlert( title: "Gloss", - message: "浏览器与 PDF 翻译\n\n支持 Safari、Chrome 页面翻译与批量 PDF 翻译。" + message: "浏览器与 PDF 翻译\n\n支持 \(browserSupport)与批量 PDF 翻译。" ) } diff --git a/Sources/Gloss/PairingTokenStore.swift b/Sources/Gloss/PairingTokenStore.swift index 062c862..2a0eff5 100644 --- a/Sources/Gloss/PairingTokenStore.swift +++ b/Sources/Gloss/PairingTokenStore.swift @@ -1,4 +1,5 @@ import Foundation +import GlossCore import Security enum PairingTokenStore { @@ -40,18 +41,24 @@ enum PairingTokenStore { } private static func shareWithSafari(_ token: String) { - guard Bundle.main.url( - forResource: "embedded", - withExtension: "provisionprofile" - ) != nil else { return } + guard GlossDistributionProfile.current.safariExtensionAvailable else { + return + } let fileManager = FileManager.default - guard let directory = fileManager.containerURL( - forSecurityApplicationGroupIdentifier: safariAppGroup - ) else { return } + guard + let directory = fileManager.containerURL( + forSecurityApplicationGroupIdentifier: safariAppGroup + ) + else { + return + } let tokenURL = directory.appendingPathComponent(fileName, isDirectory: false) try? Data(token.utf8).write(to: tokenURL, options: .atomic) - try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tokenURL.path) + try? fileManager.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: tokenURL.path + ) } private static func applicationSupportDirectory() throws -> URL { diff --git a/Sources/Gloss/SettingsWindowController.swift b/Sources/Gloss/SettingsWindowController.swift index 7e0be01..3df6ec3 100644 --- a/Sources/Gloss/SettingsWindowController.swift +++ b/Sources/Gloss/SettingsWindowController.swift @@ -647,16 +647,24 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel target: self, action: #selector(copyBrowserToken) ) - let safariButton = NSButton( - title: "Safari 设置", - target: self, - action: #selector(openSafariExtensionSettings) - ) - for button in [safariButton, revealExtensionButton, tokenButton] { + for button in [revealExtensionButton, tokenButton] { button.controlSize = .small } + var browserButtonViews: [NSView] = [ + revealExtensionButton, + tokenButton, + ] + if capabilityRegistry.supports(.safariExtension) { + let safariButton = NSButton( + title: "Safari 设置", + target: self, + action: #selector(openSafariExtensionSettings) + ) + safariButton.controlSize = .small + browserButtonViews.insert(safariButton, at: 0) + } let browserButtons = NSStackView( - views: [safariButton, revealExtensionButton, tokenButton] + views: browserButtonViews ) browserButtons.orientation = .horizontal browserButtons.alignment = .centerY @@ -710,7 +718,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel case (true, true): return "浏览器与 PDF 翻译" case (true, false): - return "Safari 与 Chrome 翻译" + return capabilityRegistry.supports(.safariExtension) + ? "Safari 与 Chrome 翻译" + : "Chrome 翻译" case (false, true): return "批量 PDF 翻译" case (false, false): diff --git a/Sources/GlossCore/GlossCLIInvocation.swift b/Sources/GlossCore/GlossCLIInvocation.swift index 9c79af2..81db844 100644 --- a/Sources/GlossCore/GlossCLIInvocation.swift +++ b/Sources/GlossCore/GlossCLIInvocation.swift @@ -375,19 +375,20 @@ public struct GlossCapabilitiesReport: Codable, Equatable, Sendable { registry: GlossCapabilityRegistry = .current ) { schemaVersion = 1 - availableCoreCapabilities = - GlossCapabilityRegistry.coreExecutionCapabilities.sorted { - $0.rawValue < $1.rawValue - } - enabledCoreCapabilities = registry.enabledCoreCapabilities.sorted { + availableCoreCapabilities = registry.availableCoreCapabilities.sorted { $0.rawValue < $1.rawValue } - enabledCapabilities = registry.enabledCapabilities.sorted { + enabledCoreCapabilities = registry.enabledCoreCapabilities.sorted { $0.rawValue < $1.rawValue } - availableScenarios = GlossBusinessScenario.allCases.sorted { + enabledCapabilities = registry.enabledCapabilities.sorted { $0.rawValue < $1.rawValue } + availableScenarios = GlossBusinessScenario.allCases + .filter(registry.isAvailable) + .sorted { + $0.rawValue < $1.rawValue + } enabledScenarios = registry.enabledScenarios.sorted { $0.rawValue < $1.rawValue } diff --git a/Sources/GlossCore/GlossCapabilityRegistry.swift b/Sources/GlossCore/GlossCapabilityRegistry.swift index 4b18895..1bc4902 100644 --- a/Sources/GlossCore/GlossCapabilityRegistry.swift +++ b/Sources/GlossCore/GlossCapabilityRegistry.swift @@ -156,6 +156,11 @@ public struct GlossCLICommandMapping: Codable, Equatable, Sendable { } public struct GlossCapabilityRegistry: Equatable, Sendable { + private static let browserAdapterCapabilities: Set = [ + .chromeExtension, + .safariExtension, + ] + /// The focused product surface. Secondary scenarios remain defined and can be /// restored by constructing a registry with a larger set. public static let defaultEnabledScenarios: Set = [ @@ -190,19 +195,39 @@ public struct GlossCapabilityRegistry: Equatable, Sendable { .pdfExport, ] - public static let current = GlossCapabilityRegistry() + public static let current = GlossCapabilityRegistry( + distributionProfile: .current + ) - public let enabledScenarios: Set + private let configuredScenarios: Set + public let unavailableCapabilities: Set public init( - enabledScenarios: Set = Self.defaultEnabledScenarios + enabledScenarios: Set = Self.defaultEnabledScenarios, + distributionProfile: GlossDistributionProfile = .current ) { - self.enabledScenarios = enabledScenarios + configuredScenarios = enabledScenarios + unavailableCapabilities = + distributionProfile.safariExtensionAvailable + ? [] + : [.safariExtension] + } + + public var enabledScenarios: Set { + configuredScenarios.filter(isAvailable) + } + + public var availableCoreCapabilities: Set { + Self.coreExecutionCapabilities.subtracting(unavailableCapabilities) } public var enabledCapabilities: Set { - enabledScenarios.reduce(into: Self.infrastructureCapabilities) { - $0.formUnion($1.requiredCapabilities) + enabledScenarios.reduce( + into: Self.infrastructureCapabilities.subtracting( + unavailableCapabilities + ) + ) { + $0.formUnion(availableCapabilities(for: $1)) } } @@ -217,7 +242,7 @@ public struct GlossCapabilityRegistry: Equatable, Sendable { $0.rawValue < $1.rawValue } let scenarioCapabilities = - scenario?.requiredCapabilities.sorted { + scenario.map(availableCapabilities(for:))?.sorted { $0.rawValue < $1.rawValue } ?? [] return GlossCLICommandMapping( @@ -236,7 +261,34 @@ public struct GlossCapabilityRegistry: Equatable, Sendable { } public func isEnabled(_ scenario: GlossBusinessScenario) -> Bool { - enabledScenarios.contains(scenario) + configuredScenarios.contains(scenario) && isAvailable(scenario) + } + + public func isAvailable(_ scenario: GlossBusinessScenario) -> Bool { + let required = scenario.requiredCapabilities + let unavailableRequired = required.intersection( + unavailableCapabilities + ) + guard scenario == .browserTranslation else { + return unavailableRequired.isEmpty + } + + let unavailableBase = unavailableRequired.subtracting( + Self.browserAdapterCapabilities + ) + let availableAdapters = Self.browserAdapterCapabilities + .intersection(required) + .subtracting(unavailableCapabilities) + return unavailableBase.isEmpty && !availableAdapters.isEmpty + } + + public func availableCapabilities( + for scenario: GlossBusinessScenario + ) -> Set { + guard isAvailable(scenario) else { return [] } + return scenario.requiredCapabilities.subtracting( + unavailableCapabilities + ) } public func supports(_ capability: GlossCapability) -> Bool { diff --git a/Sources/GlossCore/GlossDistributionProfile.swift b/Sources/GlossCore/GlossDistributionProfile.swift new file mode 100644 index 0000000..359a2e9 --- /dev/null +++ b/Sources/GlossCore/GlossDistributionProfile.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Capabilities that depend on how the current Gloss build was distributed. +/// +/// Homebrew releases use an ad-hoc signature, which cannot pair a Safari App +/// Extension with its containing App. Apple-signed builds set the bundle flag +/// to keep Safari available. +public struct GlossDistributionProfile: Equatable, Sendable { + public let safariExtensionAvailable: Bool + + public init(safariExtensionAvailable: Bool) { + self.safariExtensionAvailable = safariExtensionAvailable + } + + public static let current = resolve() + + public static func resolve( + bundleValue: Bool? = Bundle.main.object( + forInfoDictionaryKey: "GlossSafariExtensionAvailable" + ) as? Bool, + executableURL: URL? = Bundle.main.executableURL, + workingDirectoryURL: URL = URL( + fileURLWithPath: FileManager.default.currentDirectoryPath, + isDirectory: true + ) + ) -> GlossDistributionProfile { + if let bundleValue { + return GlossDistributionProfile( + safariExtensionAvailable: bundleValue + ) + } + + if var candidate = executableURL?.resolvingSymlinksInPath() + .deletingLastPathComponent() + { + for _ in 0..<10 { + if candidate.pathExtension.lowercased() == "app", + let value = safariExtensionValue( + in: candidate.appendingPathComponent( + "Contents/Info.plist" + ) + ) + { + return GlossDistributionProfile( + safariExtensionAvailable: value + ) + } + if let value = safariExtensionValue( + in: candidate.appendingPathComponent( + "Resources/Info.plist" + ) + ) { + return GlossDistributionProfile( + safariExtensionAvailable: value + ) + } + let parent = candidate.deletingLastPathComponent() + guard parent.path != candidate.path else { break } + candidate = parent + } + } + + let checkoutValue = safariExtensionValue( + in: workingDirectoryURL.appendingPathComponent( + "Resources/Info.plist" + ) + ) + return GlossDistributionProfile( + safariExtensionAvailable: checkoutValue ?? false + ) + } + + private static func safariExtensionValue(in plistURL: URL) -> Bool? { + guard let data = try? Data(contentsOf: plistURL), + let value = try? PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil + ), + let dictionary = value as? [String: Any] + else { + return nil + } + return dictionary["GlossSafariExtensionAvailable"] as? Bool + } +} diff --git a/Tests/GlossCoreTests/GlossCapabilityRegistryTests.swift b/Tests/GlossCoreTests/GlossCapabilityRegistryTests.swift index 6c616bc..0a29919 100644 --- a/Tests/GlossCoreTests/GlossCapabilityRegistryTests.swift +++ b/Tests/GlossCoreTests/GlossCapabilityRegistryTests.swift @@ -22,6 +22,43 @@ final class GlossCapabilityRegistryTests: XCTestCase { XCTAssertTrue(registry.supports(.safariExtension)) } + func testAdHocDistributionKeepsBrowserScenarioWithChromeOnly() { + let registry = GlossCapabilityRegistry( + distributionProfile: GlossDistributionProfile( + safariExtensionAvailable: false + ) + ) + + XCTAssertTrue(registry.isEnabled(.browserTranslation)) + XCTAssertTrue(registry.supports(.browserBridge)) + XCTAssertTrue(registry.supports(.chromeExtension)) + XCTAssertFalse(registry.supports(.safariExtension)) + XCTAssertFalse( + registry.availableCoreCapabilities.contains(.safariExtension) + ) + + let browser = registry.commandMappings.first { + $0.command == .browser + } + XCTAssertEqual(browser?.enabled, true) + XCTAssertEqual( + browser?.scenarioCapabilities.contains(.chromeExtension), + true + ) + XCTAssertEqual( + browser?.scenarioCapabilities.contains(.safariExtension), + false + ) + + let report = GlossCapabilitiesReport(registry: registry) + XCTAssertTrue( + report.enabledScenarios.contains(.browserTranslation) + ) + XCTAssertFalse( + report.availableCoreCapabilities.contains(.safariExtension) + ) + } + func testDefaultPDFScenarioSupportsRuntimeAndBatchQueue() { let registry = GlossCapabilityRegistry() diff --git a/Tests/GlossCoreTests/GlossDistributionProfileTests.swift b/Tests/GlossCoreTests/GlossDistributionProfileTests.swift new file mode 100644 index 0000000..71a3709 --- /dev/null +++ b/Tests/GlossCoreTests/GlossDistributionProfileTests.swift @@ -0,0 +1,74 @@ +import Foundation +import XCTest + +@testable import GlossCore + +final class GlossDistributionProfileTests: XCTestCase { + func testExplicitBundleValueTakesPriority() { + let profile = GlossDistributionProfile.resolve( + bundleValue: false, + executableURL: nil + ) + + XCTAssertFalse(profile.safariExtensionAvailable) + } + + func testStandaloneHelperReadsEnclosingAppProfile() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "gloss-distribution-test-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let contents = root.appendingPathComponent( + "Gloss.app/Contents", + isDirectory: true + ) + let helper = contents.appendingPathComponent( + "Helpers/gloss-cli" + ) + try FileManager.default.createDirectory( + at: helper.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + XCTAssertTrue( + FileManager.default.createFile( + atPath: helper.path, + contents: Data() + ) + ) + let plist = try PropertyListSerialization.data( + fromPropertyList: [ + "GlossSafariExtensionAvailable": false + ], + format: .xml, + options: 0 + ) + try plist.write( + to: contents.appendingPathComponent("Info.plist") + ) + + let profile = GlossDistributionProfile.resolve( + bundleValue: nil, + executableURL: helper, + workingDirectoryURL: root + ) + + XCTAssertFalse(profile.safariExtensionAvailable) + } + + func testMissingDistributionFlagFailsClosed() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "gloss-missing-distribution-\(UUID().uuidString)", + isDirectory: true + ) + let profile = GlossDistributionProfile.resolve( + bundleValue: nil, + executableURL: nil, + workingDirectoryURL: root + ) + + XCTAssertFalse(profile.safariExtensionAvailable) + } +} diff --git a/docs/release-notes/v0.8.3.md b/docs/release-notes/v0.8.3.md index 8d42ed7..d06a1ea 100644 --- a/docs/release-notes/v0.8.3.md +++ b/docs/release-notes/v0.8.3.md @@ -7,12 +7,19 @@ scenario model available to automation, and closes the Homebrew update loop. - Composes user-facing workflows from a shared capability registry instead of starting every implemented feature. -- Enables Safari/Chrome browser translation and PDF translation by default. +- Enables Chrome browser translation and PDF translation by default; an + Apple-signed build also enables Safari. - Keeps clipboard, screenshot/OCR, system selection, glossary, and history implementations available in source without exposing their UI or starting their listeners. - Uses the same registry for App lifecycle decisions, menus, settings, external PDF-open requests, and CLI dispatch. +- Reads a signed-build capability flag so Homebrew's ad-hoc package does not + expose an unusable Safari settings entry or claim Safari in CLI output. +- Omits the Safari App Extension from ad-hoc/Homebrew artifacts so PlugInKit + cannot register an unusable system extension. +- Removes dormant text and image Services from the focused App bundle while + retaining their implementation in source. ## CLI automation @@ -61,7 +68,7 @@ scenario model available to automation, and closes the Homebrew update loop. ## Validation -- The complete test suite passes: 280 tests, 5 conditionally skipped, and no +- The complete test suite passes: 290 tests, 5 conditionally skipped, and no failures. - All three products (`Gloss`, `gloss-cli`, and `gloss-update-helper`) build. - A real CLI translation of the 15-page *Attention Is All You Need* PDF diff --git a/docs/runtime-distribution.md b/docs/runtime-distribution.md index 0cf0bbf..e0301af 100644 --- a/docs/runtime-distribution.md +++ b/docs/runtime-distribution.md @@ -230,16 +230,20 @@ Gloss 的 Actions 页面手工运行 Release,输入原 `release_tag` 并显式 - Cask 的下载 SHA-256 和公开 Release 的 `SHA256SUMS` 能证明实际下载内容与 tap 固定的内容 相同,但它们不能替代发布者身份签名;`gloss-releases`、`homebrew-tap` 或跨仓库 token 同时失守时,攻击者可能替换二进制与 checksum。 -- custom tap 的 `postflight` 按最深层优先顺序分别对 Safari `.appex`、Codex helper、CLI 和 +- custom tap 的 `postflight` 按最深层优先顺序分别对 Codex helper、CLI、更新 helper 和 最外层 `Gloss.app` 执行 `codesign --force --sign -`,不使用可能覆盖嵌套 entitlement 的 - `--deep --sign`;每一步都通过 + `--deep --sign`;Homebrew 资产不会包含无法配对的 Safari `.appex`。每一步都通过 `--preserve-metadata=identifier,entitlements,requirements,flags,runtime` 保留已有 metadata, - 并比较签名前后 App 与 `.appex` 的 entitlement bytes。随后只递归删除 + 并比较签名前后 App 的 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 身份构建。 +- ad-hoc 签名不能完成 Safari App Extension 与宿主 App 的 Apple 身份配对,因此构建脚本会 + 完全省略 Safari `.appex` 并把 Safari 标记为不可用;App、设置页与 + `gloss-cli capabilities --json` 都只声明 Chrome。需要 Safari 配对时仍应在本地使用 Apple + Development 或 distribution identity 构建,并确保签名允许 + `group.com.samsoncj.gloss` App Group。构建脚本要求分别提供宿主与扩展 provisioning + profile,并将它们嵌入对应 bundle;运行时只会向系统实际授予的 group container 写入令牌。 因此该 Cask 只适用于用户明确信任 `SunChJ/homebrew-tap` 和 `SunChJ/gloss-releases` 的自定义分发场景,不应被描述为 Apple 已签名或已公证的软件。 From 278b3f2164b5ebd6e62cc6cfb3b31d4808d889ff Mon Sep 17 00:00:00 2001 From: SamsonCJ Date: Mon, 27 Jul 2026 01:51:50 -0700 Subject: [PATCH 9/9] test: avoid process launch timing flake --- Tests/GlossCoreTests/GlossHomebrewUpgradeTests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tests/GlossCoreTests/GlossHomebrewUpgradeTests.swift b/Tests/GlossCoreTests/GlossHomebrewUpgradeTests.swift index 10d0279..8b554b3 100644 --- a/Tests/GlossCoreTests/GlossHomebrewUpgradeTests.swift +++ b/Tests/GlossCoreTests/GlossHomebrewUpgradeTests.swift @@ -947,7 +947,10 @@ struct GlossHomebrewUpgradeTests { let clock = ContinuousClock() let startedAt = clock.now - for value in 0..<64 { + // A smaller sample still catches the historical per-command cleanup + // delay while avoiding a wall-clock assertion dominated by process + // launch contention on shared CI runners. + for value in 0..<16 { let output = try await GlossCommandRunner.live.run( executableURL: URL(fileURLWithPath: "/usr/bin/printf"), arguments: ["%d", String(value)],