Files
OSGKeyboard/OSGKeyboardMac/MacSettingsView.swift
T
Rocky 9f308fadd2 feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish
Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
2026-08-13 01:00:51 +08:00

619 lines
25 KiB
Swift

// MacSettingsView.swift
// OSGKeyboard · Mac
//
// Settings uses native grouped `Form`. LLM / ASR provider blocks use a merged
// `MacSettingsProviderCard` with responsive rows and theme-aware controls.
import SwiftUI
#if os(macOS)
import AppKit
#endif
struct MacSettingsView: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
@AppStorage(MacAppearancePreference.storageKey)
private var appearanceRaw = MacAppearancePreference.system.rawValue
@AppStorage(MacOnboardingState.storageKey)
private var hasCompletedMacOnboarding = true
@State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
private var lang: AppUILanguage { viewModel.config.uiLanguage }
private let recognitionLocales: [(id: String, key: String, fallback: String)] = [
("auto", "locale.auto", "Auto"),
("zh-Hans", "locale.zh-Hans", "Chinese (Simplified)"),
("zh-Hant", "locale.zh-Hant", "Chinese (Traditional)"),
("en-US", "locale.en-US", "English (US)"),
("ja-JP", "locale.ja-JP", "Japanese"),
("ko-KR", "locale.ko-KR", "Korean")
]
var body: some View {
NavigationStack {
VStack(spacing: 0) {
MacPageHeader(
title: MacL10n.string("mac.section.settings", language: lang),
subtitle: MacL10n.string("mac.page.settings.subtitle", language: lang)
)
ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
supportSection
generalSection
recognitionSection
if viewModel.config.engineMode == "cloud" {
asrProviderSection
.transition(.opacity)
}
if viewModel.config.engineMode == "local" {
MacLocalASRModelSettingsView(viewModel: viewModel)
.transition(.opacity)
}
polishProviderSection
inputSection
legalSection
}
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.bottom, Spacing.md)
}
.tint(palette.accent)
.scrollContentBackground(.hidden)
.background(palette.background)
}
.background(palette.background)
}
.onAppear { refreshAccessibilityState() }
}
// MARK: - General
private var generalSection: some View {
MacSettingsSection(title: MacL10n.string("mac.settings.general", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
MacProviderSettingRow(title: MacL10n.string("mac.settings.appearance", language: lang)) {
MacInlinePicker(
selection: $appearanceRaw,
options: MacAppearancePreference.allCases.map {
MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang))
},
fillsWidth: true
)
}
MacProviderSettingRow(title: MacL10n.string("mac.settings.interfaceLanguage", language: lang)) {
MacInlinePicker(
selection: interfaceLanguageBinding,
options: AppUILanguage.allCases.map {
MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang))
},
fillsWidth: true
)
}
MacProviderSettingRow(title: MacL10n.string("mac.settings.recognitionLanguage", language: lang)) {
MacInlinePicker(
selection: recognitionLanguageBinding,
options: recognitionLocales.map {
MacInlinePickerOption(value: $0.id, label: localeLabel($0))
},
fillsWidth: true
)
}
MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang)
}
}
}
// MARK: - Polish LLM
private var polishProviderSection: some View {
MacSettingsSection(title: MacL10n.string("mac.settings.polishProvider", language: lang)) {
MacSettingsProviderCard {
MacProviderPickerRow(
title: MacL10n.string("mac.settings.service", language: lang),
providers: viewModel.polishSelectableProviders,
selection: polishProviderBinding
)
MacCredentialField(
title: MacL10n.string("mac.settings.apiKey", language: lang),
placeholder: "sk-…",
text: $viewModel.config.apiKey,
isSecret: true
)
MacCredentialField(
title: MacL10n.string("mac.settings.baseURL", language: lang),
placeholder: currentPolishProvider.defaultBaseURL,
text: $viewModel.config.baseURL
)
MacProviderModelRow(
title: MacL10n.string("mac.settings.model", language: lang),
placeholder: currentPolishProvider.defaultModel,
model: $viewModel.config.model,
providerIdentity: viewModel.config.providerId,
endpointIdentity: viewModel.config.baseURL,
apiKey: viewModel.config.apiKey,
makeFetchModelsRequest: makeMacLLMModelsRequest,
language: lang
)
.id(viewModel.config.providerId)
MacProviderThinkingRow(
title: MacL10n.string("mac.settings.thinking", language: lang),
subtitle: MacL10n.string("mac.settings.thinkingSubtitle", language: lang),
isOn: $viewModel.config.llmThinkingEnabled
)
MacProviderToolsRow(
title: MacL10n.string("mac.settings.connectionCheck", language: lang),
providerIdentity: viewModel.config.providerId,
endpointIdentity: viewModel.config.baseURL,
credentialIdentity: viewModel.config.apiKey,
modelIdentity: viewModel.config.model,
makeValidateRequest: makeMacLLMValidateRequest,
language: lang
)
MacProviderSettingRow(title: MacL10n.string("mac.settings.translation", language: lang)) {
MacInlinePicker(
selection: translationTargetBinding,
options: TranslationLanguageCatalog.all.map { language in
MacInlinePickerOption(
value: language.id,
label: translationLabel(for: language)
)
},
fillsWidth: true
)
}
}
}
}
// MARK: - Cloud ASR
private var asrProviderSection: some View {
MacSettingsSection(title: MacL10n.string("mac.settings.asrProvider", language: lang)) {
MacSettingsProviderCard {
MacProviderPickerRow(
title: MacL10n.string("mac.settings.asrService", language: lang),
providers: viewModel.asrSelectableProviders,
selection: asrProviderBinding
)
if viewModel.config.asrProviderId == "volcengine" {
volcengineAsrRows
} else {
genericAsrRows
}
MacProviderToolsRow(
title: MacL10n.string("mac.settings.connectionCheck", language: lang),
providerIdentity: viewModel.config.asrProviderId,
endpointIdentity: viewModel.config.asrBaseURL,
credentialIdentity: viewModel.config.asrApiKey,
modelIdentity: viewModel.config.asrModel,
makeValidateRequest: makeMacASRValidateRequest,
language: lang
)
}
}
}
@ViewBuilder
private var volcengineAsrRows: some View {
MacProviderThinkingRow(
title: MacL10n.string("mac.settings.volcengineApiKeyMode", language: lang),
subtitle: MacL10n.string("mac.settings.volcengineApiKeyModeSubtitle", language: lang),
isOn: Binding(
get: { macVolcengineFields.usesAPIKeyAuth },
set: { updateMacVolcengine(authMode: $0 ? .apiKey : .appToken) }
)
)
if macVolcengineFields.usesAPIKeyAuth {
MacCredentialField(
title: MacL10n.string("mac.settings.volcengineApiKey", language: lang),
placeholder: "API Key",
text: Binding(
get: { macVolcengineFields.apiKeyCredential },
set: { updateMacVolcengine(apiKeyCredential: $0) }
),
isSecret: true
)
} else {
MacCredentialField(
title: MacL10n.string("mac.settings.volcengineAppId", language: lang),
placeholder: "APP ID",
text: Binding(
get: { macVolcengineFields.appID },
set: { updateMacVolcengine(appID: $0) }
),
isSecret: true
)
MacCredentialField(
title: MacL10n.string("mac.settings.volcengineAccessToken", language: lang),
placeholder: "Access Token",
text: Binding(
get: { macVolcengineFields.accessToken },
set: { updateMacVolcengine(accessToken: $0) }
),
isSecret: true
)
}
}
@ViewBuilder
private var genericAsrRows: some View {
if CloudASRModelCatalog.showsASREndpointField(for: viewModel.config.asrProviderId) {
MacCredentialField(
title: MacL10n.string("mac.settings.baseURL", language: lang),
placeholder: currentAsrProvider.defaultBaseURL,
text: $viewModel.config.asrBaseURL
)
}
MacCredentialField(
title: MacL10n.string("mac.settings.asrApiKey", language: lang),
placeholder: "sk-…",
text: $viewModel.config.asrApiKey,
isSecret: true
)
MacProviderModelRow(
title: MacL10n.string("mac.settings.asrModel", language: lang),
placeholder: CloudASRModelCatalog.defaultModel(for: viewModel.config.asrProviderId),
model: $viewModel.config.asrModel,
providerIdentity: viewModel.config.asrProviderId,
endpointIdentity: viewModel.config.asrBaseURL,
apiKey: viewModel.config.asrApiKey,
makeFetchModelsRequest: makeMacASRModelsRequest,
language: lang
)
.id(viewModel.config.asrProviderId)
}
// MARK: - Recognition method
private var recognitionSection: some View {
MacSettingsSection(title: MacL10n.string("mac.settings.recognition", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
methodRow(
title: MacL10n.string("mac.settings.cloudEngine", language: lang),
subtitle: MacL10n.string("mac.settings.cloudEngineDesc", language: lang),
systemImage: "cloud",
selected: viewModel.config.engineMode == "cloud"
) { withAnimation(Motion.soft) { viewModel.setEngineMode("cloud") } }
methodRow(
title: MacL10n.string("mac.settings.localEngine", language: lang),
subtitle: MacL10n.string("mac.settings.localEngineDesc", language: lang),
systemImage: "cpu",
selected: viewModel.config.engineMode == "local"
) { withAnimation(Motion.soft) { viewModel.setEngineMode("local") } }
}
}
}
// MARK: - Hotkey / paste
private var inputSection: some View {
MacSettingsSection(title: MacL10n.string("mac.settings.input", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
MacProviderSettingRow(title: MacL10n.string("mac.settings.hotkeyTrigger", language: lang)) {
MacInlinePicker(
selection: hotkeyTriggerBinding,
options: MacHotkeyTrigger.allCases.map {
MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang))
},
fillsWidth: true
)
}
MacProviderSettingRow(
title: MacL10n.string("mac.settings.autoPaste", language: lang),
verticalAlignment: .center
) {
Toggle("", isOn: autoPasteBinding)
.labelsHidden()
.toggleStyle(MacToggleStyle())
}
MacProviderSettingRow(
title: MacL10n.string("mac.settings.accessibility", language: lang),
verticalAlignment: .center
) {
HStack(spacing: Spacing.sm) {
MacSettingsToolButton(title: MacL10n.string("mac.settings.openAccessibility", language: lang)) {
openAccessibilitySettings()
}
Label(
accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded,
systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle"
)
.font(TypeStyle.caption)
.foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning)
.contentTransition(.opacity)
.animation(Motion.quick, value: accessibilityTrusted)
}
}
}
}
}
// MARK: - Support developer
private var supportSection: some View {
MacSettingsSection(
title: MacL10n.string("tip.title", language: lang),
footer: MacL10n.string("tip.consumableNotice", language: lang)
) {
MacSupportDeveloperTipRows(language: lang)
}
}
// MARK: - Legal
private var legalSection: some View {
MacSettingsSection(title: MacL10n.string("mac.settings.about", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
NavigationLink {
MacPrivacyPolicyView(uiLanguage: lang)
} label: {
MacFormLinkRow(title: MacL10n.string("mac.settings.privacyPolicy", language: lang))
}
.buttonStyle(.plain)
NavigationLink {
MacOpenSourceLicensesView(uiLanguage: lang)
} label: {
MacFormLinkRow(title: MacL10n.string("mac.settings.thirdPartyLicenses", language: lang))
}
.buttonStyle(.plain)
Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) {
hasCompletedMacOnboarding = false
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading)
.padding(.horizontal, MacMetrics.settingsCardInset)
// Read-only version row at the bottom of About.
MacInlineRow(title: MacL10n.string("mac.settings.version", language: lang)) {
Text(AppVersionDisplay.detailedLabel)
.font(MacSettingsType.rowLabel)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.trailing)
}
.accessibilityElement(children: .combine)
}
}
}
// MARK: - Row helpers
private func methodRow(
title: String,
subtitle: String,
systemImage: String,
selected: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(spacing: Spacing.sm) {
Image(systemName: systemImage)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
.frame(width: 26)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.foregroundStyle(palette.textPrimary)
Text(subtitle)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: Spacing.sm)
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
.contentTransition(.symbolEffect(.replace))
}
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
.animation(Motion.quick, value: selected)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private var currentPolishProvider: LLMProvider {
viewModel.polishSelectableProviders.first { $0.id == viewModel.config.providerId }
?? viewModel.polishSelectableProviders.first
?? LLMProvider.presets[0]
}
private var currentAsrProvider: LLMProvider {
viewModel.asrSelectableProviders.first { $0.id == viewModel.config.asrProviderId }
?? viewModel.asrSelectableProviders.first
?? LLMProvider.presets[0]
}
private var macVolcengineFields: VolcengineASRFields {
VolcengineASRFields.parse(apiKey: viewModel.config.asrApiKey)
}
private func updateMacVolcengine(
authMode: VolcengineASRAuthMode? = nil,
appID: String? = nil,
accessToken: String? = nil,
apiKeyCredential: String? = nil
) {
var fields = macVolcengineFields
if let authMode { fields.authMode = authMode }
if let appID { fields.appID = appID }
if let accessToken { fields.accessToken = accessToken }
if let apiKeyCredential { fields.apiKeyCredential = apiKeyCredential }
viewModel.config.asrModel = VolcengineASRFields.fixedResourceID
viewModel.config.asrApiKey = fields.encodedAPIKey
}
@MainActor
private func makeMacLLMValidateRequest() -> ProviderToolRequest<Void> {
let providerID = viewModel.config.providerId
let baseURL = viewModel.config.baseURL
let apiKey = viewModel.config.apiKey
let model = viewModel.config.model
let thinkingEnabled = viewModel.config.llmThinkingEnabled
return ProviderToolRequest(providerIdentity: providerID) {
let client = LLMClientFactory.make(
providerId: providerID,
baseURL: baseURL,
apiKey: apiKey,
model: model,
thinkingEnabled: thinkingEnabled
)
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
}
}
@MainActor
private func makeMacLLMModelsRequest() -> ProviderToolRequest<[String]> {
let providerID = viewModel.config.providerId
let baseURL = viewModel.config.baseURL
let apiKey = viewModel.config.apiKey
let currentModel = viewModel.config.model
return ProviderToolRequest(providerIdentity: providerID) {
try await ProviderModelService.listLLMModels(
providerId: providerID,
baseURL: baseURL,
apiKey: apiKey,
currentModel: currentModel
)
}
}
@MainActor
private func makeMacASRValidateRequest() -> ProviderToolRequest<Void> {
let persisted = AppGroupStore(defaults: viewModel.defaults)
let store = LiveConfigurationStore(config: viewModel.config, fallback: persisted)
let providerID = viewModel.config.asrProviderId
return ProviderToolRequest(providerIdentity: providerID) {
try await CloudASRConnectionCheck.validate(store: store)
}
}
@MainActor
private func makeMacASRModelsRequest() -> ProviderToolRequest<[String]> {
let providerID = viewModel.config.asrProviderId
let baseURL = viewModel.config.asrBaseURL
let apiKey = viewModel.config.asrApiKey
let currentModel = viewModel.config.asrModel
return ProviderToolRequest(providerIdentity: providerID) {
try await ProviderModelService.listASRModels(
providerId: providerID,
baseURL: baseURL,
apiKey: apiKey,
currentModel: currentModel
)
}
}
// MARK: - Bindings
private var interfaceLanguageBinding: Binding<String> {
Binding(
get: { viewModel.config.uiLanguage.rawValue },
set: { viewModel.config.uiLanguage = AppUILanguage(rawValue: $0) ?? .auto }
)
}
private var polishProviderBinding: Binding<String> {
Binding(
get: { viewModel.config.providerId },
set: { newId in
if let provider = viewModel.polishSelectableProviders.first(where: { $0.id == newId }) {
viewModel.selectProvider(provider)
}
}
)
}
private var asrProviderBinding: Binding<String> {
Binding(
get: { viewModel.config.asrProviderId },
set: { newId in
if let provider = viewModel.asrSelectableProviders.first(where: { $0.id == newId }) {
viewModel.selectAsrProvider(provider)
}
}
)
}
private var recognitionLanguageBinding: Binding<String> {
Binding(
get: {
let current = viewModel.config.localeId
return current.isEmpty ? "auto" : current
},
set: { newValue in
viewModel.config.localeId = newValue == "auto" ? "" : newValue
}
)
}
private var hotkeyTriggerBinding: Binding<String> {
Binding(
get: { viewModel.hotkeyTrigger.rawValue },
set: { viewModel.setHotkeyTrigger(MacHotkeyTrigger(rawValue: $0) ?? .rightOption) }
)
}
private var autoPasteBinding: Binding<Bool> {
Binding(
get: { viewModel.autoPasteEnabled },
set: { viewModel.setAutoPasteEnabled($0) }
)
}
private var translationTargetBinding: Binding<String> {
Binding(
get: { viewModel.config.translationTargetLocaleId },
set: { viewModel.config.translationTargetLocaleId = $0 }
)
}
private func translationLabel(for language: TranslationLanguage) -> String {
if TranslationLanguageCatalog.isOff(language.id) {
return MacL10n.string("mac.settings.translationOff", language: lang)
}
return language.nativeName
}
// MARK: - AppKit actions (macOS only)
private func openAccessibilitySettings() {
#if os(macOS)
_ = MacTextInsertionService.requestAccessibilityIfNeeded()
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
NSWorkspace.shared.open(url)
}
refreshAccessibilityState()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
refreshAccessibilityState()
}
#endif
}
private func refreshAccessibilityState() {
accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
}
private func localeLabel(_ locale: (id: String, key: String, fallback: String)) -> String {
let resolved = AppUILanguage.localizedString(
locale.key,
tableName: nil,
bundle: .main,
language: lang
)
return resolved == locale.key ? locale.fallback : resolved
}
private var accessibilityStatusGranted: String {
lang.resolvedLanguageCode().hasPrefix("zh") ? "已授权" : "Granted"
}
private var accessibilityStatusNeeded: String {
lang.resolvedLanguageCode().hasPrefix("zh") ? "未授权" : "Needed"
}
}