项目文件夹

文件
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 行
29 KiB
Swift

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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
}
}