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:
@@ -172,7 +172,8 @@ struct BottomDictationBar: View {
|
||||
recordControl
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.padding(.top, Spacing.xs)
|
||||
.padding(.bottom, Spacing.sm)
|
||||
// No surface fill — the mic bar sits on the page background so Home
|
||||
// stays flat and the canvas above can stay shorter without a second
|
||||
// floating card competing for height.
|
||||
@@ -219,12 +220,9 @@ struct BottomDictationBar: View {
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.font(MacSettingsType.control)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: MacMetrics.settingsControlHeight)
|
||||
.background(
|
||||
palette.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
.background(palette.surfaceElevated, in: Capsule())
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
@@ -247,16 +245,24 @@ struct BottomDictationBar: View {
|
||||
.animation(Motion.quick, value: viewModel.isRecording)
|
||||
}
|
||||
|
||||
/// 胶囊按钮内、麦克风右侧的快捷键指引:「⌥ 按住 Option」。
|
||||
private var optionHoldHint: some View {
|
||||
HStack(spacing: Spacing.xxs) {
|
||||
Image(systemName: "option")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
Text(MacL10n.string("mac.hint.holdOption.compact", language: lang))
|
||||
.font(TypeStyle.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(palette.textOnAccent.opacity(0.9))
|
||||
.fixedSize()
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private var recordButton: some View {
|
||||
Button(action: viewModel.toggleRecording) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(viewModel.isRecording ? palette.recordRed : palette.accent)
|
||||
.frame(width: 52, height: 52)
|
||||
.shadow(
|
||||
color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.35),
|
||||
radius: pulse ? 10 : 5
|
||||
)
|
||||
// 胶囊按钮:左侧麦克风(录音时为波形),右侧「按住 Option」提示。
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Group {
|
||||
if viewModel.isRecording {
|
||||
MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent)
|
||||
@@ -266,8 +272,25 @@ struct BottomDictationBar: View {
|
||||
.foregroundStyle(palette.textOnAccent)
|
||||
}
|
||||
}
|
||||
.frame(width: 24)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.7)))
|
||||
|
||||
if !viewModel.isRecording {
|
||||
optionHoldHint
|
||||
.transition(.opacity.combined(with: .move(edge: .trailing)))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(height: 52)
|
||||
.background(
|
||||
(viewModel.isRecording ? palette.recordRed : palette.accent),
|
||||
in: Capsule()
|
||||
)
|
||||
.shadow(
|
||||
color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.35),
|
||||
radius: pulse ? 10 : 5
|
||||
)
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(viewModel.isProcessing)
|
||||
|
||||
@@ -46,5 +46,7 @@
|
||||
<string>OSGKeyboard uses the microphone to transcribe your voice via your configured cloud speech provider.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>OSGKeyboard uses on-device speech recognition for local dictation mode.</string>
|
||||
<key>OSGPrivateKeychainAccessGroup</key>
|
||||
<string>$(AppIdentifierPrefix)com.osgkeyboard.mac</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// MacAccountAppleButton.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Sign in with Apple for the menu-bar app. Sign-in and delete-account
|
||||
// reauthentication share one button, exactly like the iOS account center.
|
||||
// Nonce generation and credential validation come from the cross-platform
|
||||
// `AccountAppleNonce` / `AccountAppleAuthorizationPayload` factories, so the
|
||||
// security-relevant path is identical on both platforms — only the labels,
|
||||
// which resolve from Shared.strings here instead of the iOS app bundle, differ.
|
||||
//
|
||||
// This works in a non-sandboxed Developer ID build: Sign in with Apple needs
|
||||
// only the `com.apple.developer.applesignin` entitlement, never the Mac App
|
||||
// Store. The Mac App ID must be grouped under the iOS primary App ID or the
|
||||
// same person authenticates as a different Apple `sub` here than on iPhone.
|
||||
|
||||
import AuthenticationServices
|
||||
import SwiftUI
|
||||
|
||||
struct MacAccountAppleButton: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@EnvironmentObject private var coordinator: AccountSessionCoordinator
|
||||
|
||||
let purpose: AccountAppleAuthorizationPurpose
|
||||
let language: AppUILanguage
|
||||
var onSignedIn: () -> Void = {}
|
||||
var onDeleted: () -> Void = {}
|
||||
|
||||
@State private var rawNonce: String?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if purpose == .signIn, coordinator.operation == .signingIn {
|
||||
loadingButton
|
||||
} else {
|
||||
SignInWithAppleButton(
|
||||
purpose == .signIn ? .signIn : .continue,
|
||||
onRequest: prepareRequest,
|
||||
onCompletion: completeAuthorization
|
||||
)
|
||||
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
|
||||
// 系统按钮的文字/图标随按钮高度缩放,40pt 时文字明显偏小。
|
||||
.frame(height: MacMetrics.appleButtonHeight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
|
||||
.accessibilityIdentifier(
|
||||
purpose == .signIn
|
||||
? "mac.account.signIn.apple"
|
||||
: "mac.account.delete.reauthenticate"
|
||||
)
|
||||
}
|
||||
}
|
||||
// Fixed, not `maxWidth`: inside a settings row the label column is also
|
||||
// flexible, and two flexible siblings split the row evenly — which
|
||||
// shrank the button and squeezed the copy next to it.
|
||||
.frame(width: 260)
|
||||
}
|
||||
|
||||
private var loadingButton: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(foreground)
|
||||
Text(MacL10n.string("mac.account.signIn.loading", language: language))
|
||||
.font(TypeStyle.bodyEmph)
|
||||
}
|
||||
.foregroundStyle(foreground)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: MacMetrics.appleButtonHeight)
|
||||
.background(background)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityIdentifier("mac.account.signIn.loading")
|
||||
}
|
||||
|
||||
private var background: Color { colorScheme == .dark ? .white : .black }
|
||||
private var foreground: Color { colorScheme == .dark ? .black : .white }
|
||||
|
||||
private func prepareRequest(_ request: ASAuthorizationAppleIDRequest) {
|
||||
guard let nonce = AccountAppleNonce.make() else {
|
||||
rawNonce = nil
|
||||
request.nonce = nil
|
||||
return
|
||||
}
|
||||
rawNonce = nonce.rawValue
|
||||
request.requestedScopes = purpose == .signIn ? [.fullName] : []
|
||||
request.nonce = nonce.sha256Hex
|
||||
}
|
||||
|
||||
private func completeAuthorization(_ result: Result<ASAuthorization, Error>) {
|
||||
guard let rawNonce else {
|
||||
OSGDiag.log(
|
||||
"appleAuthorization failed stage=nonce-unavailable",
|
||||
category: "account"
|
||||
)
|
||||
coordinator.recordAppleAuthorizationFailure(detail: "nonce-unavailable")
|
||||
return
|
||||
}
|
||||
self.rawNonce = nil
|
||||
|
||||
switch result {
|
||||
case let .success(authorization):
|
||||
guard let payload = AccountAppleAuthorizationPayload.make(
|
||||
from: authorization,
|
||||
rawNonce: rawNonce
|
||||
) else {
|
||||
OSGDiag.log(
|
||||
"appleAuthorization failed stage=credential-payload",
|
||||
category: "account"
|
||||
)
|
||||
coordinator.recordAppleAuthorizationFailure(detail: "credential-payload")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
switch purpose {
|
||||
case .signIn:
|
||||
await coordinator.signIn(with: payload)
|
||||
if coordinator.isSignedIn {
|
||||
ProviderConfig.shared.reloadFromPersistedStorage()
|
||||
onSignedIn()
|
||||
}
|
||||
case .deleteAccount:
|
||||
await coordinator.deleteAccount(with: payload)
|
||||
if !coordinator.isSignedIn {
|
||||
onDeleted()
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .failure(error):
|
||||
let diagnostic = error as NSError
|
||||
OSGDiag.log(
|
||||
"appleAuthorization failed stage=system "
|
||||
+ "domain=\(diagnostic.domain) code=\(diagnostic.code)",
|
||||
category: "account"
|
||||
)
|
||||
if (error as? ASAuthorizationError)?.code != .canceled {
|
||||
coordinator.recordAppleAuthorizationFailure(
|
||||
detail: "as-\(diagnostic.domain)-\(diagnostic.code)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// MacAccountView.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// `MacAccountSettingsSection` — the account card that opens the Settings
|
||||
// page: Sign in with Apple, credit balance, and the permanent invitation
|
||||
// link. Everything here reuses the iOS
|
||||
// account stack (`AccountSessionCoordinator`, `LiveAccountServices`,
|
||||
// `ReferralProfileViewModel`) compiled at source level — see project.yml.
|
||||
//
|
||||
// Unlike iOS there is no separate Account tab. macOS has one settings surface,
|
||||
// so the account is simply the first `MacSettingsSection` on it and every row
|
||||
// sits on the same grid (`settingsCardInset` + `settingsRowMinHeight`) as the
|
||||
// provider rows below — the old standalone page stacked free-floating VStacks
|
||||
// that lined up with nothing.
|
||||
//
|
||||
// Deliberately no purchase UI. A Developer ID build cannot use StoreKit, so
|
||||
// credits are bought on iPhone or the web and only *spent* here; the balance
|
||||
// is server-authoritative either way.
|
||||
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct MacAccountSettingsSection: View {
|
||||
@EnvironmentObject private var coordinator: AccountSessionCoordinator
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let language: AppUILanguage
|
||||
|
||||
@State private var isConfirmingDelete = false
|
||||
|
||||
private var lang: AppUILanguage { language }
|
||||
|
||||
var body: some View {
|
||||
MacSettingsSection(
|
||||
title: MacL10n.string("mac.section.account", language: lang),
|
||||
footer: footer
|
||||
) {
|
||||
VStack(spacing: MacMetrics.settingsRowGap) {
|
||||
switch coordinator.sessionPhase {
|
||||
case .restoring:
|
||||
statusRow(MacL10n.string("mac.account.restoring", language: lang))
|
||||
case .signedOut:
|
||||
signedOutRow
|
||||
case let .signedIn(session):
|
||||
identityRow(session)
|
||||
creditRows
|
||||
MacInviteLinkRow(profile: coordinator.referralProfile, language: lang)
|
||||
referralCountRows
|
||||
accountActionsRow
|
||||
deleteRows
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await coordinator.restoreIfNeeded() }
|
||||
.alert(
|
||||
MacL10n.string("mac.account.error.title", language: lang),
|
||||
isPresented: operationErrorBinding
|
||||
) {
|
||||
Button(MacL10n.string("mac.account.error.dismiss", language: lang)) {
|
||||
coordinator.dismissOperationError()
|
||||
}
|
||||
} message: {
|
||||
if let key = coordinator.operationErrorKey {
|
||||
// The code is appended verbatim: on Mac there is no TestFlight
|
||||
// crash trail behind a failed sign-in, and "登录失败,请重试"
|
||||
// alone is not something anyone can act on.
|
||||
Text(alertMessage(for: key))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: String {
|
||||
switch coordinator.sessionPhase {
|
||||
case .signedOut, .restoring:
|
||||
return MacL10n.string("mac.account.signedOut.footnote", language: lang)
|
||||
case .signedIn:
|
||||
return MacL10n.string("mac.account.credits.purchaseNote", language: lang)
|
||||
}
|
||||
}
|
||||
|
||||
private func alertMessage(for key: String) -> String {
|
||||
let message = MacL10n.string(key, language: lang)
|
||||
guard let detail = coordinator.operationErrorDetail else { return message }
|
||||
return "\(message)\n(\(detail))"
|
||||
}
|
||||
|
||||
// MARK: - Signed out
|
||||
|
||||
/// Title + explanation on the left, the Apple button right-aligned — the
|
||||
/// same two-column shape every other settings row uses.
|
||||
private var signedOutRow: some View {
|
||||
MacInlineRow(
|
||||
title: MacL10n.string("mac.account.signedOut.title", language: lang),
|
||||
subtitle: MacL10n.string("mac.account.signedOut.body", language: lang)
|
||||
) {
|
||||
MacAccountAppleButton(purpose: .signIn, language: lang)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Signed in
|
||||
|
||||
private func identityRow(_ session: AccountSession) -> some View {
|
||||
MacInlineRow(
|
||||
title: session.displayName?.isEmpty == false
|
||||
? session.displayName!
|
||||
: MacL10n.string("mac.account.profile.unnamed", language: lang),
|
||||
subtitle: MacL10n.format(
|
||||
"mac.account.profile.accountID",
|
||||
language: lang,
|
||||
String(session.accountID.uuidString.prefix(8)).lowercased()
|
||||
)
|
||||
) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var creditRows: some View {
|
||||
switch coordinator.snapshotPhase {
|
||||
case .idle, .loading:
|
||||
statusRow(MacL10n.string("mac.account.credits.loading", language: lang))
|
||||
|
||||
case let .loaded(snapshot):
|
||||
MacInlineRow(title: MacL10n.string("mac.account.credits.balance", language: lang)) {
|
||||
Text("\(snapshot.credits.balance)")
|
||||
.font(TypeStyle.title3)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
MacInlineRow(title: MacL10n.string("mac.account.credits.used", language: lang)) {
|
||||
Text("\(snapshot.credits.usedCredits)")
|
||||
.font(MacSettingsType.rowLabel)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
case let .failed(messageKey):
|
||||
MacInlineRow(
|
||||
title: MacL10n.string("mac.account.credits.balance", language: lang),
|
||||
subtitle: MacL10n.string(messageKey, language: lang)
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var referralCountRows: some View {
|
||||
if case let .loaded(snapshot) = coordinator.snapshotPhase {
|
||||
let summary = AccountReferralSummary(referrals: snapshot.referrals)
|
||||
MacInlineRow(title: MacL10n.string("mac.account.invite.rewarded", language: lang)) {
|
||||
Text("\(summary.rewarded)")
|
||||
.font(MacSettingsType.rowLabel)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
MacInlineRow(title: MacL10n.string("mac.account.invite.pending", language: lang)) {
|
||||
Text("\(summary.pending)")
|
||||
.font(MacSettingsType.rowLabel)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh / sign out share one row so the card ends on the row grid rather
|
||||
/// than on two stacked full-width buttons.
|
||||
private var accountActionsRow: some View {
|
||||
HStack(spacing: Spacing.lg) {
|
||||
linkButton(
|
||||
MacL10n.string("mac.account.credits.refresh", language: lang),
|
||||
isBusy: coordinator.isRefreshingAccountData
|
||||
) {
|
||||
Task { await coordinator.refreshAccountData(force: true) }
|
||||
}
|
||||
linkButton(
|
||||
MacL10n.string("mac.account.signOut", language: lang),
|
||||
isBusy: coordinator.operation == .signingOut
|
||||
) {
|
||||
Task { await coordinator.signOut() }
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
.frame(minHeight: MacMetrics.settingsRowMinHeight)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var deleteRows: some View {
|
||||
if isConfirmingDelete {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text(MacL10n.string("mac.account.delete.confirmBody", language: lang))
|
||||
.font(MacSettingsType.hint)
|
||||
.foregroundStyle(palette.danger)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
HStack(spacing: Spacing.lg) {
|
||||
// Apple requires re-authentication before deletion, so this
|
||||
// is a second Sign in with Apple pass, not a confirm button.
|
||||
MacAccountAppleButton(
|
||||
purpose: .deleteAccount,
|
||||
language: lang,
|
||||
onDeleted: { isConfirmingDelete = false }
|
||||
)
|
||||
Button(MacL10n.string("mac.account.delete.cancel", language: lang)) {
|
||||
isConfirmingDelete = false
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(MacSettingsType.rowLabel)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
} else {
|
||||
MacInlineRow(
|
||||
title: MacL10n.string("mac.account.delete.title", language: lang),
|
||||
subtitle: MacL10n.string("mac.account.delete.body", language: lang)
|
||||
) {
|
||||
Button(MacL10n.string("mac.account.delete.action", language: lang)) {
|
||||
isConfirmingDelete = true
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(MacSettingsType.rowLabel)
|
||||
.foregroundStyle(palette.danger)
|
||||
.accessibilityIdentifier("mac.account.delete.action")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func statusRow(_ text: String) -> some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text(text)
|
||||
.font(MacSettingsType.rowLabel)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
.frame(minHeight: MacMetrics.settingsRowMinHeight)
|
||||
}
|
||||
|
||||
private func linkButton(
|
||||
_ title: String,
|
||||
isBusy: Bool,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Button(title, action: action)
|
||||
.buttonStyle(.plain)
|
||||
.font(MacSettingsType.rowLabel)
|
||||
.foregroundStyle(isBusy ? palette.textTertiary : palette.accent)
|
||||
.disabled(isBusy)
|
||||
if isBusy {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var operationErrorBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { coordinator.operationErrorKey != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented { coordinator.dismissOperationError() }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Invitation link
|
||||
|
||||
/// Separate view so the nested `ReferralProfileViewModel` is observed
|
||||
/// directly — the parent only observes `AccountSessionCoordinator`, and
|
||||
/// SwiftUI does not propagate a child ObservableObject's changes through it.
|
||||
private struct MacInviteLinkRow: View {
|
||||
@ObservedObject var profile: ReferralProfileViewModel
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let language: AppUILanguage
|
||||
|
||||
@State private var didCopy = false
|
||||
@State private var copyResetTask: Task<Void, Never>?
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.onDisappear { copyResetTask?.cancel() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch profile.state {
|
||||
case .idle, .loading:
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text(MacL10n.string("mac.account.invite.loading", language: language))
|
||||
.font(MacSettingsType.rowLabel)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
.frame(minHeight: MacMetrics.settingsRowMinHeight)
|
||||
|
||||
case let .loaded(loaded):
|
||||
MacInlineRow(
|
||||
title: MacL10n.string("mac.account.invite.link", language: language),
|
||||
subtitle: loaded.code.inviteURL.absoluteString
|
||||
) {
|
||||
Button {
|
||||
copy(loaded.code.inviteURL)
|
||||
} label: {
|
||||
Label(
|
||||
MacL10n.string(
|
||||
didCopy
|
||||
? "mac.account.invite.copied"
|
||||
: "mac.account.invite.copy",
|
||||
language: language
|
||||
),
|
||||
systemImage: didCopy ? "checkmark" : "doc.on.doc"
|
||||
)
|
||||
.font(MacSettingsType.rowLabel)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(palette.accent)
|
||||
.accessibilityIdentifier("mac.account.invite.copy")
|
||||
}
|
||||
|
||||
case let .failed(messageKey):
|
||||
MacInlineRow(
|
||||
title: MacL10n.string("mac.account.invite.link", language: language),
|
||||
subtitle: MacL10n.string(messageKey, language: language)
|
||||
) {
|
||||
Button(MacL10n.string("mac.account.invite.retry", language: language)) {
|
||||
Task { await profile.refresh() }
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(MacSettingsType.rowLabel)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func copy(_ url: URL) {
|
||||
#if os(macOS)
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(url.absoluteString, forType: .string)
|
||||
#endif
|
||||
didCopy = true
|
||||
copyResetTask?.cancel()
|
||||
copyResetTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
didCopy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,9 @@ enum MacMetrics {
|
||||
/// label. Content is centered within it; taller rows (status text, progress)
|
||||
/// grow past it.
|
||||
static let settingsRowMinHeight: CGFloat = 40
|
||||
/// Sign in with Apple 按钮高度:系统按钮的字号随高度缩放,44 让文字与图标
|
||||
/// 与设置页 15pt 正文观感一致(40 时明显偏小)。
|
||||
static let appleButtonHeight: CGFloat = 44
|
||||
/// The single vertical rhythm for settings cards: the gap *between* rows AND
|
||||
/// the padding between a card's top/bottom edge and its first/last row are
|
||||
/// both this value, so the card breathes evenly. Applied as `VStack(spacing:)`
|
||||
@@ -62,10 +65,20 @@ enum MacMetrics {
|
||||
/// `pageHorizontalInset` on the Settings Form so its card outer edge lands
|
||||
/// on `pageHorizontalInset` — matching the History page and the page title.
|
||||
static let groupedFormSectionInset: CGFloat = Spacing.lg
|
||||
/// Default (= minimum) main-window size. Opening the app uses this size;
|
||||
/// the window cannot shrink below it.
|
||||
/// Smallest the main window may be dragged to. Every page stays usable
|
||||
/// (and scrollable) at this size, but nothing is laid out *for* it.
|
||||
///
|
||||
/// The height floor also repairs windows restored from saved state:
|
||||
/// `defaultSize` only applies on a window's first appearance, so anyone
|
||||
/// who already ran the app keeps the old frame until the minimum lifts it.
|
||||
static let windowMinWidth: CGFloat = 860
|
||||
static let windowMinHeight: CGFloat = 600
|
||||
static let windowMinHeight: CGFloat = 680
|
||||
/// Size the window opens at. Deliberately larger than the minimum: at
|
||||
/// 860×600 the onboarding hero + step card + progress dots + bottom bar
|
||||
/// do not all fit, so the primary action fell below the window edge.
|
||||
/// Settings and Onboarding both fit comfortably at this size.
|
||||
static let windowDefaultWidth: CGFloat = 1_100
|
||||
static let windowDefaultHeight: CGFloat = 800
|
||||
/// Compact dictation-canvas height so Home fits the min window without scrolling.
|
||||
static let dictationCanvasMinHeight: CGFloat = 120
|
||||
/// Top inset that clears the window traffic-light buttons now that the
|
||||
|
||||
@@ -14,6 +14,8 @@ enum MacSection: String, CaseIterable, Identifiable {
|
||||
case history
|
||||
case dictionary
|
||||
case styles
|
||||
// No `account` case: on Mac the account lives in Settings as its first
|
||||
// card (iOS keeps a dedicated tab). One sign-in surface, one page.
|
||||
case settings
|
||||
|
||||
var id: String { rawValue }
|
||||
@@ -183,10 +185,6 @@ final class MacDictationViewModel: ObservableObject {
|
||||
LLMProvider.asrSelectablePresets
|
||||
}
|
||||
|
||||
var selectableProviders: [LLMProvider] {
|
||||
asrSelectableProviders
|
||||
}
|
||||
|
||||
var dictionaryTermCount: Int {
|
||||
_ = dictionaryRevision
|
||||
return AppGroupStore(defaults: defaults).personalDictionary.entries.count
|
||||
@@ -268,6 +266,15 @@ final class MacDictationViewModel: ObservableObject {
|
||||
if enabled { hotkeyService.start() } else { hotkeyService.stop() }
|
||||
}
|
||||
|
||||
/// Called when Accessibility flips to trusted (see `MacPermissionMonitor`).
|
||||
/// Granting the permission does not retro-attach the global event monitor,
|
||||
/// so the hotkey has to be re-armed or Option-hold keeps doing nothing
|
||||
/// until the app is relaunched.
|
||||
func reattachHotkeyIfNeeded() {
|
||||
guard hotkeyEnabled else { return }
|
||||
hotkeyService.restartIfGlobalMonitorMissing()
|
||||
}
|
||||
|
||||
func setHotkeyTrigger(_ trigger: MacHotkeyTrigger) {
|
||||
hotkeyTrigger = trigger
|
||||
defaults.set(trigger.rawValue, forKey: StoredKeys.hotkeyTrigger)
|
||||
|
||||
@@ -83,6 +83,24 @@ final class MacHotkeyService {
|
||||
if !enabled { cancelPress() }
|
||||
}
|
||||
|
||||
/// True once the *global* `.flagsChanged` monitor is attached, i.e. once
|
||||
/// Option-hold works outside the app's own windows. False whenever
|
||||
/// Accessibility was denied at the time `start()` ran.
|
||||
var isGlobalMonitorActive: Bool { globalFlagsMonitor != nil }
|
||||
|
||||
/// Re-attaches the global monitor after Accessibility was granted.
|
||||
///
|
||||
/// `start()` alone cannot do this: when Accessibility is denied,
|
||||
/// `addGlobalMonitorForEvents` returns nil while the *local* monitor still
|
||||
/// attaches, so its "already started" guard would short-circuit every
|
||||
/// retry and the hotkey stayed dead until the next launch.
|
||||
func restartIfGlobalMonitorMissing() {
|
||||
guard isEnabled, globalFlagsMonitor == nil else { return }
|
||||
guard MacTextInsertionService.isAccessibilityTrusted else { return }
|
||||
stop()
|
||||
start()
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard globalFlagsMonitor == nil, localFlagsMonitor == nil else { return }
|
||||
// Global monitors require Accessibility; without it the call returns
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ struct MacSettingsView: View {
|
||||
private var appearanceRaw = MacAppearancePreference.system.rawValue
|
||||
@AppStorage(MacOnboardingState.storageKey)
|
||||
private var hasCompletedMacOnboarding = true
|
||||
@State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
||||
// Accessibility is granted in System Settings, which sends no notification
|
||||
// back. The monitor polls while this page is on screen so the status flips
|
||||
// as soon as the user returns, instead of on the next app launch.
|
||||
@StateObject private var permissions = MacPermissionMonitor()
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
private let recognitionLocales: [(id: String, key: String, fallback: String)] = [
|
||||
@@ -39,7 +42,7 @@ struct MacSettingsView: View {
|
||||
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
|
||||
supportSection
|
||||
MacAccountSettingsSection(language: lang)
|
||||
generalSection
|
||||
recognitionSection
|
||||
if viewModel.config.engineMode == "cloud" {
|
||||
@@ -63,7 +66,8 @@ struct MacSettingsView: View {
|
||||
}
|
||||
.background(palette.background)
|
||||
}
|
||||
.onAppear { refreshAccessibilityState() }
|
||||
.onAppear { permissions.start() }
|
||||
.onDisappear { permissions.stop() }
|
||||
}
|
||||
|
||||
// MARK: - General
|
||||
@@ -320,30 +324,23 @@ struct MacSettingsView: View {
|
||||
}
|
||||
|
||||
Label(
|
||||
accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded,
|
||||
systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle"
|
||||
permissions.isAccessibilityTrusted
|
||||
? accessibilityStatusGranted
|
||||
: accessibilityStatusNeeded,
|
||||
systemImage: permissions.isAccessibilityTrusted
|
||||
? "checkmark.circle.fill"
|
||||
: "exclamationmark.circle"
|
||||
)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning)
|
||||
.foregroundStyle(permissions.isAccessibilityTrusted ? palette.accent : palette.warning)
|
||||
.contentTransition(.opacity)
|
||||
.animation(Motion.quick, value: accessibilityTrusted)
|
||||
.animation(Motion.quick, value: permissions.isAccessibilityTrusted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -361,12 +358,17 @@ struct MacSettingsView: View {
|
||||
MacFormLinkRow(title: MacL10n.string("mac.settings.thirdPartyLicenses", language: lang))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) {
|
||||
Button {
|
||||
hasCompletedMacOnboarding = false
|
||||
} label: {
|
||||
// 与相邻的链接行同款排版:15pt 主文字 + 同样的行高与内边距。
|
||||
MacFormLinkRow(
|
||||
title: MacL10n.string("mac.settings.restartOnboarding", language: lang),
|
||||
showsChevron: false
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading)
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Read-only version row at the bottom of About.
|
||||
MacInlineRow(title: MacL10n.string("mac.settings.version", language: lang)) {
|
||||
@@ -582,20 +584,7 @@ struct MacSettingsView: View {
|
||||
// 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
|
||||
permissions.openAccessibilitySettings()
|
||||
}
|
||||
|
||||
private func localeLabel(_ locale: (id: String, key: String, fallback: String)) -> String {
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
// MacSupportDeveloperTipRows.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Optional consumable tip rows for macOS Settings.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MacSupportDeveloperTipRows: View {
|
||||
@ObservedObject private var tipManager = TipPurchaseManager.shared
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let language: AppUILanguage
|
||||
|
||||
@State private var showThankYouAlert = false
|
||||
@State private var showErrorAlert = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: MacMetrics.settingsRowGap) {
|
||||
SupportDeveloperTipBody(language: language)
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
|
||||
if tipManager.supportCount > 0 {
|
||||
Text(
|
||||
SharedL10n.format(
|
||||
"tip.thankYou.past",
|
||||
language: language,
|
||||
tipManager.supportCount
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.accent)
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
}
|
||||
|
||||
MacSettingsToolButton(title: tipButtonTitle, disabled: isPurchaseInFlight) {
|
||||
Task { await tipManager.purchase() }
|
||||
}
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
}
|
||||
.onChange(of: tipManager.purchaseState) { _, newValue in
|
||||
switch newValue {
|
||||
case .succeeded:
|
||||
showThankYouAlert = true
|
||||
case .failed(let message):
|
||||
errorMessage = message
|
||||
showErrorAlert = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
SharedL10n.string("tip.thankYou.title", language: language),
|
||||
isPresented: $showThankYouAlert
|
||||
) {
|
||||
Button(SharedL10n.string("tip.alert.dismiss", language: language)) {
|
||||
tipManager.acknowledgePurchaseState()
|
||||
}
|
||||
.tint(palette.textPrimary)
|
||||
} message: {
|
||||
Text(SharedL10n.string("tip.thankYou.message", language: language))
|
||||
}
|
||||
.alert(
|
||||
SharedL10n.string("tip.error.title", language: language),
|
||||
isPresented: $showErrorAlert
|
||||
) {
|
||||
Button(SharedL10n.string("tip.alert.dismiss", language: language)) {
|
||||
tipManager.acknowledgePurchaseState()
|
||||
}
|
||||
.tint(palette.textPrimary)
|
||||
} message: {
|
||||
Text(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private var isPurchaseInFlight: Bool {
|
||||
switch tipManager.purchaseState {
|
||||
case .loading, .purchasing:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var tipButtonTitle: String {
|
||||
if let product = tipManager.product {
|
||||
return SharedL10n.format("tip.button", language: language, product.displayPrice)
|
||||
}
|
||||
return SharedL10n.string("tip.buttonFallback", language: language)
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,15 @@
|
||||
// a synthetic ⌘V (SayIt / Typeless-style). Requires Accessibility trust.
|
||||
// Re-activates the app the user was dictating into (the popover steals
|
||||
// focus) and restores the original clipboard once the paste has landed.
|
||||
//
|
||||
// Also hosts `MacPermissionMonitor` — the live Accessibility / microphone
|
||||
// authorization watcher shared by onboarding and Settings.
|
||||
|
||||
import AVFoundation
|
||||
import AppKit
|
||||
@preconcurrency import ApplicationServices
|
||||
import Carbon
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum MacTextInsertionService {
|
||||
@@ -206,3 +211,100 @@ private final class FrontmostAppTracker: NSObject {
|
||||
lastExternalApp = app
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live permission monitoring
|
||||
|
||||
/// Publishes Accessibility / microphone authorization while a permission UI is
|
||||
/// on screen.
|
||||
///
|
||||
/// Both grants are made *outside* the app — in System Settings — and macOS
|
||||
/// sends no notification when they flip. `AXIsProcessTrusted()` also keeps
|
||||
/// returning the value captured when the process was launched for a short
|
||||
/// while after the toggle, so a single delayed re-check (what onboarding and
|
||||
/// Settings used to do) reads stale far more often than not. Polling on a
|
||||
/// short timer plus re-reading on app activation is the only reliable way to
|
||||
/// see the change without asking the user to relaunch.
|
||||
@MainActor
|
||||
final class MacPermissionMonitor: ObservableObject {
|
||||
@Published private(set) var isAccessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
||||
@Published private(set) var microphoneStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||
|
||||
/// Fast enough that returning from System Settings feels instantaneous,
|
||||
/// slow enough to be free — `AXIsProcessTrusted` is a cheap XPC-free read.
|
||||
private static let pollInterval: TimeInterval = 0.6
|
||||
|
||||
private var pollTask: Task<Void, Never>?
|
||||
/// Observed as an `AsyncSequence` rather than a block observer so the
|
||||
/// registration is owned by the task and torn down by cancelling it —
|
||||
/// a nonisolated `deinit` cannot hand a block observer back safely.
|
||||
private var activationTask: Task<Void, Never>?
|
||||
|
||||
deinit {
|
||||
pollTask?.cancel()
|
||||
activationTask?.cancel()
|
||||
}
|
||||
|
||||
/// Begins polling. Safe to call repeatedly — extra calls are ignored.
|
||||
func start() {
|
||||
refresh()
|
||||
guard pollTask == nil else { return }
|
||||
|
||||
// Returning from System Settings usually means the grant just changed;
|
||||
// refresh immediately instead of waiting out the poll interval.
|
||||
activationTask = Task { [weak self] in
|
||||
let activations = NotificationCenter.default.notifications(
|
||||
named: NSApplication.didBecomeActiveNotification
|
||||
)
|
||||
for await _ in activations {
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run { self?.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
pollTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(
|
||||
nanoseconds: UInt64(Self.pollInterval * 1_000_000_000)
|
||||
)
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
pollTask?.cancel()
|
||||
pollTask = nil
|
||||
activationTask?.cancel()
|
||||
activationTask = nil
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
let trusted = MacTextInsertionService.isAccessibilityTrusted
|
||||
if trusted != isAccessibilityTrusted {
|
||||
isAccessibilityTrusted = trusted
|
||||
// Showing a green checkmark is only half the grant: the global
|
||||
// hotkey monitor was refused at launch and has to be re-attached.
|
||||
if trusted { MacDictationViewModel.shared.reattachHotkeyIfNeeded() }
|
||||
}
|
||||
let mic = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||
if mic != microphoneStatus { microphoneStatus = mic }
|
||||
}
|
||||
|
||||
/// Prompts, then opens the Accessibility pane. Polling picks the grant up.
|
||||
func openAccessibilitySettings() {
|
||||
_ = MacTextInsertionService.requestAccessibilityIfNeeded()
|
||||
if let url = URL(
|
||||
string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
|
||||
) {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
func requestMicrophone() {
|
||||
AVCaptureDevice.requestAccess(for: .audio) { [weak self] _ in
|
||||
Task { @MainActor in self?.refresh() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,22 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
<array>
|
||||
<string>Default</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)com.osgkeyboard.ios</string>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<false/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)com.osgkeyboard.ios</string>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.osgkeyboard.shared</string>
|
||||
<string>$(AppIdentifierPrefix)com.osgkeyboard.mac</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -12,6 +12,14 @@ import SwiftUI
|
||||
struct OSGKeyboardMacApp: App {
|
||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) private var appDelegate
|
||||
@StateObject private var viewModel = MacDictationViewModel.shared
|
||||
// Optional managed account: Sign in with Apple, server-held credits, and
|
||||
// the invitation link. Unlike iOS there is no managed-gateway credential
|
||||
// handoff to reset on sign-out — the Mac app is BYOK-only for providers,
|
||||
// so the account only carries identity, credits and referrals.
|
||||
@StateObject private var accountSession = AccountSessionCoordinator(
|
||||
dependencies: LiveAccountDependencyFactory.make(),
|
||||
arePurchasesAvailable: false
|
||||
)
|
||||
|
||||
// Mac-local appearance preference. Drives both the SwiftUI colour scheme
|
||||
// and — via `applyToApp` — the AppKit window chrome / popover.
|
||||
@@ -36,8 +44,10 @@ struct OSGKeyboardMacApp: App {
|
||||
}
|
||||
.macSystemPalette()
|
||||
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
|
||||
.environmentObject(accountSession)
|
||||
.preferredColorScheme(appearance.colorScheme)
|
||||
.task { await viewModel.onAppear() }
|
||||
.task { await accountSession.restoreIfNeeded() }
|
||||
.onAppear { MacAppearancePreference.applyToApp(appearance) }
|
||||
.onChange(of: appearanceRaw) { MacAppearancePreference.applyToApp(appearance) }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
|
||||
@@ -53,6 +63,16 @@ struct OSGKeyboardMacApp: App {
|
||||
viewModel.speechHistory.reloadFromDisk()
|
||||
}
|
||||
.onOpenURL { url in
|
||||
// Invitation links (https://osglab.com/i/<code>). Reaching
|
||||
// the app this way additionally needs the
|
||||
// `applinks:osglab.com` associated-domains entitlement on
|
||||
// the Mac App ID; without it the handler is simply never
|
||||
// called and sharing your own link still works.
|
||||
if accountSession.handleIncomingURL(url) {
|
||||
// Account lives in Settings on Mac (first card).
|
||||
viewModel.selectedSection = .settings
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
guard url.scheme == "osgkeyboard", url.host == "seed-demo" else { return }
|
||||
DemoDataSeeder.seedRichPlaceholderData(
|
||||
@@ -71,9 +91,10 @@ struct OSGKeyboardMacApp: App {
|
||||
// very top, matching macOS System Settings.
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.windowResizability(.contentMinSize)
|
||||
// Open at the minimum size — same as `MacMetrics.windowMin*`, so the
|
||||
// first launch already matches the smallest allowed window.
|
||||
.defaultSize(width: MacMetrics.windowMinWidth, height: MacMetrics.windowMinHeight)
|
||||
// Open larger than the minimum: onboarding's hero + step card +
|
||||
// progress dots + bottom bar do not fit in `windowMin*`, which pushed
|
||||
// the primary button off screen on first launch.
|
||||
.defaultSize(width: MacMetrics.windowDefaultWidth, height: MacMetrics.windowDefaultHeight)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user