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 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 } }