chore: snapshot uncommitted local work before rebasing onto cloud-agent
Safety commit so the 94 uncommitted files in the working tree (in-flight Account/Mac/Tip work plus the feature-preview recording tooling) survive the move onto cursor/cloud-agent-1787824905651-vxuwc. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,11 @@
|
||||
// The current local runtime defaults to Qwen3 MLX with Apple Speech fallback;
|
||||
// Sherpa identifiers and install records are retained only for migration compatibility.
|
||||
//
|
||||
// Step order mirrors iOS: permissions → engine → the recognition credentials
|
||||
// that engine needs → the polish LLM. Speech recognition and polishing are two
|
||||
// different providers with two different keys (iOS splits them across
|
||||
// `APISetupPage` and `PolishSetupPage`), so they get one step each here too.
|
||||
//
|
||||
// Visual language mirrors the iOS onboarding: an ambient top gradient, a
|
||||
// glowing hero icon, a large title block, and elongated capsule progress
|
||||
// dots — all carried by whitespace and a single accent colour.
|
||||
@@ -23,8 +28,12 @@ private enum MacOnboardingStep: Int, CaseIterable {
|
||||
case microphone
|
||||
case accessibility
|
||||
case engine
|
||||
case cloudAPI
|
||||
/// Cloud *speech recognition* credentials — shown only in cloud mode.
|
||||
case cloudASR
|
||||
/// Local ASR model download — shown only in local mode.
|
||||
case localModel
|
||||
/// Polish / translation / AI LLM credentials — shown in both modes.
|
||||
case polish
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
@@ -32,8 +41,9 @@ private enum MacOnboardingStep: Int, CaseIterable {
|
||||
case .microphone: return "mic.fill"
|
||||
case .accessibility: return "accessibility"
|
||||
case .engine: return "switch.2"
|
||||
case .cloudAPI: return "key.fill"
|
||||
case .cloudASR: return "waveform"
|
||||
case .localModel: return "arrow.down.circle.fill"
|
||||
case .polish: return "wand.and.stars"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,8 +51,6 @@ private enum MacOnboardingStep: Int, CaseIterable {
|
||||
@MainActor
|
||||
private final class MacOnboardingViewModel: ObservableObject {
|
||||
@Published var step: MacOnboardingStep = .welcome
|
||||
@Published var micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||
@Published var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
||||
@Published var catalog: LocalASRCatalogDocument?
|
||||
@Published var installProgress = LocalASRModelInstallProgress.idle
|
||||
@Published var isInstalling = false
|
||||
@@ -67,31 +75,6 @@ private final class MacOnboardingViewModel: ObservableObject {
|
||||
|
||||
func reload() {
|
||||
catalog = try? LocalASRModelCatalog.loadBundled()
|
||||
micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||
accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
||||
}
|
||||
|
||||
func requestMicrophone() {
|
||||
AVCaptureDevice.requestAccess(for: .audio) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openAccessibilitySettings() {
|
||||
_ = MacTextInsertionService.requestAccessibilityIfNeeded()
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
refreshAccessibilitySoon()
|
||||
}
|
||||
|
||||
func refreshAccessibilitySoon() {
|
||||
accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
||||
self?.accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
||||
}
|
||||
}
|
||||
|
||||
func installDefaultModel() {
|
||||
@@ -165,49 +148,62 @@ struct MacOnboardingView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@StateObject private var model = MacOnboardingViewModel()
|
||||
// Microphone and Accessibility are both granted outside the app. Poll for
|
||||
// them rather than sampling once — see `MacPermissionMonitor`.
|
||||
@StateObject private var permissions = MacPermissionMonitor()
|
||||
@State private var contentAppeared = false
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
private var visibleSteps: [MacOnboardingStep] {
|
||||
if viewModel.config.engineMode == "cloud" {
|
||||
return [.welcome, .microphone, .accessibility, .engine, .cloudAPI]
|
||||
}
|
||||
return [.welcome, .microphone, .accessibility, .engine, .localModel]
|
||||
var steps: [MacOnboardingStep] = [.welcome, .microphone, .accessibility, .engine]
|
||||
steps.append(viewModel.config.engineMode == "cloud" ? .cloudASR : .localModel)
|
||||
// Polishing runs on a cloud LLM in both engine modes, so its provider
|
||||
// is configured either way.
|
||||
steps.append(.polish)
|
||||
return steps
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .top) {
|
||||
ZStack(alignment: .top) {
|
||||
GeometryReader { geo in
|
||||
background(height: geo.size.height)
|
||||
}
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: Spacing.xl)
|
||||
|
||||
hero
|
||||
.id(model.step)
|
||||
.transition(stepTransition)
|
||||
|
||||
Spacer(minLength: Spacing.lg)
|
||||
|
||||
progressDots
|
||||
.padding(.bottom, Spacing.lg)
|
||||
|
||||
bottomBar
|
||||
.padding(.horizontal, Spacing.xxxl)
|
||||
.padding(.bottom, Spacing.xxl)
|
||||
VStack(spacing: 0) {
|
||||
// The hero scrolls; the dots and the bottom bar are pinned so
|
||||
// the primary action can never fall below the window edge —
|
||||
// which is exactly what happened at the minimum window size.
|
||||
GeometryReader { scrollGeo in
|
||||
ScrollView {
|
||||
hero
|
||||
.id(model.step)
|
||||
.transition(stepTransition)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Spacing.xl)
|
||||
.frame(minHeight: scrollGeo.size.height, alignment: .center)
|
||||
}
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
progressDots
|
||||
.padding(.bottom, Spacing.lg)
|
||||
|
||||
bottomBar
|
||||
.padding(.horizontal, Spacing.xxxl)
|
||||
.padding(.bottom, Spacing.xxl)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: MacMetrics.windowMinWidth, minHeight: MacMetrics.windowMinHeight)
|
||||
.onAppear {
|
||||
applyDefaults()
|
||||
model.reload()
|
||||
permissions.start()
|
||||
withAnimation(.spring(response: 0.7, dampingFraction: 0.85)) {
|
||||
contentAppeared = true
|
||||
}
|
||||
}
|
||||
.onDisappear { permissions.stop() }
|
||||
}
|
||||
|
||||
// MARK: Background
|
||||
@@ -306,22 +302,24 @@ struct MacOnboardingView: View {
|
||||
featureList
|
||||
case .microphone:
|
||||
permissionCard(
|
||||
isGranted: model.micStatus == .authorized,
|
||||
isGranted: permissions.microphoneStatus == .authorized,
|
||||
grantedText: MacL10n.string("mac.onboarding.microphone.granted", language: lang),
|
||||
neededText: MacL10n.string("mac.onboarding.microphone.needed", language: lang)
|
||||
)
|
||||
case .accessibility:
|
||||
permissionCard(
|
||||
isGranted: model.accessibilityTrusted,
|
||||
isGranted: permissions.isAccessibilityTrusted,
|
||||
grantedText: MacL10n.string("mac.onboarding.accessibility.granted", language: lang),
|
||||
neededText: MacL10n.string("mac.onboarding.accessibility.needed", language: lang)
|
||||
)
|
||||
case .engine:
|
||||
enginePicker
|
||||
case .cloudAPI:
|
||||
cloudAPIFields
|
||||
case .cloudASR:
|
||||
cloudASRFields
|
||||
case .localModel:
|
||||
localModelPanel
|
||||
case .polish:
|
||||
polishFields
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,12 +361,14 @@ struct MacOnboardingView: View {
|
||||
Text(isGranted ? grantedText : neededText)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(cardShape.fill(palette.surface))
|
||||
.animation(Motion.quick, value: isGranted)
|
||||
}
|
||||
|
||||
private var enginePicker: some View {
|
||||
@@ -428,40 +428,94 @@ struct MacOnboardingView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var cloudAPIFields: some View {
|
||||
/// Cloud speech-recognition credentials. Distinct from `polishFields`
|
||||
/// below: this writes the `asr*` config, and its provider list is the
|
||||
/// cloud-ASR allowlist, not the LLM presets.
|
||||
private var cloudASRFields: some View {
|
||||
credentialCard(
|
||||
providerLabel: MacL10n.string("mac.settings.asrService", language: lang),
|
||||
providers: viewModel.asrSelectableProviders,
|
||||
providerSelection: asrProviderBinding,
|
||||
keyLabel: MacL10n.string("mac.settings.asrApiKey", language: lang),
|
||||
key: $viewModel.config.asrApiKey,
|
||||
modelLabel: MacL10n.string("mac.settings.asrModel", language: lang),
|
||||
modelPlaceholder: CloudASRModelCatalog.defaultModel(for: viewModel.config.asrProviderId),
|
||||
model: $viewModel.config.asrModel,
|
||||
hint: MacL10n.string("mac.onboarding.cloud.skipHint", language: lang)
|
||||
)
|
||||
}
|
||||
|
||||
/// Polish / translation / AI LLM credentials — the step that was missing
|
||||
/// on Mac. Onboarding previously offered only the ASR provider list while
|
||||
/// writing into the *polish* config fields, so the two were never both set.
|
||||
private var polishFields: some View {
|
||||
credentialCard(
|
||||
providerLabel: MacL10n.string("mac.settings.service", language: lang),
|
||||
providers: viewModel.polishSelectableProviders,
|
||||
providerSelection: polishProviderBinding,
|
||||
keyLabel: MacL10n.string("mac.settings.apiKey", language: lang),
|
||||
key: $viewModel.config.apiKey,
|
||||
modelLabel: MacL10n.string("mac.settings.model", language: lang),
|
||||
modelPlaceholder: currentPolishProvider.defaultModel,
|
||||
model: $viewModel.config.model,
|
||||
hint: MacL10n.string("mac.onboarding.polish.skipHint", language: lang)
|
||||
)
|
||||
}
|
||||
|
||||
private func credentialCard(
|
||||
providerLabel: String,
|
||||
providers: [LLMProvider],
|
||||
providerSelection: Binding<String>,
|
||||
keyLabel: String,
|
||||
key: Binding<String>,
|
||||
modelLabel: String,
|
||||
modelPlaceholder: String,
|
||||
model modelText: Binding<String>,
|
||||
hint: String
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
fieldLabel(providerLabel)
|
||||
MacInlinePicker(
|
||||
selection: providerBinding,
|
||||
options: viewModel.selectableProviders.map {
|
||||
selection: providerSelection,
|
||||
options: providers.map {
|
||||
MacInlinePickerOption(
|
||||
value: $0.id,
|
||||
label: ProviderDisplayName.name(for: $0.id, language: lang)
|
||||
)
|
||||
}
|
||||
},
|
||||
fillsWidth: true
|
||||
)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-...")) {
|
||||
Text(MacL10n.string("mac.settings.apiKey", language: lang))
|
||||
fieldLabel(keyLabel)
|
||||
SecureField(text: key, prompt: Text(verbatim: "sk-…")) {
|
||||
Text(keyLabel)
|
||||
}
|
||||
.labelsHidden()
|
||||
.macFieldStyle()
|
||||
|
||||
TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) {
|
||||
Text(MacL10n.string("mac.settings.model", language: lang))
|
||||
fieldLabel(modelLabel)
|
||||
TextField(text: modelText, prompt: Text(verbatim: modelPlaceholder)) {
|
||||
Text(modelLabel)
|
||||
}
|
||||
.labelsHidden()
|
||||
.macFieldStyle()
|
||||
|
||||
Label(MacL10n.string("mac.onboarding.cloud.skipHint", language: lang), systemImage: "info.circle")
|
||||
Label(hint, systemImage: "info.circle")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(cardShape.fill(palette.surface))
|
||||
}
|
||||
|
||||
private func fieldLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
|
||||
private var localModelPanel: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
@@ -507,6 +561,7 @@ struct MacOnboardingView: View {
|
||||
Label(MacL10n.string("mac.onboarding.model.skipHint", language: lang), systemImage: "info.circle")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -540,20 +595,27 @@ struct MacOnboardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
Spacer(minLength: Spacing.md)
|
||||
|
||||
primaryButton(primaryButtonTitle, disabled: model.isInstalling && model.step == .localModel) {
|
||||
primaryAction()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 520)
|
||||
// Wide enough that three CJK labels ("上一步 · 暂时跳过 · 打开系统设置")
|
||||
// still fit on one line at the minimum window width.
|
||||
.frame(maxWidth: 640)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func primaryButton(_ titleText: String, disabled: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
// `lineLimit(1)` + `fixedSize` keep the label on one line: without
|
||||
// them a long CJK title ("打开系统设置") wrapped inside the fixed
|
||||
// 44pt pill and spilled outside the capsule.
|
||||
Text(titleText)
|
||||
.font(TypeStyle.headline)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.foregroundStyle(disabled ? palette.textSecondary : palette.textOnAccent)
|
||||
.padding(.horizontal, Spacing.xxl)
|
||||
.frame(minWidth: 150, minHeight: 44)
|
||||
@@ -570,6 +632,8 @@ struct MacOnboardingView: View {
|
||||
Button(action: action) {
|
||||
Text(titleText)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.frame(minHeight: 44)
|
||||
@@ -589,8 +653,9 @@ struct MacOnboardingView: View {
|
||||
case .microphone: return MacL10n.string("mac.onboarding.microphone.title", language: lang)
|
||||
case .accessibility: return MacL10n.string("mac.onboarding.accessibility.title", language: lang)
|
||||
case .engine: return MacL10n.string("mac.onboarding.engine.title", language: lang)
|
||||
case .cloudAPI: return MacL10n.string("mac.onboarding.cloud.title", language: lang)
|
||||
case .cloudASR: return MacL10n.string("mac.onboarding.cloud.title", language: lang)
|
||||
case .localModel: return MacL10n.string("mac.onboarding.model.title", language: lang)
|
||||
case .polish: return MacL10n.string("mac.onboarding.polish.title", language: lang)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,23 +665,24 @@ struct MacOnboardingView: View {
|
||||
case .microphone: return MacL10n.string("mac.onboarding.microphone.subtitle", language: lang)
|
||||
case .accessibility: return MacL10n.string("mac.onboarding.accessibility.subtitle", language: lang)
|
||||
case .engine: return MacL10n.string("mac.onboarding.engine.subtitle", language: lang)
|
||||
case .cloudAPI: return MacL10n.string("mac.onboarding.cloud.subtitle", language: lang)
|
||||
case .cloudASR: return MacL10n.string("mac.onboarding.cloud.subtitle", language: lang)
|
||||
case .localModel: return MacL10n.string("mac.onboarding.model.subtitle", language: lang)
|
||||
case .polish: return MacL10n.string("mac.onboarding.polish.subtitle", language: lang)
|
||||
}
|
||||
}
|
||||
|
||||
private var primaryButtonTitle: String {
|
||||
switch model.step {
|
||||
case .microphone where model.micStatus != .authorized:
|
||||
case .microphone where permissions.microphoneStatus != .authorized:
|
||||
return MacL10n.string("mac.onboarding.microphone.allow", language: lang)
|
||||
case .accessibility where !model.accessibilityTrusted:
|
||||
case .accessibility where !permissions.isAccessibilityTrusted:
|
||||
return MacL10n.string("mac.onboarding.accessibility.open", language: lang)
|
||||
case .cloudAPI:
|
||||
return MacL10n.string("mac.onboarding.finish", language: lang)
|
||||
case .localModel:
|
||||
return MacL10n.string(model.isDefaultModelInstalled ? "mac.onboarding.finish" : "mac.onboarding.skipForNow", language: lang)
|
||||
case .localModel where !model.isDefaultModelInstalled:
|
||||
return MacL10n.string("mac.onboarding.skipForNow", language: lang)
|
||||
default:
|
||||
return isLastStep ? MacL10n.string("mac.onboarding.finish", language: lang) : MacL10n.string("mac.onboarding.next", language: lang)
|
||||
return isLastStep
|
||||
? MacL10n.string("mac.onboarding.finish", language: lang)
|
||||
: MacL10n.string("mac.onboarding.next", language: lang)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,16 +694,34 @@ struct MacOnboardingView: View {
|
||||
return ByteCountFormatter.string(fromByteCount: Int64(model.sizeBytes), countStyle: .file)
|
||||
}
|
||||
|
||||
private var providerBinding: Binding<String> {
|
||||
private var currentPolishProvider: LLMProvider {
|
||||
viewModel.polishSelectableProviders.first { $0.id == viewModel.config.providerId }
|
||||
?? viewModel.polishSelectableProviders.first
|
||||
?? LLMProvider.presets[0]
|
||||
}
|
||||
|
||||
private var polishProviderBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { viewModel.config.providerId },
|
||||
set: { newId in
|
||||
guard let provider = viewModel.selectableProviders.first(where: { $0.id == newId }) else { return }
|
||||
guard let provider = viewModel.polishSelectableProviders
|
||||
.first(where: { $0.id == newId }) else { return }
|
||||
viewModel.selectProvider(provider)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var asrProviderBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { viewModel.config.asrProviderId },
|
||||
set: { newId in
|
||||
guard let provider = viewModel.asrSelectableProviders
|
||||
.first(where: { $0.id == newId }) else { return }
|
||||
viewModel.selectAsrProvider(provider)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Derived
|
||||
|
||||
private var cardShape: RoundedRectangle {
|
||||
@@ -660,7 +744,10 @@ struct MacOnboardingView: View {
|
||||
}
|
||||
|
||||
private var canSkipCurrentStep: Bool {
|
||||
model.step != .welcome && !model.isInstalling
|
||||
guard model.step != .welcome, !model.isInstalling else { return false }
|
||||
// On the local-model step the primary button already reads "Skip for
|
||||
// now"; a second identical button next to it reads as a bug.
|
||||
return !(model.step == .localModel && !model.isDefaultModelInstalled)
|
||||
}
|
||||
|
||||
private var isLastStep: Bool {
|
||||
@@ -675,10 +762,10 @@ struct MacOnboardingView: View {
|
||||
|
||||
private func primaryAction() {
|
||||
switch model.step {
|
||||
case .microphone where model.micStatus != .authorized:
|
||||
model.requestMicrophone()
|
||||
case .accessibility where !model.accessibilityTrusted:
|
||||
model.openAccessibilitySettings()
|
||||
case .microphone where permissions.microphoneStatus != .authorized:
|
||||
permissions.requestMicrophone()
|
||||
case .accessibility where !permissions.isAccessibilityTrusted:
|
||||
permissions.openAccessibilitySettings()
|
||||
default:
|
||||
if isLastStep { finish() } else { goForward() }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user