diff --git a/README.md b/README.md index 34a7a18..9907f15 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,10 @@ Gloss requires **macOS 14 or newer**. brew install --cask sunchj/tap/gloss ``` -Upgrade from the app or with: +Gloss checks the signed release channel after launch, at most once every 24 +hours. When an update is available it presents an actionable reminder; official +Homebrew installations can update and restart directly from that prompt. You +can also update manually with: ```bash brew update @@ -87,7 +90,11 @@ gloss-cli browser --target 'Chinese (Simplified)' 'Translate this webpage.' gloss-cli pdf paper-a.pdf paper-b.pdf \ --output ./translated \ --target 'Chinese (Simplified)' \ - --mode mono + --mode bilingual + +# Derive reusable reading copies from a bilingual master without translating again +./Scripts/export_pdf_side_by_side.sh translated/paper-a-gloss-dual.pdf +./Scripts/export_pdf_translation_only.sh translated/paper-a-gloss-dual.pdf # Translate plain text locally gloss-cli text --provider llama \ @@ -99,6 +106,12 @@ gloss-cli text --provider llama \ UI. `pdf` reports progress on stderr and writes the final artifact paths as JSON on stdout, which makes it suitable for scripts. +The bilingual PDF is the reusable master: source and translated pages alternate. +The two export scripts create a permanent left/right spread or a translation-only +PDF without another translation pass. They derive output names beside the input; +pass an explicit output path as the second argument, or `--force` to replace an +existing derivative. + ## Privacy and security - Local-model translation keeps source text and translations on the device. diff --git a/README.zh-CN.md b/README.zh-CN.md index dd37b4f..9660979 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -39,7 +39,8 @@ Gloss 要求 **macOS 14 或更高版本**。 brew install --cask sunchj/tap/gloss ``` -可以在 App 内升级,也可以运行: +Gloss 会在启动后检查签名发布通道,每 24 小时最多一次。发现新版本时会主动提醒; +官方 Homebrew 安装可以直接在提醒中点击“更新并重新启动”。也可以手动运行: ```bash brew update @@ -79,7 +80,11 @@ gloss-cli browser --target 'Chinese (Simplified)' 'Translate this webpage.' gloss-cli pdf paper-a.pdf paper-b.pdf \ --output ./translated \ --target 'Chinese (Simplified)' \ - --mode mono + --mode bilingual + +# 无需再次翻译,从双语母版快速派生阅读版本 +./Scripts/export_pdf_side_by_side.sh translated/paper-a-gloss-dual.pdf +./Scripts/export_pdf_translation_only.sh translated/paper-a-gloss-dual.pdf # 使用本地引擎翻译文本 gloss-cli text --provider llama \ @@ -90,6 +95,10 @@ gloss-cli text --provider llama \ `browser` 可以从参数或 stdin 读取正文,但不会操控浏览器 UI。`pdf` 将进度写入 stderr, 并以 JSON 形式将最终产物路径写入 stdout,适合用于自动化脚本。 +双语 PDF 是可复用的母版,其中原文页与译文页交替排列。两个导出脚本无需再次翻译, +即可生成永久左右并排版或仅译文版。默认输出到输入文件所在目录;第二个参数可以指定 +输出路径,已有派生文件需要使用 `--force` 才会覆盖。 + ## 隐私与安全 - 本地模型模式下,原文和译文都留在设备上。 diff --git a/Resources/Info.plist b/Resources/Info.plist index c9c9736..e3b948e 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.1 + 1.0.2 CFBundleVersion - 14 + 15 GlossSafariExtensionAvailable CFBundleDocumentTypes diff --git a/Scripts/export_pdf_side_by_side.sh b/Scripts/export_pdf_side_by_side.sh new file mode 100755 index 0000000..c0da686 --- /dev/null +++ b/Scripts/export_pdf_side_by_side.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +SCRIPT_DIRECTORY=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec /usr/bin/env swift \ + "$SCRIPT_DIRECTORY/pdf_dual_export.swift" \ + side-by-side \ + "$@" diff --git a/Scripts/export_pdf_translation_only.sh b/Scripts/export_pdf_translation_only.sh new file mode 100755 index 0000000..b5d86d8 --- /dev/null +++ b/Scripts/export_pdf_translation_only.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +SCRIPT_DIRECTORY=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec /usr/bin/env swift \ + "$SCRIPT_DIRECTORY/pdf_dual_export.swift" \ + translation-only \ + "$@" diff --git a/Scripts/pdf_dual_export.swift b/Scripts/pdf_dual_export.swift new file mode 100755 index 0000000..161176c --- /dev/null +++ b/Scripts/pdf_dual_export.swift @@ -0,0 +1,374 @@ +#!/usr/bin/env swift +import CoreGraphics +import Foundation + +private enum ExportMode: String { + case sideBySide = "side-by-side" + case translationOnly = "translation-only" + + var filenameSuffix: String { + switch self { + case .sideBySide: + "gloss-side-by-side" + case .translationOnly: + "gloss-translation-only" + } + } +} + +private struct ScriptError: LocalizedError { + let message: String + + var errorDescription: String? { message } +} + +private struct Arguments { + let mode: ExportMode + let inputURL: URL + let outputURL: URL + let force: Bool + + init(commandLine: [String]) throws { + guard commandLine.count >= 3, + let mode = ExportMode(rawValue: commandLine[1]) + else { + throw ScriptError(message: Self.usage) + } + + var force = false + var positional: [String] = [] + for argument in commandLine.dropFirst(2) { + if argument == "--force" || argument == "-f" { + force = true + } else if argument.hasPrefix("-") { + throw ScriptError( + message: "Unknown option: \(argument)\n\n\(Self.usage)" + ) + } else { + positional.append(argument) + } + } + + guard positional.count == 1 || positional.count == 2 else { + throw ScriptError(message: Self.usage) + } + + let inputURL = Self.fileURL(positional[0]) + let outputURL = + positional.count == 2 + ? Self.fileURL(positional[1]) + : Self.defaultOutputURL(for: inputURL, mode: mode) + + guard inputURL.pathExtension.lowercased() == "pdf" else { + throw ScriptError(message: "Input must be a PDF: \(inputURL.path)") + } + guard outputURL.pathExtension.lowercased() == "pdf" else { + throw ScriptError(message: "Output must be a PDF: \(outputURL.path)") + } + guard inputURL != outputURL else { + throw ScriptError(message: "Input and output paths must be different.") + } + + self.mode = mode + self.inputURL = inputURL + self.outputURL = outputURL + self.force = force + } + + private static func fileURL(_ path: String) -> URL { + URL( + fileURLWithPath: (path as NSString).expandingTildeInPath + ).standardizedFileURL + } + + private static func defaultOutputURL( + for inputURL: URL, + mode: ExportMode + ) -> URL { + let rawStem = inputURL.deletingPathExtension().lastPathComponent + let dualSuffix = "-gloss-dual" + let stem = + rawStem.hasSuffix(dualSuffix) + ? String(rawStem.dropLast(dualSuffix.count)) + : rawStem + return inputURL.deletingLastPathComponent().appendingPathComponent( + "\(stem)-\(mode.filenameSuffix).pdf" + ) + } + + private static let usage = """ + Usage: pdf_dual_export.swift MODE INPUT.pdf [OUTPUT.pdf] [--force] + + MODE side-by-side | translation-only + + The input must be a Gloss bilingual PDF whose pages alternate between + source and translation. Existing outputs are preserved unless --force + is supplied. + """ +} + +private func validateInput(_ arguments: Arguments) throws -> CGPDFDocument { + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: arguments.inputURL.path), + let document = CGPDFDocument(arguments.inputURL as CFURL) + else { + throw ScriptError( + message: "Cannot read input PDF: \(arguments.inputURL.path)" + ) + } + guard document.numberOfPages > 0 else { + throw ScriptError(message: "Input PDF has no pages.") + } + guard document.numberOfPages.isMultiple(of: 2) else { + throw ScriptError( + message: + "Expected alternating source/translation pairs, but the input has \(document.numberOfPages) pages." + ) + } + if fileManager.fileExists(atPath: arguments.outputURL.path), + !arguments.force + { + throw ScriptError( + message: + "Output already exists: \(arguments.outputURL.path)\nUse --force to replace it." + ) + } + try fileManager.createDirectory( + at: arguments.outputURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + return document +} + +private func temporaryOutputURL(for outputURL: URL) -> URL { + outputURL.deletingLastPathComponent().appendingPathComponent( + ".\(outputURL.lastPathComponent).\(UUID().uuidString).tmp" + ) +} + +private func installTemporaryOutput( + _ temporaryURL: URL, + at outputURL: URL, + force: Bool +) throws { + let fileManager = FileManager.default + if fileManager.fileExists(atPath: outputURL.path) { + guard force else { + throw ScriptError(message: "Output already exists: \(outputURL.path)") + } + _ = try fileManager.replaceItemAt(outputURL, withItemAt: temporaryURL) + } else { + try fileManager.moveItem(at: temporaryURL, to: outputURL) + } +} + +private func exportTranslationOnly( + from source: CGPDFDocument, + arguments: Arguments +) throws { + let temporaryURL = temporaryOutputURL(for: arguments.outputURL) + defer { try? FileManager.default.removeItem(at: temporaryURL) } + let context = try makePDFContext( + at: temporaryURL, + source: source, + mode: arguments.mode + ) + + for sourcePageNumber in stride( + from: 2, + through: source.numberOfPages, + by: 2 + ) { + guard let page = source.page(at: sourcePageNumber) else { + throw ScriptError( + message: "Cannot read translated page \(sourcePageNumber)." + ) + } + let pageSize = displayedSize(of: page) + let mediaBox = CGRect(origin: .zero, size: pageSize) + context.beginPDFPage(pageInfo(mediaBox: mediaBox)) + draw(page: page, in: mediaBox, context: context) + context.endPDFPage() + } + context.closePDF() + + try installTemporaryOutput( + temporaryURL, + at: arguments.outputURL, + force: arguments.force + ) +} + +private func displayedSize(of page: CGPDFPage) -> CGSize { + let bounds = page.getBoxRect(.mediaBox) + let rotation = ((page.rotationAngle % 360) + 360) % 360 + if rotation == 90 || rotation == 270 { + return CGSize(width: bounds.height, height: bounds.width) + } + return bounds.size +} + +private func draw( + page: CGPDFPage, + in targetRect: CGRect, + context: CGContext +) { + context.saveGState() + context.concatenate( + page.getDrawingTransform( + .mediaBox, + rect: targetRect, + rotate: 0, + preserveAspectRatio: true + ) + ) + context.drawPDFPage(page) + context.restoreGState() +} + +private func pdfString( + named key: String, + in dictionary: CGPDFDictionaryRef? +) -> String? { + guard let dictionary else { return nil } + var value: CGPDFStringRef? + guard CGPDFDictionaryGetString(dictionary, key, &value), + let value + else { + return nil + } + return CGPDFStringCopyTextString(value) as String? +} + +private func documentInfo( + from source: CGPDFDocument, + mode: ExportMode +) -> CFDictionary { + var info: [CFString: Any] = [ + kCGPDFContextCreator: "Gloss PDF exporter", + kCGPDFContextSubject: + mode == .sideBySide + ? "Source and translation side by side" + : "Translation-only derivative of a Gloss bilingual PDF", + ] + if let title = pdfString(named: "Title", in: source.info) { + info[kCGPDFContextTitle] = title + } + if let author = pdfString(named: "Author", in: source.info) { + info[kCGPDFContextAuthor] = author + } + return info as CFDictionary +} + +private func makePDFContext( + at temporaryURL: URL, + source: CGPDFDocument, + mode: ExportMode +) throws -> CGContext { + guard let consumer = CGDataConsumer(url: temporaryURL as CFURL) else { + throw ScriptError( + message: "Cannot create output PDF: \(temporaryURL.path)" + ) + } + var firstPageBox = CGRect(x: 0, y: 0, width: 1, height: 1) + guard + let context = CGContext( + consumer: consumer, + mediaBox: &firstPageBox, + documentInfo(from: source, mode: mode) + ) + else { + throw ScriptError( + message: "Cannot initialize PDF writer: \(temporaryURL.path)" + ) + } + return context +} + +private func pageInfo(mediaBox: CGRect) -> CFDictionary { + var mediaBox = mediaBox + let mediaBoxData = + Data( + bytes: &mediaBox, + count: MemoryLayout.size + ) as CFData + return [kCGPDFContextMediaBox: mediaBoxData] as CFDictionary +} + +private func exportSideBySide( + from source: CGPDFDocument, + arguments: Arguments +) throws { + let temporaryURL = temporaryOutputURL(for: arguments.outputURL) + defer { try? FileManager.default.removeItem(at: temporaryURL) } + let context = try makePDFContext( + at: temporaryURL, + source: source, + mode: arguments.mode + ) + + for sourcePageNumber in stride(from: 1, through: source.numberOfPages, by: 2) { + guard let leftPage = source.page(at: sourcePageNumber), + let rightPage = source.page(at: sourcePageNumber + 1) + else { + throw ScriptError( + message: "Cannot read page pair starting at \(sourcePageNumber)." + ) + } + + let leftSize = displayedSize(of: leftPage) + let rightSize = displayedSize(of: rightPage) + let canvasSize = CGSize( + width: leftSize.width + rightSize.width, + height: max(leftSize.height, rightSize.height) + ) + let mediaBox = CGRect(origin: .zero, size: canvasSize) + context.beginPDFPage(pageInfo(mediaBox: mediaBox)) + draw( + page: leftPage, + in: CGRect( + x: 0, + y: canvasSize.height - leftSize.height, + width: leftSize.width, + height: leftSize.height + ), + context: context + ) + draw( + page: rightPage, + in: CGRect( + x: leftSize.width, + y: canvasSize.height - rightSize.height, + width: rightSize.width, + height: rightSize.height + ), + context: context + ) + context.endPDFPage() + } + context.closePDF() + + try installTemporaryOutput( + temporaryURL, + at: arguments.outputURL, + force: arguments.force + ) +} + +do { + let arguments = try Arguments(commandLine: CommandLine.arguments) + let source = try validateInput(arguments) + switch arguments.mode { + case .sideBySide: + try exportSideBySide(from: source, arguments: arguments) + case .translationOnly: + try exportTranslationOnly(from: source, arguments: arguments) + } + print(arguments.outputURL.path) +} catch { + let message = + (error as? LocalizedError)?.errorDescription + ?? String(describing: error) + FileHandle.standardError.write(Data("error: \(message)\n".utf8)) + exit(EXIT_FAILURE) +} diff --git a/Sources/Gloss/AppUpdateController.swift b/Sources/Gloss/AppUpdateController.swift index 0488cf8..d5c36ea 100644 --- a/Sources/Gloss/AppUpdateController.swift +++ b/Sources/Gloss/AppUpdateController.swift @@ -40,6 +40,7 @@ final class AppUpdateController { ) async throws -> Void var openReleasePage: (URL) -> Bool var isBusinessTaskActive: () async -> Bool + var deferredInstallPollInterval: Duration var requestApplicationTermination: () -> Void var operatingSystemVersion: () -> OperatingSystemVersion } @@ -51,10 +52,12 @@ final class AppUpdateController { } var onStateChange: ((AppUpdateDashboardState) -> Void)? + var onAutomaticUpdateAvailable: ((GlossAppUpdateAvailability, AppUpdateDelivery) -> Void)? private let currentVersion: String private let dependencies: Dependencies private var automaticCheckTask: Task? + private var deferredInstallTask: Task? private var operationInProgress = false init( @@ -85,6 +88,8 @@ final class AppUpdateController { func cancel() { automaticCheckTask?.cancel() automaticCheckTask = nil + deferredInstallTask?.cancel() + deferredInstallTask = nil } func check(mode: GlossAppUpdateCheckMode) async { @@ -126,11 +131,13 @@ final class AppUpdateController { let installation = try await dependencies.detectHomebrewInstallation() - state = .updateAvailable( - update, - delivery: installation.map(AppUpdateDelivery.homebrew) - ?? .releasePage - ) + let delivery = + installation.map(AppUpdateDelivery.homebrew) + ?? .releasePage + state = .updateAvailable(update, delivery: delivery) + if mode == .automatic { + onAutomaticUpdateAvailable?(update, delivery) + } } } catch is CancellationError { state = previousState @@ -222,9 +229,13 @@ final class AppUpdateController { update, installation: installation ) + scheduleDeferredInstall() return } + deferredInstallTask?.cancel() + deferredInstallTask = nil + operationInProgress = true state = .preparingInstall(version: update.version) defer { operationInProgress = false } @@ -242,4 +253,24 @@ final class AppUpdateController { ) } } + + private func scheduleDeferredInstall() { + guard deferredInstallTask == nil else { return } + let isBusinessTaskActive = dependencies.isBusinessTaskActive + let pollInterval = dependencies.deferredInstallPollInterval + deferredInstallTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + do { + try await Task.sleep(for: pollInterval) + } catch { + return + } + guard !(await isBusinessTaskActive()) else { continue } + guard let self, !Task.isCancelled else { return } + deferredInstallTask = nil + await installAvailableUpdate() + return + } + } + } } diff --git a/Sources/Gloss/AppUpdateDashboardState.swift b/Sources/Gloss/AppUpdateDashboardState.swift index d58fa78..9b36248 100644 --- a/Sources/Gloss/AppUpdateDashboardState.swift +++ b/Sources/Gloss/AppUpdateDashboardState.swift @@ -138,10 +138,10 @@ enum AppUpdateDashboardState: Equatable { case .blockedByBusinessTask(let update, _): AppUpdateDashboardPresentation( headline: "等待当前翻译任务完成", - detail: "完成当前任务后即可安装 Gloss \(update.version)", + detail: "任务完成后将自动安装 Gloss \(update.version)", tone: .warning, - actionTitle: "重试更新", - actionEnabled: true, + actionTitle: "等待任务完成", + actionEnabled: false, showsProgress: false ) case .preparingInstall(let version): @@ -193,8 +193,8 @@ enum AppUpdateDashboardState: Equatable { return AppUpdateMenuPresentation(title: title, isEnabled: true) case .blockedByBusinessTask(let update, _): return AppUpdateMenuPresentation( - title: "完成当前任务后更新到 \(update.version)…", - isEnabled: true + title: "任务完成后自动更新到 \(update.version)…", + isEnabled: false ) case .preparingInstall: return AppUpdateMenuPresentation( diff --git a/Sources/Gloss/GlossAppDelegate.swift b/Sources/Gloss/GlossAppDelegate.swift index 7e30fd1..b9021ea 100644 --- a/Sources/Gloss/GlossAppDelegate.swift +++ b/Sources/Gloss/GlossAppDelegate.swift @@ -62,6 +62,7 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var pdfRuntimeActionID: UUID? private lazy var pdfRuntimeController = PDFRuntimeController() private var appUpdateActionTask: Task? + private var remindedAppUpdateVersion: String? private lazy var appUpdateController: AppUpdateController? = makeAppUpdateController() private var selectionMonitor: SelectionMonitor? @@ -218,6 +219,7 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { || dispatch.activeBackgroundJobs > 0 || dispatch.upstreamBackgroundItems > 0 }, + deferredInstallPollInterval: .seconds(2), requestApplicationTermination: { NSApp.terminate(nil) }, @@ -230,6 +232,15 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { controller.onStateChange = { [weak self] state in self?.showAppUpdateState(state) } + controller.onAutomaticUpdateAvailable = { + [weak self] update, delivery in + DispatchQueue.main.async { + self?.presentAppUpdateReminder( + update: update, + delivery: delivery + ) + } + } return controller } catch { runtimeLog.write( @@ -1953,6 +1964,39 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } + private func presentAppUpdateReminder( + update: GlossAppUpdateAvailability, + delivery: AppUpdateDelivery + ) { + guard remindedAppUpdateVersion != update.version, + appUpdateActionTask == nil + else { return } + remindedAppUpdateVersion = update.version + + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = "Gloss \(update.version) 已可用" + switch delivery { + case .homebrew: + alert.informativeText = """ + 新版本已经过签名发行信息校验。 + + 点击“更新并重新启动”后,Gloss 将退出并由 Homebrew 完成升级;安装验证通过后会自动重新打开。如果当前正在翻译,Gloss 会在任务结束后自动继续更新。 + """ + alert.addButton(withTitle: "更新并重新启动") + case .releasePage: + alert.informativeText = """ + 当前 Gloss 不是由官方 sunchj/tap/gloss Homebrew Cask 管理,无法执行自动安装。可以打开正式发布页面下载安装。 + """ + alert.addButton(withTitle: "查看下载") + } + alert.addButton(withTitle: "稍后") + + NSApp.activate(ignoringOtherApps: true) + guard alert.runModal() == .alertFirstButtonReturn else { return } + performAppUpdateAction() + } + private func updateAppUpdateMenuItem(_ item: NSMenuItem) { let presentation = currentAppUpdateState.menuPresentation item.title = presentation.title diff --git a/Tests/GlossAppTests/AppUpdateControllerTests.swift b/Tests/GlossAppTests/AppUpdateControllerTests.swift index e3c2a9d..2678336 100644 --- a/Tests/GlossAppTests/AppUpdateControllerTests.swift +++ b/Tests/GlossAppTests/AppUpdateControllerTests.swift @@ -39,6 +39,42 @@ final class AppUpdateControllerTests: XCTestCase { ) } + func testAutomaticUpdateDiscoveryRequestsOneUserReminder() async { + let expectedInstallation = installation() + let controller = makeController( + check: { _ in .updateAvailable(self.updateAvailability()) }, + detect: { expectedInstallation } + ) + var reminders: [(GlossAppUpdateAvailability, AppUpdateDelivery)] = [] + controller.onAutomaticUpdateAvailable = { update, delivery in + reminders.append((update, delivery)) + } + + await controller.check(mode: .automatic) + + XCTAssertEqual(reminders.count, 1) + XCTAssertEqual(reminders.first?.0, updateAvailability()) + XCTAssertEqual( + reminders.first?.1, + .homebrew(expectedInstallation) + ) + } + + func testManualUpdateCheckDoesNotRequestAutomaticReminder() async { + let controller = makeController( + check: { _ in .updateAvailable(self.updateAvailability()) }, + detect: { self.installation() } + ) + var reminderCount = 0 + controller.onAutomaticUpdateAvailable = { _, _ in + reminderCount += 1 + } + + await controller.check(mode: .manual) + + XCTAssertEqual(reminderCount, 0) + } + func testUnmanagedInstallOnlyOpensOfficialReleasePage() async { var openedURL: URL? let controller = makeController( @@ -75,6 +111,36 @@ final class AppUpdateControllerTests: XCTestCase { guard case .blockedByBusinessTask = controller.state else { return XCTFail("Expected business-task blocked state") } + controller.cancel() + } + + func testDeferredInstallContinuesWhenBusinessTaskFinishes() async { + var businessTaskActive = true + var helperLaunchCount = 0 + var terminationCount = 0 + let helperLaunched = expectation(description: "update helper launched") + let controller = makeController( + check: { _ in .updateAvailable(self.updateAvailability()) }, + detect: { self.installation() }, + launch: { _, _ in + helperLaunchCount += 1 + helperLaunched.fulfill() + }, + isPDFActive: { businessTaskActive }, + terminate: { terminationCount += 1 } + ) + + await controller.check(mode: .manual) + await controller.performPrimaryAction() + businessTaskActive = false + await fulfillment(of: [helperLaunched], timeout: 1) + + XCTAssertEqual(helperLaunchCount, 1) + XCTAssertEqual(terminationCount, 1) + XCTAssertEqual( + controller.state, + .preparingInstall(version: "0.8.3") + ) } func testVerifiedHelperLaunchRequestsTermination() async { @@ -222,6 +288,7 @@ final class AppUpdateControllerTests: XCTestCase { launchHomebrewUpdate: launch, openReleasePage: openReleasePage, isBusinessTaskActive: isPDFActive, + deferredInstallPollInterval: .milliseconds(10), requestApplicationTermination: terminate, operatingSystemVersion: { operatingSystemVersion } ) diff --git a/Tests/GlossAppTests/AppUpdateDashboardStateTests.swift b/Tests/GlossAppTests/AppUpdateDashboardStateTests.swift index c29f342..7750936 100644 --- a/Tests/GlossAppTests/AppUpdateDashboardStateTests.swift +++ b/Tests/GlossAppTests/AppUpdateDashboardStateTests.swift @@ -38,7 +38,7 @@ final class AppUpdateDashboardStateTests: XCTestCase { ) } - func testBusinessTaskBlockKeepsVerifiedInstallationForRetry() { + func testBusinessTaskBlockKeepsVerifiedInstallationForAutomaticContinuation() { let expectedInstallation = installation() let state = AppUpdateDashboardState.blockedByBusinessTask( updateAvailability(), @@ -47,6 +47,9 @@ final class AppUpdateDashboardStateTests: XCTestCase { XCTAssertEqual(state.action, .install) XCTAssertEqual(state.presentation.headline, "等待当前翻译任务完成") + XCTAssertFalse(state.presentation.actionEnabled) + XCTAssertFalse(state.menuPresentation.isEnabled) + XCTAssertTrue(state.presentation.detail.contains("自动安装")) XCTAssertTrue(state.menuPresentation.title.contains("0.8.3")) guard case .blockedByBusinessTask(_, let actualInstallation) = state else { return XCTFail("Expected blocked business-task state") diff --git a/docs/release-notes/v1.0.2.md b/docs/release-notes/v1.0.2.md new file mode 100644 index 0000000..968cf28 --- /dev/null +++ b/docs/release-notes/v1.0.2.md @@ -0,0 +1,37 @@ +# Gloss 1.0.2 + +Gloss 1.0.2 makes the bilingual BabelDOC output a reusable PDF master instead +of a single presentation choice. + +## Actionable update reminders + +- Presents a native update reminder when the background signed-manifest check + discovers a newer Gloss release. +- Lets official Homebrew installations start the verified upgrade and automatic + restart directly from the reminder. +- Offers the official release page for unmanaged installations. +- Shows each discovered version once per App session; choosing Later keeps work + uninterrupted and allows the normal 24-hour check to remind again later. +- Queues a confirmed update while translation work is active and automatically + continues the install as soon as the active work finishes. + +## Reusable bilingual exports + +- Adds `Scripts/export_pdf_side_by_side.sh` to combine each source/translation + pair into one permanent left/right spread. +- Adds `Scripts/export_pdf_translation_only.sh` to extract translated pages + from the same bilingual master. +- Keeps both conversions local and avoids another translation pass. +- Preserves vector PDF content instead of rasterizing pages into images. +- Rejects odd page counts because a valid Gloss bilingual master consists of + complete source/translation pairs. +- Refuses to overwrite an existing derivative unless `--force` is supplied. + +## Validation + +- Both exporters were exercised against a 40-page bilingual research paper. +- The resulting 20-page side-by-side and translation-only PDFs were checked at + the beginning, middle, and end for page order, clipping, and legibility. +- The complete Swift and Swift Testing suites pass. + +Gloss 1.0.2 requires macOS 14 or newer.