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.