diff --git a/README.ja.md b/README.ja.md index 8ceea97..fc45a5c 100644 --- a/README.ja.md +++ b/README.ja.md @@ -52,6 +52,7 @@ AirTranslateは、Macで再生されている音声をリアルタイムで文 - **より安定したフローティング翻訳:** 新しい翻訳が用意できるまで前の翻訳を保持し、認識器が文を直すたびに字幕が書き換わったり点滅したりしません。 - **予約された字幕の高さ:** フローティング字幕は固定ブロック高さを保ち、差し替えを一塊でフェードするため、文字が増えても原文行が上下に跳ねたり中央揃えし直したりしません。 - **字幕の安定化と配置:** 設定とメニューバーで字幕の安定化(即応/標準/安定)と字幕の配置(中央/左)を選べます。左揃えは行が伸びても始点が固定されます。 +- **プレゼンテーション品質モード:** 1つのスイッチで、翻訳のみ・2行・安定した節のタイミングを使う観客向けオーバーレイを設定できます。講演の背景と用語集を追加すると、人名、ブランド名、略語、希望する日本語表現を正確に保てます。 詳細は[AirTranslate 1.7.1リリースノート](https://github.com/himomohi/AirTranslate/releases/tag/v1.7.1)をご覧ください。 diff --git a/README.ko.md b/README.ko.md index 09bdb7c..cf6b1ac 100644 --- a/README.ko.md +++ b/README.ko.md @@ -52,6 +52,7 @@ AirTranslate는 Mac에서 재생되는 소리를 실시간으로 기록하고 - **더 안정적인 플로팅 번역:** 새 번역이 준비될 때까지 이전 번역을 유지해, 인식기가 문장을 고칠 때마다 자막이 다시 쓰이거나 깜빡이지 않습니다. - **예약된 자막 높이:** 플로팅 자막이 고정 블록 높이를 유지하고 교체 문구를 한 덩어리로 페이드해, 글자가 늘어나도 원문 줄이 위아래로 점프하거나 다시 가운데 맞춰지지 않습니다. - **자막 안정화와 정렬:** 설정과 메뉴 막대에서 자막 안정화(빠름/기본/안정)와 자막 정렬(가운데/왼쪽)을 고를 수 있습니다. 왼쪽 정렬은 줄이 늘어나도 시작점이 고정됩니다. +- **프레젠테이션 품질 모드:** 스위치 하나로 번역만 표시되는 두 줄 청중용 오버레이와 더 안정적인 구절 타이밍을 설정합니다. 발표 맥락과 용어집을 추가해 이름, 브랜드, 약어, 선호 번역을 정확히 유지할 수 있습니다. 전체 내용은 [AirTranslate 1.7.1 릴리즈 노트](https://github.com/himomohi/AirTranslate/releases/tag/v1.7.1)에서 확인할 수 있습니다. diff --git a/README.md b/README.md index 32a8a35..233bea2 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ The default workflow uses Apple frameworks. GPT Realtime, Gemini Live Translate, - **Steadier floating translations:** The overlay holds the previous translation until a new one is ready, so live captions no longer rewrite or flicker on every recognizer revision. - **Reserved caption height:** Floating captions keep a fixed block height and fade replacements in one piece, so the source line does not jump or re-center as text grows. - **Caption Stability and alignment:** Settings and the menu bar now offer Caption Stability (Responsive / Balanced / Steady) and Caption Alignment (Center / Left). Left-aligned captions stay anchored as the line grows. +- **Presentation Quality mode:** One switch configures a translation-only, two-line audience overlay with steadier clause timing. Add talk context and a terminology glossary to protect names, brands, acronyms, and preferred Japanese wording. See the complete [AirTranslate 1.7.1 release notes](https://github.com/himomohi/AirTranslate/releases/tag/v1.7.1). @@ -248,6 +249,32 @@ Developer ID signing and notarization are planned for a later distribution step. ## Build From Source +### Install the privacy-fixed custom build on another Mac + +On an Apple Silicon Mac running macOS 26 or later, paste this block into Terminal. It downloads an immutable installer revision and verifies its SHA-256 digest before execution: + +```bash +INSTALLER_COMMIT="2cffcdde08e7d9d4cb7408aa1c34e08df8a753a2" +INSTALLER_SHA256="49ea3e8b1b6fcd2df4adf8e649af12ef634baf471facf611d746c2866e4469f7" +INSTALLER_PATH="$(mktemp "${TMPDIR:-/tmp}/airtranslate-installer.XXXXXX")" +curl -fL "https://raw.githubusercontent.com/gary8020/AirTranslate/$INSTALLER_COMMIT/script/install_on_this_mac.sh" -o "$INSTALLER_PATH" +printf '%s %s\n' "$INSTALLER_SHA256" "$INSTALLER_PATH" | shasum -a 256 -c - +AIRTRANSLATE_SOURCE_REVISION="$INSTALLER_COMMIT" /bin/bash "$INSTALLER_PATH" install +rm -f "$INSTALLER_PATH" +``` + +The installer downloads the pinned source commit, creates a release build locally, checks +the app bundle's code-signature integrity, installs it to +`~/Applications/AirTranslate.app`, and opens it. It does not copy API keys, +transcripts, or settings from another Mac. Each Mac must grant its own +Microphone and Speech Recognition permissions and download any required Apple +language assets. macOS may ask for those permissions again after an update if +the Mac does not have a persistent code-signing identity. + +Running a newer verified installer block updates the source and preserves one previous app at +`~/Library/Application Support/AirTranslate Custom Build/backups/AirTranslate-previous.app` +before installing the new build. + Run the app bundle: ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 8ea9141..f7fec6d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -52,6 +52,7 @@ AirTranslate 可以捕获 Mac 正在播放的音频,实时转写并翻译, - **更稳定的悬浮翻译:** 在新译文就绪之前会保留上一句翻译,识别器每次修订句子时字幕不再整句重写或闪烁。 - **预留字幕高度:** 悬浮字幕保持固定块高度,并以整块淡入替换文本,因此原文行不会在文字变长时上下跳动或重新居中。 - **字幕稳定与对齐:** 可在设置和菜单栏选择字幕稳定(灵敏/均衡/稳定)和字幕对齐(居中/左)。左对齐会在行变长时保持起点固定。 +- **演示质量模式:** 一键设置仅显示译文的两行观众字幕,并采用更稳定的分句时机。可添加演讲背景和术语表,以准确保留人名、品牌、缩写及首选译法。 完整内容请参阅 [AirTranslate 1.7.1 发布说明](https://github.com/himomohi/AirTranslate/releases/tag/v1.7.1)。 diff --git a/Release/GITHUB-RELEASE-1.7.1.md b/Release/GITHUB-RELEASE-1.7.1.md index b9e656a..eab6c52 100644 --- a/Release/GITHUB-RELEASE-1.7.1.md +++ b/Release/GITHUB-RELEASE-1.7.1.md @@ -1,12 +1,13 @@ # AirTranslate 1.7.1 -AirTranslate 1.7.1 steadies the floating caption overlay so live translations stop jumping, flickering, and rewriting mid-sentence, and adds Caption Stability and Caption Alignment controls. +AirTranslate 1.7.1 steadies the floating caption overlay so live translations stop jumping, flickering, and rewriting mid-sentence, adds Caption Stability and Caption Alignment controls, and introduces a Presentation Quality mode for audience-facing interpretation. AirTranslate is an independent open-source project and is not affiliated with Apple, OpenAI, Google, or Meta. ## Added - **Caption Stability** (Responsive / Balanced / Steady) and **Caption Alignment** (Center / Left) are available in Settings and the menu bar. Steady holds each rewrite longer; Left keeps the start of each line fixed as text grows. +- **Presentation Quality mode** switches to a translation-only, two-line audience overlay with steadier clause timing. Optional talk context improves GPT text translation, while the terminology glossary protects names, brands, acronyms, and preferred translations across caption engines. ## Changed diff --git a/Release/README.md b/Release/README.md index 1fdfa6b..ff00611 100644 --- a/Release/README.md +++ b/Release/README.md @@ -61,6 +61,7 @@ Release/product/AirTranslate-.dmg.sha256 ## 1.7.1 Floating Caption Stability Notes - Public docs must describe Caption Stability (Responsive / Balanced / Steady) and Caption Alignment (Center / Left) as Settings and menu-bar controls for the floating overlay. +- Public docs must describe Presentation Quality mode, talk context, and the terminology glossary as optional audience-caption controls. - Floating translations hold the previous translation until a replacement is ready; do not describe the overlay as clearing to a blank line on every rewrite. - Floating captions reserve a fixed caption height so the source line does not jump when the translation appears or wraps. @@ -97,5 +98,5 @@ git diff -- . ':(exclude).build/**' ':(exclude)Release/product/**' | \ - Confirm Gemini Live mode still requires a user-provided key at runtime and does not bundle one. - Confirm Meta Scribe mode still requires a user-provided key at runtime and does not bundle one. - Confirm `Release/product/` remains ignored. -- Confirm all four public READMEs and `GITHUB-RELEASE-1.7.1.md` describe all three public themes with equivalent meaning: steadier floating translations that hold the previous line, reserved caption height, and Caption Stability plus Caption Alignment controls. +- Confirm all four public READMEs and `GITHUB-RELEASE-1.7.1.md` describe all four public themes with equivalent meaning: steadier floating translations that hold the previous line, reserved caption height, Caption Stability plus Caption Alignment controls, and Presentation Quality mode with terminology guidance. - Publish the new GitHub Release without deleting previous release versions or tags. diff --git a/Sources/AirTranslate/Models/TranslationQualityContext.swift b/Sources/AirTranslate/Models/TranslationQualityContext.swift new file mode 100644 index 0000000..1788471 --- /dev/null +++ b/Sources/AirTranslate/Models/TranslationQualityContext.swift @@ -0,0 +1,201 @@ +import Foundation + +struct TranslationGlossaryEntry: Equatable, Sendable { + let source: String + let target: String +} + +struct TranslationQualityContext: Equatable, Sendable { + static let maximumPresentationContextCharacters = 1_000 + static let maximumGlossaryEntries = 50 + static let maximumGlossaryTermCharacters = 120 + private static let separators = ["=>", "->", "→", "="] + + let presentationContext: String + let glossaryEntries: [TranslationGlossaryEntry] + private let replacementEntries: [TranslationGlossaryEntry] + + init(presentationContext: String, glossaryText: String) { + self.presentationContext = String( + presentationContext + .trimmingCharacters(in: .whitespacesAndNewlines) + .prefix(Self.maximumPresentationContextCharacters) + ) + let parsedEntries = Self.parseGlossary(glossaryText) + glossaryEntries = parsedEntries + replacementEntries = parsedEntries.sorted { left, right in + left.source.count > right.source.count + } + } + + var sourceTerms: [String] { + glossaryEntries.map(\.source) + } + + func applyingTerminology(to text: String) -> String { + let matches = replacementEntries + .flatMap { entry in + matchingRanges(for: entry, in: text).map { range in + (range: range, entry: entry) + } + } + .sorted { left, right in + if left.range.lowerBound == right.range.lowerBound { + return left.entry.source.count > right.entry.source.count + } + return left.range.lowerBound < right.range.lowerBound + } + + guard !matches.isEmpty else { return text } + + var result = "" + var cursor = text.startIndex + for match in matches where match.range.lowerBound >= cursor { + result.append(contentsOf: text[cursor.. String { + var guidance = [ + baseInstructions, + "This is live simultaneous interpretation for an audience. Translate the speaker's meaning naturally and concisely instead of following source-language word order.", + "Preserve names, numbers, acronyms, and business terminology. Omit empty fillers such as uh, um, and huh when they add no meaning.", + ] + + if target.id.lowercased().hasPrefix("ja") { + guidance.append("Use polite, natural Japanese suitable for projected captions, with concise です/ます style where appropriate.") + } + + if !presentationContext.isEmpty { + guidance.append("Presentation context: \(presentationContext)") + } + + if !glossaryEntries.isEmpty { + let terms = glossaryEntries + .map { "- \($0.source) => \($0.target)" } + .joined(separator: "\n") + guidance.append("Use these exact target terms whenever the matching source term appears:\n\(terms)") + } + + guidance.append("Return only the interpretation. Do not add explanations, labels, or facts that the speaker did not say.") + return guidance.joined(separator: "\n\n") + } + + private static func parseGlossary(_ text: String) -> [TranslationGlossaryEntry] { + var entries: [TranslationGlossaryEntry] = [] + var indexBySource: [String: Int] = [:] + + for rawLine in text.components(separatedBy: .newlines) { + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty, !line.hasPrefix("#") else { continue } + guard let separatorRange = separators + .compactMap({ separator in + line.range(of: separator).map { (range: $0, length: separator.count) } + }) + .min(by: { left, right in + if left.range.lowerBound == right.range.lowerBound { + return left.length > right.length + } + return left.range.lowerBound < right.range.lowerBound + })? + .range + else { continue } + + let source = line[..= maximumGlossaryEntries { + break + } + } + + return entries + } + + private func matchingRanges( + for entry: TranslationGlossaryEntry, + in text: String + ) -> [Range] { + var ranges: [Range] = [] + var searchStart = text.startIndex + + while searchStart < text.endIndex, + let range = text.range( + of: entry.source, + options: [.caseInsensitive], + range: searchStart.., + in text: String + ) -> Bool { + if range.lowerBound > text.startIndex { + let previous = text[text.index(before: range.lowerBound)] + if previous.isLetter || previous.isNumber { + return false + } + } + + if range.upperBound < text.endIndex { + let next = text[range.upperBound] + if next.isLetter || next.isNumber { + return false + } + } + + return true + } +} + +enum TranslationQualityPolicy { + /// Returns nil when the normal low-latency policy should remain in charge. + static func debounceDelay( + isEnabled: Bool, + isFinal: Bool, + sourceText: String + ) -> Int? { + guard isEnabled else { return nil } + guard !isFinal else { return 0 } + + let trimmedText = sourceText.trimmingCharacters(in: .whitespacesAndNewlines) + guard let finalCharacter = trimmedText.last else { return 420 } + if ".!?。!?".contains(finalCharacter) { + return 120 + } + if ",;:、,;:—–".contains(finalCharacter) { + return 240 + } + return 420 + } +} diff --git a/Sources/AirTranslate/Services/OpenAITranslationService.swift b/Sources/AirTranslate/Services/OpenAITranslationService.swift index 85beb47..af4a872 100644 --- a/Sources/AirTranslate/Services/OpenAITranslationService.swift +++ b/Sources/AirTranslate/Services/OpenAITranslationService.swift @@ -20,6 +20,7 @@ actor OpenAITranslationService { source: LanguageOption, target: LanguageOption, model selectedModel: OpenAIRealtimeTranslationModel, + qualityContext: TranslationQualityContext? = nil, progress: (@MainActor @Sendable (String) -> Void)? = nil ) async throws -> String { guard !text.isEmpty else { return text } @@ -35,6 +36,7 @@ actor OpenAITranslationService { source: source, target: target, model: selectedModel, + qualityContext: qualityContext, streaming: true ) if let streamedText = try await streamTranslation(streamingRequest, progress: progress) { @@ -48,6 +50,7 @@ actor OpenAITranslationService { source: source, target: target, model: selectedModel, + qualityContext: qualityContext, streaming: false ) let (data, httpResponse) = try await send(request) @@ -72,19 +75,25 @@ actor OpenAITranslationService { source: LanguageOption, target: LanguageOption, model: OpenAIRealtimeTranslationModel, + qualityContext: TranslationQualityContext?, streaming: Bool ) throws -> URLRequest { var request = URLRequest(url: endpoint) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + let baseInstructions = AppText.openAITranslationInstructions( + source: source.localizedTitle, + target: target.localizedTitle + ) + let instructions = qualityContext?.enhancing( + instructions: baseInstructions, + target: target + ) ?? baseInstructions request.httpBody = try JSONEncoder().encode( OpenAIResponseRequest( model: model.apiModelID, - instructions: AppText.openAITranslationInstructions( - source: source.localizedTitle, - target: target.localizedTitle - ), + instructions: instructions, input: text, store: false, stream: streaming ? true : nil diff --git a/Sources/AirTranslate/Services/TranslationSessionStore.swift b/Sources/AirTranslate/Services/TranslationSessionStore.swift index bd09ed6..b90bdc7 100644 --- a/Sources/AirTranslate/Services/TranslationSessionStore.swift +++ b/Sources/AirTranslate/Services/TranslationSessionStore.swift @@ -84,6 +84,9 @@ private enum SettingsKey { static let keepsFloatingCaptionAboveOtherWindows = "keepsFloatingCaptionAboveOtherWindows" static let floatingCaptionStability = "floatingCaptionStability" static let floatingCaptionTextAlignment = "floatingCaptionTextAlignment" + static let isPresentationQualityModeEnabled = "isPresentationQualityModeEnabled" + static let presentationContext = "presentationContext" + static let presentationGlossary = "presentationGlossary" static let paragraphBreakSilenceInterval = "paragraphBreakSilenceInterval" static let savedTranscriptContentMode = "savedTranscriptContentMode" static let sessionDurationMode = "sessionDurationMode" @@ -592,6 +595,27 @@ final class TranslationSessionStore { var floatingCaptionTextAlignment = FloatingCaptionTextAlignment.center { didSet { persistSelectedSettings() } } + var isPresentationQualityModeEnabled = false { + didSet { + refreshTranslationQualityContext() + if isPresentationQualityModeEnabled, !isRestoringSelectedSettings { + applyPresentationQualityPreset() + } + persistSelectedSettings() + } + } + var presentationContext = "" { + didSet { + refreshTranslationQualityContext() + persistSelectedSettings() + } + } + var presentationGlossary = "" { + didSet { + refreshTranslationQualityContext() + persistSelectedSettings() + } + } /// Text width the floating window currently offers, reported by the view so /// captions wrap to the real width instead of a per-size estimate. `0` means /// unknown and falls back to the estimate. @@ -665,6 +689,7 @@ final class TranslationSessionStore { )? private let transcriptsDirectoryOverride: URL? private let settingsDefaults: UserDefaults + @ObservationIgnored private var activeTranslationQualityContext: TranslationQualityContext? private var audioSampleCount = 0 private var lastRecognizedText = "" private var lastRecognizedWasFinal = false @@ -754,6 +779,17 @@ final class TranslationSessionStore { sessionDurationMode == .thirtyMinutesOrMore } + private func applyPresentationQualityPreset() { + if !isTranscribeOnlyMode { + floatingCaptionDisplayMode = .translation + } + floatingCaptionTextSize = .large + floatingCaptionLineCount = .two + floatingCaptionStability = .steady + floatingCaptionTextAlignment = .leading + keepsFloatingCaptionAboveOtherWindows = true + } + var shouldCoalesceTranscriptAutoScroll: Bool { isRunning || usesLongSessionMode } @@ -2195,7 +2231,7 @@ final class TranslationSessionStore { languageBias: configuration.usesAppleSourceAutoDetection ? nil : configuration.sourceLanguage.metaLanguageBiasName.map { [$0] }, - keywords: [] + keywords: activeTranslationQualityContext?.sourceTerms ?? [] ) scheduleMetaSessionRefresh( service: metaVoiceTranscriber, @@ -2458,7 +2494,7 @@ final class TranslationSessionStore { languageBias: configuration.usesAppleSourceAutoDetection ? nil : configuration.sourceLanguage.metaLanguageBiasName.map { [$0] }, - keywords: [] + keywords: activeTranslationQualityContext?.sourceTerms ?? [] ) } @@ -2771,6 +2807,17 @@ final class TranslationSessionStore { let alignment = FloatingCaptionTextAlignment(rawValue: alignmentID) { floatingCaptionTextAlignment = alignment } + if let context = defaults.string(forKey: SettingsKey.presentationContext) { + presentationContext = context + } + if let glossary = defaults.string(forKey: SettingsKey.presentationGlossary) { + presentationGlossary = glossary + } + if defaults.object(forKey: SettingsKey.isPresentationQualityModeEnabled) != nil { + isPresentationQualityModeEnabled = defaults.bool( + forKey: SettingsKey.isPresentationQualityModeEnabled + ) + } if defaults.object(forKey: SettingsKey.paragraphBreakSilenceInterval) != nil { paragraphBreakSilenceInterval = min( max(defaults.double(forKey: SettingsKey.paragraphBreakSilenceInterval), 1), @@ -2836,6 +2883,9 @@ final class TranslationSessionStore { } else { applyRestoredVoiceOutputPreference() } + if isPresentationQualityModeEnabled { + applyPresentationQualityPreset() + } } private func persistSelectedSettings() { @@ -2866,6 +2916,12 @@ final class TranslationSessionStore { defaults.set(keepsFloatingCaptionAboveOtherWindows, forKey: SettingsKey.keepsFloatingCaptionAboveOtherWindows) defaults.set(floatingCaptionStability.id, forKey: SettingsKey.floatingCaptionStability) defaults.set(floatingCaptionTextAlignment.id, forKey: SettingsKey.floatingCaptionTextAlignment) + defaults.set( + isPresentationQualityModeEnabled, + forKey: SettingsKey.isPresentationQualityModeEnabled + ) + defaults.set(presentationContext, forKey: SettingsKey.presentationContext) + defaults.set(presentationGlossary, forKey: SettingsKey.presentationGlossary) defaults.set(paragraphBreakSilenceInterval, forKey: SettingsKey.paragraphBreakSilenceInterval) defaults.set(savedTranscriptContentMode.id, forKey: SettingsKey.savedTranscriptContentMode) defaults.set(sessionDurationMode.id, forKey: SettingsKey.sessionDurationMode) @@ -4513,6 +4569,7 @@ final class TranslationSessionStore { target: LanguageOption, progress: @escaping @MainActor @Sendable (String) -> Void = { _ in } ) async throws -> String { + let qualityContext = activeTranslationQualityContext let paragraphSegments = try await Task.detached(priority: .userInitiated) { try Task.checkCancellation() return Self.translationSegmentGroups(from: text) @@ -4548,8 +4605,12 @@ final class TranslationSessionStore { source: source, target: target, model: openAITranslationModel, + qualityContext: qualityContext, progress: { partialSegment in - let partialText = (completedParagraphs + [(completedSegments + [partialSegment]).joined(separator: "\n")]) + let adjustedPartialSegment = qualityContext?.applyingTerminology( + to: partialSegment + ) ?? partialSegment + let partialText = (completedParagraphs + [(completedSegments + [adjustedPartialSegment]).joined(separator: "\n")]) .joined(separator: "\n\n") .trimmingCharacters(in: .whitespacesAndNewlines) guard !partialText.isEmpty else { return } @@ -4557,15 +4618,19 @@ final class TranslationSessionStore { } ) } else { + let translationInput = qualityContext?.applyingTerminology(to: segment) ?? segment translatedSegment = try await translator.translate( - segment, + translationInput, source: source, target: target, model: selectedModel ) } try Task.checkCancellation() - let organizedSegment = organizeTranscript(translatedSegment, language: target) + let qualityAdjustedSegment = qualityContext?.applyingTerminology( + to: translatedSegment + ) ?? translatedSegment + let organizedSegment = organizeTranscript(qualityAdjustedSegment, language: target) cacheTranslatedSegment(organizedSegment, forKey: cacheKey) translatedSegments.append(organizedSegment) @@ -4645,6 +4710,16 @@ final class TranslationSessionStore { translationSegmentCache.removeAll() } + private func refreshTranslationQualityContext() { + activeTranslationQualityContext = isPresentationQualityModeEnabled + ? TranslationQualityContext( + presentationContext: presentationContext, + glossaryText: presentationGlossary + ) + : nil + resetTranslationCache() + } + private func updateRealtimeTranslationSourceTranscript(_ text: String) { guard isRunning, !isPaused else { return } guard isUsingOpenAIRealtimeTranslation else { return } @@ -4676,6 +4751,7 @@ final class TranslationSessionStore { private func refreshOpenAIRealtimeTranslationLine() { let inputText = realtimeTranslationSourceText.trimmingCharacters(in: .whitespacesAndNewlines) let translatedText = realtimeTranslationOnlyText.trimmingCharacters(in: .whitespacesAndNewlines) + let qualityTranslatedText = qualityAdjustedTranslation(translatedText) guard !inputText.isEmpty || !translatedText.isEmpty else { return } lastRecognizedText = inputText.isEmpty ? translatedText : inputText @@ -4683,7 +4759,7 @@ final class TranslationSessionStore { transcriptCleanupTask?.cancel() let sourceText = inputText.isEmpty ? AppText.openAIRealtimeTranslationOnlySource : inputText - let visibleTranslatedText = translatedText.isEmpty ? AppText.translating : translatedText + let visibleTranslatedText = translatedText.isEmpty ? AppText.translating : qualityTranslatedText if let currentLineID, let index = lines.firstIndex(where: { $0.id == currentLineID }) { @@ -4716,9 +4792,9 @@ final class TranslationSessionStore { presentFloatingSourceText(inputText) } if !translatedText.isEmpty { - stageTranscriptForSave(sourceText, translatedText: translatedText) - updateFloatingTranslationPresentation(translatedText, sourceText: sourceText) - speakTranslatedDeltaIfNeeded(translatedText) + stageTranscriptForSave(sourceText, translatedText: qualityTranslatedText) + updateFloatingTranslationPresentation(qualityTranslatedText, sourceText: sourceText) + speakTranslatedDeltaIfNeeded(qualityTranslatedText) } } @@ -4928,6 +5004,7 @@ final class TranslationSessionStore { private func refreshGeminiLiveCaptionLine() { let inputText = geminiLiveInputTranscriptText.trimmingCharacters(in: .whitespacesAndNewlines) let outputText = geminiLiveOutputTranscriptText.trimmingCharacters(in: .whitespacesAndNewlines) + let qualityOutputText = qualityAdjustedTranslation(outputText) guard !inputText.isEmpty || !outputText.isEmpty else { return } lastRecognizedText = inputText.isEmpty ? outputText : inputText @@ -4935,7 +5012,7 @@ final class TranslationSessionStore { transcriptCleanupTask?.cancel() let sourceText = inputText.isEmpty ? AppText.geminiLiveTranslationSource : inputText - let translatedText = outputText.isEmpty ? AppText.translating : outputText + let translatedText = outputText.isEmpty ? AppText.translating : qualityOutputText if let currentLineID, let index = lines.firstIndex(where: { $0.id == currentLineID }) { @@ -4967,12 +5044,12 @@ final class TranslationSessionStore { } if !inputText.isEmpty { - stageTranscriptForSave(inputText, translatedText: outputText) + stageTranscriptForSave(inputText, translatedText: qualityOutputText) presentFloatingSourceText(inputText) } if !outputText.isEmpty { - updateFloatingTranslationPresentation(outputText, sourceText: sourceText) - speakTranslatedDeltaIfNeeded(outputText) + updateFloatingTranslationPresentation(qualityOutputText, sourceText: sourceText) + speakTranslatedDeltaIfNeeded(qualityOutputText) } } @@ -5037,7 +5114,7 @@ final class TranslationSessionStore { while !Task.isCancelled, let request = nextTranslationRequest() { do { - let delay = translationDebounceDelay(for: request.sourceText) + let delay = translationDebounceDelay(for: request) if delay > 0 { try await Task.sleep(for: .milliseconds(delay)) } @@ -5141,9 +5218,17 @@ final class TranslationSessionStore { return organizedSourceText } - private func translationDebounceDelay(for sourceText: String) -> Int { + private func translationDebounceDelay(for request: TranslationRequest) -> Int { + if let presentationDelay = TranslationQualityPolicy.debounceDelay( + isEnabled: isPresentationQualityModeEnabled, + isFinal: request.line.isFinal, + sourceText: request.sourceText + ) { + return presentationDelay + } + if usesLongSessionMode { - let sourceLength = sourceText.utf16.count + let sourceLength = request.sourceText.utf16.count if sourceLength >= Self.veryLargeTranscriptTranslationCharacterLimit { return 900 } @@ -5299,6 +5384,10 @@ final class TranslationSessionStore { } } + private func qualityAdjustedTranslation(_ text: String) -> String { + activeTranslationQualityContext?.applyingTerminology(to: text) ?? text + } + /// Decides whether a translation for the currently presented source may /// replace the displayed one now or has to wait for the translation dwell. /// diff --git a/Sources/AirTranslate/Views/SettingsView.swift b/Sources/AirTranslate/Views/SettingsView.swift index 8c0ef76..74589af 100644 --- a/Sources/AirTranslate/Views/SettingsView.swift +++ b/Sources/AirTranslate/Views/SettingsView.swift @@ -632,6 +632,60 @@ struct SettingsView: View { alignment: session.floatingCaptionTextAlignment ) + SettingsGroup(title: SettingsCopy.presentationQuality) { + if isSessionConfigurationLocked { + SettingsNoticeRow(text: SettingsCopy.captureRunningDisabledReason, systemImage: "pause.circle") + } + + SettingsToggleRow( + title: SettingsCopy.presentationQualityMode, + detail: SettingsCopy.presentationQualityModeDetail, + systemImage: "person.crop.rectangle.badge.checkmark", + isOn: lockedSessionConfigurationBinding($session.isPresentationQualityModeEnabled) + ) + .disabled(isSessionConfigurationLocked) + + if session.isPresentationQualityModeEnabled { + SettingsControlRow( + title: SettingsCopy.presentationContext, + detail: SettingsCopy.presentationContextDetail, + systemImage: "text.book.closed" + ) { + TextField( + SettingsCopy.presentationContextPlaceholder, + text: lockedSessionConfigurationBinding($session.presentationContext), + axis: .vertical + ) + .lineLimit(2...4) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 260, maxWidth: 360) + .disabled(isSessionConfigurationLocked) + } + + SettingsControlRow( + title: SettingsCopy.presentationGlossary, + detail: SettingsCopy.presentationGlossaryDetail, + systemImage: "character.book.closed" + ) { + TextField( + SettingsCopy.presentationGlossaryPlaceholder, + text: lockedSessionConfigurationBinding($session.presentationGlossary), + axis: .vertical + ) + .lineLimit(4...8) + .font(.callout.monospaced()) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 260, maxWidth: 360) + .disabled(isSessionConfigurationLocked) + } + + SettingsNoticeRow( + text: SettingsCopy.presentationQualityNotice, + systemImage: "checkmark.seal" + ) + } + } + SettingsGroup(title: SettingsCopy.displaySettings) { SettingsControlRow( title: SettingsCopy.displayContent, @@ -1484,6 +1538,66 @@ private enum SettingsCopy { korean: "로컬 우선 동작과 개인정보 안내를 확인합니다." ) static let modeSettings = AppText.localized(english: "Mode Settings", korean: "모드 설정") + static let presentationQuality = AppText.localized( + english: "Presentation Quality", + korean: "프레젠테이션 품질", + japanese: "プレゼンテーション品質", + chineseSimplified: "演示质量" + ) + static let presentationQualityMode = AppText.localized( + english: "Presentation Quality Mode", + korean: "프레젠테이션 품질 모드", + japanese: "プレゼンテーション品質モード", + chineseSimplified: "演示质量模式" + ) + static let presentationQualityModeDetail = AppText.localized( + english: "Starts with audience defaults: translation only, two large steady lines, left aligned, with smarter clause timing.", + korean: "번역만 표시되는 크고 안정적인 두 줄, 왼쪽 정렬, 더 자연스러운 구절 타이밍을 청중용 기본값으로 적용합니다.", + japanese: "翻訳のみ・大きな2行・安定表示・左揃え・自然な節のタイミングを観客向け初期設定として適用します。", + chineseSimplified: "以观众字幕默认设置开始:仅译文、两行大字、稳定左对齐,并采用更自然的分句时机。" + ) + static let presentationContext = AppText.localized( + english: "Talk Context", + korean: "발표 맥락", + japanese: "講演の背景", + chineseSimplified: "演讲背景" + ) + static let presentationContextDetail = AppText.localized( + english: "Describe the audience and topic in up to 1,000 characters. GPT text translation sends this to OpenAI to choose meaning and tone.", + korean: "청중과 주제를 1,000자 이내로 설명하세요. GPT 텍스트 번역은 의미와 어조를 선택하기 위해 이 내용을 OpenAI로 전송합니다.", + japanese: "聴衆とテーマを1,000文字以内で説明します。GPTテキスト翻訳では意味と語調を選ぶため、この内容をOpenAIへ送信します。", + chineseSimplified: "用不超过 1,000 个字符描述听众和主题。GPT 文本翻译会将其发送至 OpenAI,以选择合适的语义和语气。" + ) + static let presentationContextPlaceholder = AppText.localized( + english: "Example: Practical AI workshop for Japanese ecommerce business owners", + korean: "예: 일본 이커머스 사업자를 위한 실전 AI 워크숍", + japanese: "例:日本のEC事業者向け実践AIワークショップ", + chineseSimplified: "例如:面向日本电商经营者的实用 AI 工作坊" + ) + static let presentationGlossary = AppText.localized( + english: "Terminology Glossary", + korean: "용어집", + japanese: "用語集", + chineseSimplified: "术语表" + ) + static let presentationGlossaryDetail = AppText.localized( + english: "Up to 50 lines; 120 characters per term. Add likely speech-recognition mistakes as separate aliases.", + korean: "최대 50줄, 용어당 120자까지 입력할 수 있습니다. 예상되는 음성 인식 오류는 별칭으로 따로 추가하세요.", + japanese: "最大50行、各用語120文字までです。想定される音声認識ミスは別名として追加してください。", + chineseSimplified: "最多 50 行,每个术语不超过 120 个字符。可能的语音识别错误请另加一行作为别名。" + ) + static let presentationGlossaryPlaceholder = AppText.localized( + english: "Gary Hong = ゲイリー・ウォン\nAI for Business = ビジネス向けAI", + korean: "Gary Hong = 게리 웡\nAI for Business = 비즈니스용 AI", + japanese: "Gary Hong = ゲイリー・ウォン\nAI for Business = ビジネス向けAI", + chineseSimplified: "Gary Hong = Gary Wong\nAI for Business = 商业 AI" + ) + static let presentationQualityNotice = AppText.localized( + english: "Cloud note: GPT text translation sends the context and glossary to OpenAI. Meta Scribe receives glossary source terms as recognition hints. Apple processing stays on this Mac.", + korean: "클라우드 안내: GPT 텍스트 번역은 맥락과 용어집을 OpenAI로 전송합니다. Meta Scribe는 원문 용어를 인식 힌트로 받습니다. Apple 처리는 이 Mac에서만 실행됩니다.", + japanese: "クラウド利用について:GPTテキスト翻訳では背景と用語集をOpenAIへ送信します。Meta Scribeには原語を認識ヒントとして渡します。Apple処理はこのMac内で行われます。", + chineseSimplified: "云端说明:GPT 文本翻译会将背景和术语表发送至 OpenAI;Meta Scribe 会接收原词作为识别提示;Apple 处理仅在本机进行。" + ) static let processingEngine = AppText.localized(english: "Processing Mode", korean: "처리 방식") static let processingEngineDetail = AppText.localized( english: "Choose exactly one active engine: local Apple mode, GPT Realtime, GPT Transcription, Gemini Live, or Meta Scribe.", diff --git a/Tests/AirTranslateCoreTests/FloatingCaptionStabilityTests.swift b/Tests/AirTranslateCoreTests/FloatingCaptionStabilityTests.swift index eb511bd..c053675 100644 --- a/Tests/AirTranslateCoreTests/FloatingCaptionStabilityTests.swift +++ b/Tests/AirTranslateCoreTests/FloatingCaptionStabilityTests.swift @@ -62,6 +62,41 @@ struct FloatingCaptionStabilityTests { #expect(second.floatingCaptionTextAlignment == .leading) } + @Test + @MainActor + func presentationQualityPresetUsesAudienceReadableCaptionsAndPersists() { + let suiteName = "FloatingCaptionStabilityTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let first = TranslationSessionStore( + modelAvailabilityProvider: { _, _ in [:] }, + settingsDefaults: defaults + ) + first.presentationContext = "AI workshop for ecommerce sellers" + first.presentationGlossary = "Gary Hong = ゲイリー・ウォン" + first.isPresentationQualityModeEnabled = true + + #expect(first.floatingCaptionDisplayMode == .translation) + #expect(first.floatingCaptionTextSize == .large) + #expect(first.floatingCaptionLineCount == .two) + #expect(first.floatingCaptionStability == .steady) + #expect(first.floatingCaptionTextAlignment == .leading) + + let second = TranslationSessionStore( + modelAvailabilityProvider: { _, _ in [:] }, + settingsDefaults: defaults + ) + #expect(second.isPresentationQualityModeEnabled) + #expect(second.presentationContext == "AI workshop for ecommerce sellers") + #expect(second.presentationGlossary == "Gary Hong = ゲイリー・ウォン") + #expect(second.floatingCaptionDisplayMode == .translation) + #expect(second.floatingCaptionTextSize == .large) + #expect(second.floatingCaptionLineCount == .two) + #expect(second.floatingCaptionStability == .steady) + #expect(second.floatingCaptionTextAlignment == .leading) + } + @Test func unknownPersistedStabilityFallsBackToDefault() { #expect(FloatingCaptionStability(rawValue: "turbo") == nil) diff --git a/Tests/AirTranslateCoreTests/TranslationQualityContextTests.swift b/Tests/AirTranslateCoreTests/TranslationQualityContextTests.swift new file mode 100644 index 0000000..471db1d --- /dev/null +++ b/Tests/AirTranslateCoreTests/TranslationQualityContextTests.swift @@ -0,0 +1,136 @@ +import Foundation +import Testing +@testable import AirTranslate + +@Suite +struct TranslationQualityContextTests { + @Test + func glossaryParsesFriendlySeparatorsAndIgnoresInvalidLines() { + let context = TranslationQualityContext( + presentationContext: "AI for ecommerce sellers", + glossaryText: """ + # One source alias per line + Gary Hong = ゲイリー・ウォン + Air Translate -> AirTranslate + AI for Business → ビジネス向けAI + FBA => FBA + incomplete line + """ + ) + + #expect(context.glossaryEntries == [ + TranslationGlossaryEntry(source: "Gary Hong", target: "ゲイリー・ウォン"), + TranslationGlossaryEntry(source: "Air Translate", target: "AirTranslate"), + TranslationGlossaryEntry(source: "AI for Business", target: "ビジネス向けAI"), + TranslationGlossaryEntry(source: "FBA", target: "FBA"), + ]) + #expect(context.sourceTerms == ["Gary Hong", "Air Translate", "AI for Business", "FBA"]) + } + + @Test + func glossaryAppliesLongerTermsFirstAndMatchesCaseInsensitively() { + let context = TranslationQualityContext( + presentationContext: "", + glossaryText: """ + AI = AI + AI for Business = ビジネス向けAI + Gary Hong = ゲイリー・ウォン + """ + ) + + let result = context.applyingTerminology( + to: "GARY HONG introduces AI for Business." + ) + + #expect(result == "ゲイリー・ウォン introduces ビジネス向けAI.") + } + + @Test + func glossaryMatchesWholeTermsWithoutCascadingReplacements() { + let context = TranslationQualityContext( + presentationContext: "", + glossaryText: """ + AI = 人工知能 + 人工知能 = AI技術 + C++ = C-plus-plus + """ + ) + + let result = context.applyingTerminology( + to: "We said paid AI works. 人工知能 and C++ work, but C++20 stays." + ) + + #expect(result == "We said paid 人工知能 works. AI技術 and C-plus-plus work, but C++20 stays.") + } + + @Test + func contextAndGlossaryStayWithinCloudInputLimits() { + let acceptedSource = String(repeating: "S", count: TranslationQualityContext.maximumGlossaryTermCharacters) + let acceptedTarget = String(repeating: "T", count: TranslationQualityContext.maximumGlossaryTermCharacters) + let rejectedSource = acceptedSource + "S" + let context = TranslationQualityContext( + presentationContext: String( + repeating: "C", + count: TranslationQualityContext.maximumPresentationContextCharacters + 1 + ), + glossaryText: """ + \(acceptedSource) = \(acceptedTarget) + \(rejectedSource) = target + """ + ) + + #expect(context.presentationContext.count == TranslationQualityContext.maximumPresentationContextCharacters) + #expect(context.glossaryEntries == [ + TranslationGlossaryEntry(source: acceptedSource, target: acceptedTarget), + ]) + } + + @Test + func instructionsAddLiveJapaneseStyleContextAndExactTerminology() { + let japanese = LanguageOption.supported.first { $0.id == "ja-JP" }! + let context = TranslationQualityContext( + presentationContext: "A practical workshop for ecommerce business owners.", + glossaryText: "Gary Hong = ゲイリー・ウォン" + ) + + let instructions = context.enhancing( + instructions: "Translate from English to Japanese.", + target: japanese + ) + + #expect(instructions.contains("live simultaneous interpretation")) + #expect(instructions.contains("polite, natural Japanese")) + #expect(instructions.contains("A practical workshop for ecommerce business owners.")) + #expect(instructions.contains("Gary Hong => ゲイリー・ウォン")) + #expect(instructions.contains("Do not add explanations")) + } + + @Test + func presentationDebounceWaitsForClausesButReleasesFinalTextImmediately() { + #expect(TranslationQualityPolicy.debounceDelay( + isEnabled: false, + isFinal: false, + sourceText: "Today we will discuss AI" + ) == nil) + #expect(TranslationQualityPolicy.debounceDelay( + isEnabled: true, + isFinal: true, + sourceText: "Today we will discuss AI" + ) == 0) + #expect(TranslationQualityPolicy.debounceDelay( + isEnabled: true, + isFinal: false, + sourceText: "Today we will discuss AI." + ) == 120) + #expect(TranslationQualityPolicy.debounceDelay( + isEnabled: true, + isFinal: false, + sourceText: "Today," + ) == 240) + #expect(TranslationQualityPolicy.debounceDelay( + isEnabled: true, + isFinal: false, + sourceText: "Today we will discuss AI" + ) == 420) + } +} diff --git a/script/build_and_run.sh b/script/build_and_run.sh index 0acb18c..d62749a 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -17,19 +17,29 @@ INFO_PLIST="$APP_CONTENTS/Info.plist" ENTITLEMENTS_PATH="$ROOT_DIR/Resources/AirTranslate.entitlements" DEBUG_ENTITLEMENTS_PATH="$ROOT_DIR/Resources/AirTranslate.debug.entitlements" CODE_SIGN_IDENTITY="${CODE_SIGN_IDENTITY:-}" +BUILD_CONFIGURATION="debug" case "$MODE" in --debug|debug) ENTITLEMENTS_PATH="$DEBUG_ENTITLEMENTS_PATH" ;; + --build-only|build-only) + BUILD_CONFIGURATION="release" + ;; esac cd "$ROOT_DIR" -pkill -x "$APP_NAME" >/dev/null 2>&1 || true +case "$MODE" in + --build-only|build-only) + ;; + *) + pkill -x "$APP_NAME" >/dev/null 2>&1 || true + ;; +esac -swift build -BUILD_BINARY="$(swift build --show-bin-path)/$APP_NAME" +swift build --configuration "$BUILD_CONFIGURATION" +BUILD_BINARY="$(swift build --configuration "$BUILD_CONFIGURATION" --show-bin-path)/$APP_NAME" rm -rf "$APP_BUNDLE" mkdir -p "$APP_MACOS" @@ -78,6 +88,9 @@ verify_running_app() { } case "$MODE" in + --build-only|build-only) + echo "Built $APP_BUNDLE" + ;; --reset-permissions|reset-permissions) /usr/bin/defaults delete "$BUNDLE_ID" "AirTranslate.screenRecordingAccessRequestAttempted" >/dev/null 2>&1 || true /usr/bin/tccutil reset ScreenCapture "$BUNDLE_ID" || true @@ -106,7 +119,7 @@ case "$MODE" in verify_running_app ;; *) - echo "usage: $0 [run|--debug|--logs|--telemetry|--verify|--reset-permissions]" >&2 + echo "usage: $0 [run|--build-only|--debug|--logs|--telemetry|--verify|--reset-permissions]" >&2 exit 2 ;; esac diff --git a/script/install_on_this_mac.sh b/script/install_on_this_mac.sh new file mode 100755 index 0000000..0dd86d4 --- /dev/null +++ b/script/install_on_this_mac.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build the privacy-fixed AirTranslate branch locally and install it for the +# current macOS user. No API keys or saved application data are copied. + +REPOSITORY_URL="${AIRTRANSLATE_REPOSITORY_URL:-https://github.com/gary8020/AirTranslate.git}" +SOURCE_REVISION="${AIRTRANSLATE_SOURCE_REVISION:-${AIRTRANSLATE_SOURCE_BRANCH:-distribution/cross-mac-installer}}" +SOURCE_DIR="${AIRTRANSLATE_SOURCE_DIR:-${HOME}/Library/Application Support/AirTranslate Custom Build/source}" +INSTALL_DIR="${AIRTRANSLATE_INSTALL_DIR:-${HOME}/Applications}" +BACKUP_DIR="${AIRTRANSLATE_BACKUP_DIR:-$(dirname "$SOURCE_DIR")/backups}" +APP_NAME="AirTranslate" +APP_SOURCE="$SOURCE_DIR/dist/$APP_NAME.app" +APP_TARGET="$INSTALL_DIR/$APP_NAME.app" +APP_BINARY="$APP_TARGET/Contents/MacOS/$APP_NAME" +BACKUP_PATH="$BACKUP_DIR/$APP_NAME-previous.app" +LOCK_DIR="$(dirname "$SOURCE_DIR")/.installer.lock" +MODE="${1:-install}" +STAGING_DIR="" +LOCK_HELD="false" +SWAP_COMPLETE="false" +INSTALL_COMPLETE="false" +BACKUP_CREATED_THIS_RUN="false" + +fail() { + echo "AirTranslate installer: $*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" +} + +cleanup() { + if [[ "$INSTALL_COMPLETE" != "true" ]]; then + if [[ "$SWAP_COMPLETE" == "true" && -e "$APP_TARGET" ]]; then + rm -rf -- "$APP_TARGET" + fi + if [[ "$BACKUP_CREATED_THIS_RUN" == "true" && -e "$BACKUP_PATH" ]]; then + if [[ -e "$APP_TARGET" ]]; then + rm -rf -- "$APP_TARGET" + fi + mv "$BACKUP_PATH" "$APP_TARGET" || true + fi + fi + if [[ -n "$STAGING_DIR" && -d "$STAGING_DIR" ]]; then + rm -rf -- "$STAGING_DIR" + fi + if [[ "$LOCK_HELD" == "true" ]]; then + rmdir "$LOCK_DIR" >/dev/null 2>&1 || true + fi +} + +trap cleanup EXIT + +check_mac() { + [[ "$(uname -s)" == "Darwin" ]] || fail "this installer runs only on macOS" + [[ "$(uname -m)" == "arm64" ]] || fail "this build currently supports Apple Silicon Macs only" + + local major_version + major_version="$(sw_vers -productVersion | cut -d. -f1)" + [[ "$major_version" =~ ^[0-9]+$ ]] || fail "could not determine the macOS version" + (( major_version >= 26 )) || fail "macOS 26 or later is required" + + require_command git + require_command swift + require_command xcode-select + require_command codesign + require_command ditto + xcode-select -p >/dev/null 2>&1 || fail "install Xcode Command Line Tools first: xcode-select --install" +} + +prepare_source() { + if [[ -e "$SOURCE_DIR" && ! -d "$SOURCE_DIR/.git" ]]; then + fail "$SOURCE_DIR exists but is not an AirTranslate Git checkout" + fi + + if [[ -d "$SOURCE_DIR/.git" ]]; then + local current_repository_url + current_repository_url="$(git -C "$SOURCE_DIR" remote get-url origin)" + [[ "$current_repository_url" == "$REPOSITORY_URL" ]] || + fail "$SOURCE_DIR points to $current_repository_url instead of $REPOSITORY_URL" + [[ -z "$(git -C "$SOURCE_DIR" status --short)" ]] || + fail "$SOURCE_DIR has local changes; move or commit them before updating" + git -C "$SOURCE_DIR" fetch --prune origin "$SOURCE_REVISION" + else + mkdir -p "$(dirname "$SOURCE_DIR")" + git clone --no-checkout "$REPOSITORY_URL" "$SOURCE_DIR" + git -C "$SOURCE_DIR" fetch origin "$SOURCE_REVISION" + fi + + git -C "$SOURCE_DIR" switch --detach FETCH_HEAD + + if [[ "$SOURCE_REVISION" =~ ^[0-9a-fA-F]{40}$ ]]; then + local expected_revision + local actual_revision + expected_revision="$(printf '%s' "$SOURCE_REVISION" | tr '[:upper:]' '[:lower:]')" + actual_revision="$(git -C "$SOURCE_DIR" rev-parse HEAD)" + [[ "$actual_revision" == "$expected_revision" ]] || + fail "fetched $actual_revision instead of requested commit $expected_revision" + fi +} + +acquire_lock() { + mkdir -p "$(dirname "$SOURCE_DIR")" + mkdir "$LOCK_DIR" 2>/dev/null || fail "another AirTranslate install is already running" + LOCK_HELD="true" +} + +install_app() { + mkdir -p "$INSTALL_DIR" + + STAGING_DIR="$(mktemp -d "$INSTALL_DIR/.airtranslate-install.XXXXXX")" + + ( + cd "$SOURCE_DIR" + ./script/build_and_run.sh --build-only + ) + + [[ -d "$APP_SOURCE" ]] || fail "build completed without producing $APP_SOURCE" + ditto "$APP_SOURCE" "$STAGING_DIR/$APP_NAME.app" + codesign --verify --deep --strict "$STAGING_DIR/$APP_NAME.app" + + pkill -x "$APP_NAME" >/dev/null 2>&1 || true + wait_for_process_to_stop + + if [[ -e "$APP_TARGET" ]]; then + mkdir -p "$BACKUP_DIR" + rm -rf -- "$BACKUP_PATH" + mv "$APP_TARGET" "$BACKUP_PATH" + BACKUP_CREATED_THIS_RUN="true" + echo "Previous app preserved at $BACKUP_PATH" + fi + + mv "$STAGING_DIR/$APP_NAME.app" "$APP_TARGET" + SWAP_COMPLETE="true" + rm -rf -- "$STAGING_DIR" + STAGING_DIR="" +} + +wait_for_process_to_stop() { + local attempt + for ((attempt = 0; attempt < 50; attempt++)); do + pgrep -x "$APP_NAME" >/dev/null 2>&1 || return 0 + sleep 0.2 + done + fail "$APP_NAME did not stop; close it and run the installer again" +} + +open_and_verify_app() { + local attempt + local pid + local command + + open "$APP_TARGET" + for ((attempt = 0; attempt < 50; attempt++)); do + pid="$(pgrep -n -x "$APP_NAME" || true)" + if [[ -n "$pid" ]]; then + command="$(ps -p "$pid" -o command=)" + [[ "$command" == "$APP_BINARY" ]] || fail "a different AirTranslate build is running: $command" + return 0 + fi + sleep 0.2 + done + fail "AirTranslate was installed but did not open" +} + +check_mac + +if [[ "$MODE" == "--check" || "$MODE" == "check" ]]; then + echo "Compatible Mac detected. AirTranslate can be installed for this user." + exit 0 +fi + +if [[ "$MODE" != "install" && "$MODE" != "--no-launch" && "$MODE" != "no-launch" ]]; then + fail "usage: $0 [install|--check|--no-launch]" +fi + +acquire_lock +prepare_source +install_app + +echo "Installed $APP_TARGET" +echo "Source commit: $(git -C "$SOURCE_DIR" rev-parse HEAD)" + +if [[ "$MODE" == "install" ]]; then + open_and_verify_app + INSTALL_COMPLETE="true" + echo "AirTranslate opened. Approve Microphone and Speech Recognition if macOS asks." + echo "After an update, macOS may ask again if this Mac does not have a persistent signing identity." +else + INSTALL_COMPLETE="true" +fi