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:
2026-09-01 11:28:07 -07:00
co-authored by Claude Opus 5
parent 39002336c0
commit 5d51c5bf77
101 changed files with 3629 additions and 1354 deletions
+170 -83
View File
@@ -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() }
}