Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

691 lines
29 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import ApplicationServices
import Carbon
import Foundation
import SwiftUI
import MacToolsPluginKit
public final class TranslatorPluginFactory: NSObject, MacToolsPluginBundleFactory {
public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider {
TranslatorPluginProvider(context: context)
}
}
@MainActor
private struct TranslatorPluginProvider: PluginProvider {
let context: PluginRuntimeContext
func makePlugins() -> [any MacToolsPlugin] {
[TranslatorPlugin(context: context)]
}
}
typealias ScreenshotRegionCapturerFactory = @MainActor (@escaping () -> Bool) -> any ScreenshotRegionCapturing
@MainActor
final class TranslatorPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginConfigurationPresenting {
private enum APIKeyState: Equatable {
case unknown
case present
case missing
case error(String)
}
let metadata: PluginMetadata
let primaryPanelDescriptor = PluginPrimaryPanelDescriptor(
controlStyle: .switch,
menuActionBehavior: .keepPresented
)
var onStateChange: (() -> Void)?
var requestPermissionGuidance: ((String) -> Void)?
var shortcutBindingResolver: ((String) -> ShortcutBinding?)?
var requestConfigurationPresentation: (() -> Void)?
private let storage: PluginStorage
private let accessibilityTrustProvider: () -> Bool
private let accessibilityTrustRequester: (Bool) -> Bool
private let screenRecordingPermissionProvider: () -> Bool
private let selectTranslationStarter: (() -> Void)?
private let screenshotTranslationStarter: (() -> Void)?
private let secretStore: any TranslatorSecretStoring
private let languagePreferenceStore: LanguagePreferenceStore
private let panelController: any TranslatorPanelControlling
private let selectedTextCapturePipeline: SelectedTextCapturePipeline
private let screenshotRegionCapturer: (any ScreenshotRegionCapturing)?
private let screenshotRegionCapturerFactory: ScreenshotRegionCapturerFactory
private let ocrTextRecognizer: (any OCRTextRecognizing)?
private let translationProviderFactoryOverride: TranslatorProviderFactory?
private let providerProfileStore: TranslatorProviderProfileStore
private let localization: PluginLocalization
private var providerConfiguration: OpenAICompatibleConfiguration
private var providerProfiles: [TranslatorProviderProfile]
private var languagePair: TranslatorLanguagePair
private var cachedAPIKey: String?
private var cachedAPIKeys: [String: String]
private var didLoadProfileAPIKeys: Set<String>
private var didLoadAPIKey = false
private var apiKeyState: APIKeyState
private var coordinator: TranslatorCoordinator?
init(
context: PluginRuntimeContext = PluginRuntimeContext(pluginID: TranslatorConstants.pluginID),
accessibilityTrustProvider: @escaping () -> Bool = AccessibilityCheck.isTrusted,
accessibilityTrustRequester: @escaping (Bool) -> Bool = AccessibilityCheck.requestTrust,
screenRecordingPermissionProvider: @escaping () -> Bool = { CGPreflightScreenCaptureAccess() },
selectTranslationStarter: (() -> Void)? = nil,
screenshotTranslationStarter: (() -> Void)? = nil,
secretStore: any TranslatorSecretStoring = OpenAICompatibleSecretStore(),
panelController: (any TranslatorPanelControlling)? = nil,
selectedTextCapturePipeline: SelectedTextCapturePipeline? = nil,
screenshotRegionCapturer: (any ScreenshotRegionCapturing)? = nil,
screenshotRegionCapturerFactory: ScreenshotRegionCapturerFactory? = nil,
ocrTextRecognizer: (any OCRTextRecognizing)? = nil,
translationProviderFactoryOverride: TranslatorProviderFactory? = nil,
localization: PluginLocalization? = nil
) {
let localization = localization ?? PluginLocalization(bundle: context.resourceBundle)
self.localization = localization
self.metadata = PluginMetadata(
id: TranslatorConstants.pluginID,
title: localization.string("metadata.title", defaultValue: "翻译"),
iconName: "text.bubble",
iconTint: Color(nsColor: .systemBlue),
order: 57,
defaultDescription: localization.string("metadata.description", defaultValue: "划词与截图快捷键翻译")
)
self.storage = context.storage
self.accessibilityTrustProvider = accessibilityTrustProvider
self.accessibilityTrustRequester = accessibilityTrustRequester
self.screenRecordingPermissionProvider = screenRecordingPermissionProvider
self.selectTranslationStarter = selectTranslationStarter
self.screenshotTranslationStarter = screenshotTranslationStarter
self.secretStore = secretStore
self.panelController = panelController ?? TranslatorPanelController(localization: localization)
self.selectedTextCapturePipeline = selectedTextCapturePipeline ?? .live(localization: localization)
self.screenshotRegionCapturer = screenshotRegionCapturer
self.screenshotRegionCapturerFactory = screenshotRegionCapturerFactory ?? { permissionProvider in
ScreenshotRegionCapturer(screenRecordingPermissionProvider: permissionProvider)
}
self.ocrTextRecognizer = ocrTextRecognizer
self.translationProviderFactoryOverride = translationProviderFactoryOverride
let providerProfileStore = TranslatorProviderProfileStore(storage: context.storage, localization: localization)
let providerProfiles = providerProfileStore.loadProfiles()
self.providerProfileStore = providerProfileStore
self.providerProfiles = providerProfiles
let languagePreferenceStore = LanguagePreferenceStore(storage: context.storage)
self.languagePreferenceStore = languagePreferenceStore
self.providerConfiguration = providerProfiles.first?.configuration
?? OpenAICompatibleConfiguration(storage: context.storage, localization: localization)
self.languagePair = languagePreferenceStore.loadPair()
self.cachedAPIKey = nil
self.cachedAPIKeys = [:]
self.didLoadProfileAPIKeys = []
self.apiKeyState = .unknown
self.panelController.onAction = { [weak self] action in
self?.handlePanelAction(action)
}
}
var primaryPanelState: PluginPanelState {
PluginPanelState(
subtitle: panelSubtitle,
isOn: isShortcutEnabled,
isExpanded: false,
isEnabled: true,
isVisible: true,
detail: nil,
errorMessage: nil
)
}
var permissionRequirements: [PluginPermissionRequirement] {
[
PluginPermissionRequirement(
id: TranslatorConstants.PermissionID.accessibility,
kind: .accessibility,
title: localization.string("permission.accessibility.title", defaultValue: "辅助功能授权"),
description: localization.string("permission.accessibility.description", defaultValue: "划词翻译需要读取当前选中文本。")
),
PluginPermissionRequirement(
id: TranslatorConstants.PermissionID.automation,
kind: .automation,
title: localization.string("permission.automation.title", defaultValue: "自动化授权"),
description: localization.string("permission.automation.description", defaultValue: "浏览器划词可能需要允许 MacTools 控制当前浏览器。")
),
PluginPermissionRequirement(
id: TranslatorConstants.PermissionID.screenRecording,
kind: .screenRecording,
title: localization.string("permission.screenRecording.title", defaultValue: "屏幕录制授权"),
description: localization.string("permission.screenRecording.description", defaultValue: "截图翻译需要读取框选区域的屏幕内容。")
),
]
}
var settingsSections: [PluginSettingsSection] { [] }
var shortcutDefinitions: [PluginShortcutDefinition] {
[
PluginShortcutDefinition(
id: TranslatorConstants.ShortcutID.selectTranslation,
title: localization.string("shortcut.selectTranslation.title", defaultValue: "划词翻译"),
description: localization.string("shortcut.selectTranslation.description", defaultValue: "翻译当前选中的文本。"),
actionID: TranslatorConstants.ActionID.selectTranslation,
scope: .global,
defaultBinding: TranslatorConstants.Defaults.selectTranslationShortcut,
isRequired: false
),
PluginShortcutDefinition(
id: TranslatorConstants.ShortcutID.screenshotTranslation,
title: localization.string("shortcut.screenshotTranslation.title", defaultValue: "截图翻译"),
description: localization.string("shortcut.screenshotTranslation.description", defaultValue: "框选截图区域并翻译识别出的文字。"),
actionID: TranslatorConstants.ActionID.screenshotTranslation,
scope: .global,
defaultBinding: TranslatorConstants.Defaults.screenshotTranslationShortcut,
isRequired: false
),
]
}
var configuration: PluginConfiguration? {
PluginConfiguration(description: metadata.defaultDescription, prefersFullHeight: false) { [weak self] _ in
if let self {
TranslatorSettingsView(
profiles: self.providerProfiles,
apiKeys: self.cachedAPIKeys,
languagePair: self.languagePair,
localization: self.localization,
onSave: { [weak self] profiles, apiKeys, languagePair in
self?.saveConfiguration(
profiles: profiles,
apiKeys: apiKeys,
languagePair: languagePair
)
},
onMakeNewProfile: { [weak self] profiles in
self?.providerProfileStore.makeNewProfile(existingProfiles: profiles)
?? TranslatorProviderProfile(
name: self?.localization.string("openAIClient.providerTitle", defaultValue: "OpenAI 翻译")
?? "OpenAI",
isEnabled: false
)
}
)
} else {
EmptyView()
}
}
}
func handleAction(_ action: PluginPanelAction) {
guard case let .setSwitch(isEnabled) = action else {
return
}
storage.set(isEnabled, forKey: TranslatorConstants.StorageKey.shortcutEnabled)
onStateChange?()
}
func permissionState(for permissionID: String) -> PluginPermissionState {
switch permissionID {
case TranslatorConstants.PermissionID.accessibility:
let isGranted = accessibilityTrustProvider()
return PluginPermissionState(
isGranted: isGranted,
footnote: isGranted
? nil
: localization.string(
"permission.accessibility.footnote",
defaultValue: "前往系统设置 → 隐私与安全性 → 辅助功能,授权 MacTools。"
)
)
case TranslatorConstants.PermissionID.automation:
return PluginPermissionState(
isGranted: true,
footnote: localization.string(
"permission.automation.footnote",
defaultValue: "macOS 会在首次控制浏览器时请求自动化授权。"
),
statusText: localization.string("permission.automation.status", defaultValue: "按需确认"),
statusSystemImage: "sparkles",
statusTone: .neutral
)
case TranslatorConstants.PermissionID.screenRecording:
let isGranted = screenRecordingPermissionProvider()
return PluginPermissionState(
isGranted: isGranted,
footnote: isGranted
? nil
: localization.string(
"permission.screenRecording.footnote",
defaultValue: "前往系统设置 → 隐私与安全性 → 屏幕录制,授权 MacTools。"
)
)
default:
return PluginPermissionState(isGranted: true, footnote: nil)
}
}
func handlePermissionAction(id: String) {
switch id {
case TranslatorConstants.PermissionID.accessibility:
_ = accessibilityTrustRequester(true)
onStateChange?()
case TranslatorConstants.PermissionID.automation:
requestPermissionGuidance?(TranslatorConstants.PermissionID.automation)
onStateChange?()
case TranslatorConstants.PermissionID.screenRecording:
NSWorkspace.shared.open(
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture")!
)
onStateChange?()
default:
return
}
}
func handleSettingsAction(id: String) {}
func deactivate(reason: PluginDeactivationReason) {
guard reason.requiresStateCleanup else {
return
}
if let coordinator {
coordinator.close()
} else {
panelController.close()
}
coordinator = nil
}
func handleShortcutAction(id: String) {
guard isShortcutEnabled else {
return
}
switch id {
case TranslatorConstants.ActionID.selectTranslation:
if let selectTranslationStarter {
selectTranslationStarter()
return
}
let coordinator = coordinator ?? makeCoordinator()
self.coordinator = coordinator
coordinator.startSelectTranslation()
case TranslatorConstants.ActionID.screenshotTranslation:
if let screenshotTranslationStarter {
screenshotTranslationStarter()
return
}
let coordinator = coordinator ?? makeCoordinator()
self.coordinator = coordinator
coordinator.startScreenshotTranslation()
default:
return
}
}
private var isShortcutEnabled: Bool {
guard storage.object(forKey: TranslatorConstants.StorageKey.shortcutEnabled) != nil else {
return TranslatorConstants.Defaults.shortcutEnabled
}
return storage.bool(forKey: TranslatorConstants.StorageKey.shortcutEnabled)
}
private var panelSubtitle: String {
if !isShortcutEnabled {
return localization.string("panel.subtitle.shortcutPaused", defaultValue: "快捷键已暂停")
}
if !accessibilityTrustProvider() {
return localization.string("panel.subtitle.permissionRequired", defaultValue: "启用前需要辅助功能授权")
}
if enabledValidProfiles.isEmpty {
return localization.string("panel.subtitle.needsProvider", defaultValue: "需要配置翻译服务")
}
switch apiKeyState {
case .missing, .error:
return localization.string("panel.subtitle.needsProvider", defaultValue: "需要配置翻译服务")
case .unknown, .present:
return localization.string("panel.subtitle.ready", defaultValue: "按 ⌥D 划词,⌥S 截图")
}
}
private var enabledValidProfiles: [TranslatorProviderProfile] {
providerProfiles.filter { $0.isEnabled && $0.validationError == nil }
}
private var hasKnownAPIKey: Bool {
switch apiKeyState {
case .present:
return true
case .unknown:
return (try? secretStore.containsAPIKey()) == true
case .missing, .error:
return false
}
}
func saveConfiguration(
_ configuration: OpenAICompatibleConfiguration,
apiKey: String,
languagePair: TranslatorLanguagePair
) -> String? {
let profile = TranslatorProviderProfile(
id: TranslatorProviderProfile.defaultID,
name: TranslatorProviderProfile.defaultName,
isEnabled: true,
baseURL: configuration.baseURL,
model: configuration.model,
promptTemplate: configuration.promptTemplate
)
return saveConfiguration(
profiles: [profile],
apiKeys: [profile.id: apiKey],
languagePair: languagePair
)
}
func saveConfiguration(
profiles: [TranslatorProviderProfile],
apiKeys: [String: String],
languagePair: TranslatorLanguagePair
) -> String? {
guard languagePair.first != languagePair.second else {
return localization.string("settings.error.sameLanguages", defaultValue: "两种偏好语言不能相同。")
}
guard profiles.contains(where: \.isEnabled) else {
return localization.string("settings.error.noEnabledProvider", defaultValue: "至少启用一个翻译服务。")
}
for profile in profiles where profile.isEnabled {
if let validationError = profile.validationError {
let title = profile.normalizedName.isEmpty
? localization.string("settings.provider.fallbackName", defaultValue: "翻译服务")
: profile.normalizedName
return localization.format(
"settings.error.providerValidationFormat",
defaultValue: "%@%@",
title,
validationError.errorDescription(localization: localization)
)
}
}
do {
// Compute removed profiles before saving because `providerProfiles` still reflects
// the persisted state at this point.
let retainedProfileIDs = Set(profiles.map(\.id))
let removedProfileIDs = providerProfiles
.map(\.id)
.filter { !retainedProfileIDs.contains($0) }
for profile in profiles where profile.isEnabled {
let apiKey = apiKeys[profile.id]
if let normalizedAPIKey = Self.normalizedAPIKey(apiKey) {
try secretStore.saveAPIKey(normalizedAPIKey, forProfileID: profile.id)
cachedAPIKeys[profile.id] = normalizedAPIKey
didLoadProfileAPIKeys.insert(profile.id)
} else if try secretStore.containsAPIKey(forProfileID: profile.id) {
cachedAPIKeys.removeValue(forKey: profile.id)
didLoadProfileAPIKeys.remove(profile.id)
} else if profile.id == TranslatorProviderProfile.defaultID, hasKnownAPIKey {
cachedAPIKeys.removeValue(forKey: profile.id)
didLoadProfileAPIKeys.remove(profile.id)
} else {
apiKeyState = .missing
onStateChange?()
if profiles.count == 1, profile.id == TranslatorProviderProfile.defaultID {
return localization.string("settings.error.blankAPIKey", defaultValue: "API Key 不能为空。")
}
return localization.format(
"settings.error.providerBlankAPIKeyFormat",
defaultValue: "%@API Key 不能为空。",
profile.normalizedName.isEmpty
? localization.string("settings.provider.fallbackName", defaultValue: "翻译服务")
: profile.normalizedName
)
}
}
// Remove stale Keychain credentials and caches for deleted profiles.
for removedID in removedProfileIDs {
try secretStore.deleteAPIKey(forProfileID: removedID)
cachedAPIKeys.removeValue(forKey: removedID)
didLoadProfileAPIKeys.remove(removedID)
}
try providerProfileStore.saveProfiles(profiles)
languagePreferenceStore.savePair(languagePair)
providerProfiles = providerProfileStore.loadProfiles()
providerConfiguration = providerProfiles.first?.configuration
?? OpenAICompatibleConfiguration(
promptTemplate: OpenAICompatibleConfiguration.defaultPromptTemplate(localization: localization)
)
self.languagePair = languagePair
cachedAPIKey = cachedAPIKeys[TranslatorProviderProfile.defaultID]
didLoadAPIKey = didLoadProfileAPIKeys.contains(TranslatorProviderProfile.defaultID)
apiKeyState = .present
if let coordinator {
coordinator.close()
} else {
panelController.close()
}
coordinator = nil
onStateChange?()
return nil
} catch {
return error.localizedDescription
}
}
private func handlePanelAction(_ action: TranslatorPanelAction) {
if action == .openSettings {
requestConfigurationPresentation?()
return
}
if action == .close {
if let coordinator {
coordinator.close()
} else {
panelController.close()
}
return
}
guard let coordinator else { return }
Task { await coordinator.handle(action) }
}
private func makeCoordinator() -> TranslatorCoordinator {
TranslatorCoordinator(
selectedTextCapturePipeline: selectedTextCapturePipeline,
screenshotRegionCapturer: screenshotRegionCapturer
?? screenshotRegionCapturerFactory(screenRecordingPermissionProvider),
ocrTextRecognizer: ocrTextRecognizer ?? VisionOCRTextRecognizer(),
languagePreferenceStore: LanguagePreferenceStore(storage: storage),
providerFactory: translationProviderFactoryOverride ?? { [weak self] in
guard let self else {
return .missing(
message: PluginLocalization(bundle: .main).string(
"panelError.missingProvider",
defaultValue: "请先启用翻译服务"
)
)
}
let resolvedProviders = self.resolvedTranslationProviders()
guard !resolvedProviders.isEmpty else {
return .missing(message: self.localization.string("panelError.missingProvider", defaultValue: "请先启用翻译服务"))
}
return .providers(resolvedProviders)
},
panelController: panelController,
localization: localization
)
}
private func resolvedTranslationProviders() -> [ResolvedTranslationProvider] {
// Capture state before resolution: `loadAPIKey` may update `apiKeyState` early through
// `updateLegacyAPIKeyCacheIfNeeded`. Compare against the pre-resolution value so state
// change notifications are not missed.
let previousState = apiKeyState
let resolved = providerProfiles.filter(\.isEnabled).map { profile -> ResolvedTranslationProvider in
if let validationError = profile.validationError {
return ResolvedTranslationProvider(
id: profile.id,
title: profile.normalizedName.isEmpty
? localization.string("settings.provider.fallbackName", defaultValue: "翻译服务")
: profile.normalizedName,
errorMessage: validationError.errorDescription(localization: localization)
)
}
do {
let apiKey = try loadAPIKey(for: profile.id)
guard let trimmedKey = Self.normalizedAPIKey(apiKey) else {
return ResolvedTranslationProvider(
id: profile.id,
title: profile.normalizedName,
errorMessage: localization.string("panelError.missingAPIKey", defaultValue: "请配置 API Key")
)
}
return ResolvedTranslationProvider(
id: profile.id,
title: profile.normalizedName,
provider: OpenAITranslationProviderAdapter(
client: OpenAICompatibleClient(localization: localization),
configuration: profile.configuration,
apiKey: trimmedKey,
providerTitle: profile.normalizedName
)
)
} catch {
return ResolvedTranslationProvider(
id: profile.id,
title: profile.normalizedName,
errorMessage: userFacingMessage(for: error)
)
}
}
updateAPIKeyState(forResolvedProviders: resolved, previousState: previousState)
return resolved
}
/// Aggregates the overall API-key state after resolving all enabled profiles.
/// A single usable provider counts as present so one profile with a missing key does not make
/// the whole plugin look missing.
private func updateAPIKeyState(
forResolvedProviders resolved: [ResolvedTranslationProvider],
previousState: APIKeyState
) {
guard !resolved.isEmpty else { return }
apiKeyState = resolved.contains { $0.provider != nil } ? .present : .missing
if apiKeyState != previousState {
onStateChange?()
}
}
private func userFacingMessage(for error: Error) -> String {
if let error = error as? OpenAICompatibleClientError {
return error.errorDescription(localization: localization)
}
if let error = error as? OpenAICompatibleConfigurationError {
return error.errorDescription(localization: localization)
}
if let error = error as? TranslationPromptRendererError {
return error.errorDescription(localization: localization)
}
if let error = error as? OpenAICompatibleSecretStoreError {
return error.errorDescription(localization: localization)
}
if let error = error as? TranslatorProviderProfileValidationError {
return error.errorDescription(localization: localization)
}
return error.localizedDescription
}
private static func hasNonEmptyAPIKey(_ apiKey: String?) -> Bool {
apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
private static func normalizedAPIKey(_ apiKey: String?) -> String? {
let trimmedKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmedKey.isEmpty ? nil : trimmedKey
}
private func loadCachedAPIKeyIfNeeded() {
guard !didLoadAPIKey else {
return
}
let previousState = apiKeyState
do {
cachedAPIKey = try secretStore.loadAPIKey()
} catch {
cachedAPIKey = nil
apiKeyState = .error(error.localizedDescription)
didLoadAPIKey = true
if apiKeyState != previousState {
onStateChange?()
}
return
}
didLoadAPIKey = true
apiKeyState = Self.hasNonEmptyAPIKey(cachedAPIKey) ? .present : .missing
if apiKeyState != previousState {
onStateChange?()
}
}
private func loadAPIKey(for profileID: String) throws -> String? {
if didLoadProfileAPIKeys.contains(profileID) {
return cachedAPIKeys[profileID]
}
let profileKey = try secretStore.loadAPIKey(forProfileID: profileID)
if Self.hasNonEmptyAPIKey(profileKey) {
cachedAPIKeys[profileID] = profileKey
didLoadProfileAPIKeys.insert(profileID)
updateLegacyAPIKeyCacheIfNeeded(profileID: profileID, apiKey: profileKey)
return profileKey
}
if profileID == TranslatorProviderProfile.defaultID {
let legacyKey = try secretStore.loadAPIKey()
if Self.hasNonEmptyAPIKey(legacyKey) {
cachedAPIKeys[profileID] = legacyKey
didLoadProfileAPIKeys.insert(profileID)
updateLegacyAPIKeyCacheIfNeeded(profileID: profileID, apiKey: legacyKey)
return legacyKey
}
}
cachedAPIKeys.removeValue(forKey: profileID)
didLoadProfileAPIKeys.insert(profileID)
updateLegacyAPIKeyCacheIfNeeded(profileID: profileID, apiKey: nil)
return nil
}
private func updateLegacyAPIKeyCacheIfNeeded(profileID: String, apiKey: String?) {
guard profileID == TranslatorProviderProfile.defaultID else {
return
}
cachedAPIKey = apiKey
didLoadAPIKey = true
apiKeyState = Self.hasNonEmptyAPIKey(apiKey) ? .present : .missing
}
}