Files
OSGKeyboard/OSGKeyboardMac/DashboardView.swift
T
rockyandClaude Opus 5 0023741455 feat(app): add crash visibility, memory relief, and clipboard reply work
Squashes the working tree that had accumulated on cloud-agent. Two
strands of work, kept in one commit at the maintainer's request.

Crash and stability (this session):

- Subscribe to MetricKit so crash, hang, CPU, and disk-write diagnostics
  are recorded on device. Keyboard-extension memory kills are the most
  likely way a user loses the keyboard mid-sentence and iOS does not
  classify them as crashes, so they never reach App Store Connect.
- Upload those reports to POST /v1/analytics/diagnostics from internal
  builds only (local Debug and TestFlight), reusing the analytics
  installation identifier and skipping entirely when analytics are off.
  App Store builds keep everything on device.
- Shed the Chinese engine, English lexicon, and in-flight pipeline work
  at the 40 MiB and 48 MiB budget thresholds instead of waiting for a
  system memory warning that jetsam often preempts.
- Install audio taps with the input node's live format through one
  shared adaptive resampler, so an audio-route change mid-recording no
  longer raises an uncatchable format-mismatch exception. Covers the
  dictation preview and the Mac recorder, which still used the pattern
  the Flow capture path had already moved away from.
- Harden the librime bridge: never pass a nil string across a nonnull
  boundary when a user dictionary holds malformed UTF-8, null-check the
  engine API vector, and distrust a candidate count without its array.
- Guard the expanded candidate grid against a list that shrinks while
  the collection view is still laying out against the previous count.
- Resolve localized text without the App Group so the screen shown when
  that container is missing renders its message instead of crashing on
  launch, which is the one situation it exists to handle.
- Gate CI on the App Group and Keychain wiring that the keyboard
  extension hard-depends on, with a self-test proving the check fails.

Clipboard, reply, and semantics (pre-existing working tree):

- Unify the reply center, refresh keyboard AI features, and add native
  event and reminder exporters.
- Expand clipboard semantic routing with authored corpora and a
  product-blind holdout, plus the scripts that build and validate them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:31:29 -07:00

320 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DashboardView.swift
// OSGKeyboard · Mac
//
// Primary workspace: brand voice, asymmetric stats, dictation stage, and
// the record bar. History lives on its own page — no duplicate list here.
import SwiftUI
struct DashboardView: View {
@ObservedObject var viewModel: MacDictationViewModel
@ObservedObject private var stats: UsageStatisticsStore
@Environment(\.themePalette) private var palette
private var lang: AppUILanguage { viewModel.config.uiLanguage }
init(viewModel: MacDictationViewModel) {
self.viewModel = viewModel
// Observe through the view model's store instance — a bare
// `UsageStatisticsStore.shared` on @ObservedObject often misses
// post-sync @Published updates on macOS.
self._stats = ObservedObject(wrappedValue: viewModel.usageStatistics)
}
var body: some View {
// 布局意图:标题 + 统计卡片顶对齐,麦克风栏底对齐,中间的听写预览卡片
// 吸收全部剩余高度(弹性),因此拉高窗口时是卡片变高而非上下留白。
//
// 不用 ScrollView:ScrollView 在测量内容时给子视图的是「理想高度」提案,
// 会让 `.frame(maxHeight: .infinity)` 的卡片停在最小高度、多余空间变留白。
// 主窗口最小高度 600,内容恒可容纳,故直接用 GeometryReader 定高铺满
// (与 iOS 端 HomeView 的弹性预览框同款、已验证可行的模式)。
GeometryReader { proxy in
VStack(spacing: 0) {
// 顶部固定区块:品牌标题 + 统计卡片。
VStack(alignment: .leading, spacing: Spacing.lg) {
heroHeader
statCluster
}
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.top, Spacing.sm)
// 中间弹性区块:预览卡片撑满标题与麦克风之间的剩余空间。
dictationStage
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.vertical, Spacing.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// 底部固定区块:麦克风按钮 + 两侧信息。
BottomDictationBar(viewModel: viewModel)
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.bottom, Spacing.xs)
}
.frame(width: proxy.size.width, height: proxy.size.height, alignment: .top)
}
.onAppear { stats.reloadFromDisk() }
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
stats.reloadFromDisk()
}
}
// MARK: - Hero
private var heroHeader: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(MacL10n.string("mac.brand.tagline", language: lang))
.font(TypeStyle.pageTitle)
.foregroundStyle(palette.textPrimary)
.lineLimit(2)
.minimumScaleFactor(0.85)
HStack(spacing: Spacing.sm) {
Text(MacL10n.string("mac.brand.tagline.subtitle", language: lang))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
if let appName = viewModel.foregroundAppName {
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.45))
Text(MacL10n.format("mac.foregroundApp", language: lang, appName))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
.transition(.opacity)
.lineLimit(1)
}
}
.animation(Motion.soft, value: viewModel.foregroundAppName)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Stats (monthly calendar + cumulative grid — shared cluster)
private var statCluster: some View {
UsageStatsCluster(
layout: .split,
language: lang,
points: stats.currentMonth,
dictationCharacterCount: stats.dictationCharacterCount,
dictationDurationSeconds: stats.dictationDurationSeconds,
translationCharacterCount: stats.translationCharacterCount,
dictionaryTermCount: viewModel.dictionaryTermCount
)
}
// MARK: - Dictation stage
private var dictationStage: some View {
MacCard(padding: Spacing.md, cornerRadius: Radius.large) {
ZStack(alignment: .topLeading) {
if viewModel.hasHomePreview {
// 总览:累积所有会话文本,内部滚动、自动贴底显示最新一句。
ScrollView {
Text(viewModel.homePreviewText)
.font(TypeStyle.title3.weight(.regular))
.foregroundStyle(palette.textPrimary)
.lineSpacing(4)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.defaultScrollAnchor(.bottom)
.frame(
maxWidth: .infinity,
minHeight: MacMetrics.dictationCanvasMinHeight,
maxHeight: .infinity,
alignment: .topLeading
)
} else {
Text(
viewModel.isRecording
? MacL10n.string("mac.status.listening", language: lang)
: MacL10n.string("mac.status.ready", language: lang)
)
.font(.system(size: 22, weight: .light))
.foregroundStyle(palette.textTertiary)
.contentTransition(.opacity)
.frame(
maxWidth: .infinity,
minHeight: MacMetrics.dictationCanvasMinHeight,
maxHeight: .infinity,
alignment: .topLeading
)
.transition(.opacity)
}
}
// 不再封顶:卡片跟随外层弹性容器把剩余窗口高度全部吃满。
.frame(minHeight: MacMetrics.dictationCanvasMinHeight, maxHeight: .infinity)
}
.frame(maxHeight: .infinity)
.animation(Motion.soft, value: viewModel.hasHomePreview)
.animation(Motion.quick, value: viewModel.isRecording)
}
}
// MARK: - Floating record bar
struct BottomDictationBar: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
@State private var pulse = false
private var lang: AppUILanguage { viewModel.config.uiLanguage }
var body: some View {
ZStack {
HStack {
translationPicker
Spacer()
readinessChip
}
recordControl
}
.padding(.horizontal, Spacing.md)
.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.
}
private var readinessChip: some View {
HStack(spacing: 6) {
Circle()
.fill(viewModel.isProcessing ? palette.warning : palette.accent)
.frame(width: 7, height: 7)
Text(
viewModel.isProcessing
? MacL10n.string("mac.status.chipProcessing", language: lang)
: MacL10n.string("mac.status.chipReady", language: lang)
)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.contentTransition(.opacity)
}
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 5)
.background(palette.surfaceElevated, in: Capsule())
.animation(Motion.quick, value: viewModel.isProcessing)
}
private var translationPicker: some View {
Menu {
ForEach(TranslationLanguageCatalog.all) { language in
Button(translationLabel(for: language)) {
viewModel.config.translationTargetLocaleId = language.id
}
}
} label: {
HStack(spacing: Spacing.sm) {
Image(systemName: "translate")
.foregroundStyle(palette.textSecondary)
Text(currentTranslationLabel)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: Spacing.xs)
Image(systemName: "chevron.up.chevron.down")
.font(TypeStyle.caption2.weight(.semibold))
.foregroundStyle(palette.textTertiary)
}
.font(MacSettingsType.control)
.padding(.horizontal, Spacing.md)
.frame(minHeight: MacMetrics.settingsControlHeight)
.background(palette.surfaceElevated, in: Capsule())
}
.menuStyle(.borderlessButton)
.fixedSize()
}
// Mic stays geometrically centred: waveform lives inside the button,
// “press stop” floats above — neither participates in layout.
private var recordControl: some View {
recordButton
.overlay(alignment: .top) {
if viewModel.isRecording {
Text(MacL10n.string("mac.record.pressStop", language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.fixedSize()
.offset(y: -22)
.transition(.opacity.combined(with: .offset(y: 6)))
}
}
.animation(Motion.quick, value: viewModel.isRecording)
}
/// 胶囊按钮内、麦克风右侧的快捷键指引:「⌥ 按住 Option」。
private var optionHoldHint: some View {
HStack(spacing: Spacing.xxs) {
Image(systemName: "option")
.font(TypeStyle.caption.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) {
// 胶囊按钮:左侧麦克风(录音时为波形),右侧「按住 Option」提示。
HStack(spacing: Spacing.sm) {
Group {
if viewModel.isRecording {
MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent)
} else {
Image(systemName: "mic.fill")
.font(.system(size: 20, weight: .bold))
.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)
.opacity(viewModel.isProcessing ? 0.55 : 1)
.scaleEffect(viewModel.isRecording ? 1.06 : 1)
.animation(Motion.soft, value: viewModel.isRecording)
.animation(Motion.quick, value: viewModel.isProcessing)
.onAppear {
withAnimation(Motion.breath) {
pulse = true
}
}
}
private var currentTranslationLabel: String {
let current = TranslationLanguageCatalog.resolve(viewModel.config.translationTargetLocaleId)
return translationLabel(for: current)
}
private func translationLabel(for language: TranslationLanguage) -> String {
if TranslationLanguageCatalog.isOff(language.id) {
return MacL10n.string("keyboard.translation.offMenu", language: lang)
}
return language.nativeName
}
}