@hasna/recordings 0.3.13 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +279 -43
  2. package/bun.lock +37 -38
  3. package/contracts/v1/fixtures.json +1206 -0
  4. package/dist/cli/index.js +1909 -619
  5. package/dist/cli/macos-shortcut.d.ts +2 -2
  6. package/dist/contracts/hosted-v1.d.ts +105 -0
  7. package/dist/contracts/hosted-v1.d.ts.map +1 -0
  8. package/dist/contracts/hosted-v1.js +41 -0
  9. package/dist/contracts/stream-v1.d.ts +117 -0
  10. package/dist/contracts/stream-v1.d.ts.map +1 -0
  11. package/dist/contracts/stream-v1.js +35 -0
  12. package/dist/db/database.d.ts.map +1 -1
  13. package/dist/db/pg-migrations.d.ts.map +1 -1
  14. package/dist/db/recordings.d.ts +6 -0
  15. package/dist/db/recordings.d.ts.map +1 -1
  16. package/dist/hosted/index.d.ts +58 -0
  17. package/dist/hosted/index.d.ts.map +1 -0
  18. package/dist/hosted/index.js +266 -0
  19. package/dist/hosted/transport.d.ts +36 -0
  20. package/dist/hosted/transport.d.ts.map +1 -0
  21. package/dist/hosted-v1-aavn7ktb.js +4114 -0
  22. package/dist/hosted-v1-gdr9extc.js +84 -0
  23. package/dist/http/client.d.ts +183 -22
  24. package/dist/http/client.d.ts.map +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2879 -2288
  28. package/dist/lib/audio-artifact-storage.d.ts +98 -0
  29. package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
  30. package/dist/lib/capture-probe.d.ts +3 -3
  31. package/dist/lib/capture-probe.d.ts.map +1 -1
  32. package/dist/lib/config.d.ts.map +1 -1
  33. package/dist/lib/local-opt-in.d.ts +132 -0
  34. package/dist/lib/local-opt-in.d.ts.map +1 -0
  35. package/dist/lib/macos-bundle.d.ts +1 -1
  36. package/dist/lib/persistence-probe.d.ts +56 -17
  37. package/dist/lib/persistence-probe.d.ts.map +1 -1
  38. package/dist/lib/transcriber.d.ts.map +1 -1
  39. package/dist/mcp/index.d.ts.map +1 -1
  40. package/dist/mcp/index.js +1713 -459
  41. package/dist/sdk/index.d.ts +13 -5
  42. package/dist/sdk/index.d.ts.map +1 -1
  43. package/dist/sdk/index.js +904 -1
  44. package/dist/sdk/resolve.d.ts +53 -0
  45. package/dist/sdk/resolve.d.ts.map +1 -0
  46. package/dist/sdk/v1.generated.d.ts +6 -0
  47. package/dist/sdk/v1.generated.d.ts.map +1 -1
  48. package/dist/server/client-ip.d.ts +39 -0
  49. package/dist/server/client-ip.d.ts.map +1 -0
  50. package/dist/server/cloud-config.d.ts +12 -0
  51. package/dist/server/cloud-config.d.ts.map +1 -1
  52. package/dist/server/cloud-readiness.d.ts.map +1 -1
  53. package/dist/server/cloud.d.ts +20 -2
  54. package/dist/server/cloud.d.ts.map +1 -1
  55. package/dist/server/index.js +590 -287
  56. package/dist/server/migrate-command.d.ts +1 -1
  57. package/dist/server/migrate-command.d.ts.map +1 -1
  58. package/dist/server/openapi.d.ts +21 -0
  59. package/dist/server/openapi.d.ts.map +1 -1
  60. package/dist/server/repo.d.ts.map +1 -1
  61. package/dist/server/serve.d.ts.map +1 -1
  62. package/dist/storage.d.ts +2 -2
  63. package/dist/storage.d.ts.map +1 -1
  64. package/dist/storage.js +1358 -874
  65. package/dist/store.d.ts +12 -3
  66. package/dist/store.d.ts.map +1 -1
  67. package/dist/types/index.d.ts +15 -0
  68. package/dist/types/index.d.ts.map +1 -1
  69. package/docs/hosted-sdk.md +71 -0
  70. package/docs/wire-contracts.md +24 -0
  71. package/package.json +33 -11
  72. package/packaging/macos/build_release_pkg.sh +3 -3
  73. package/packaging/macos/managed_bootstrap.sh +4 -4
  74. package/packaging/macos/scripts/postinstall +2 -2
  75. package/packaging/macos/scripts/preinstall +2 -2
  76. package/scripts/build_companion_cli.sh +7 -1
  77. package/scripts/ci-linux-suite.ts +23 -13
  78. package/scripts/install_macos_app.sh +27 -24
  79. package/scripts/macos_artifact.ts +60 -49
  80. package/scripts/migrate.ts +4 -20
  81. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  82. package/scripts/native/recordings_fs_guard.c +36 -4
  83. package/scripts/native-core-receipt.py +171 -0
  84. package/scripts/native_fs_guard.ts +2 -0
  85. package/scripts/release-suite-gate.ts +227 -150
  86. package/scripts/resolve_tailscale_cli.sh +24 -3
  87. package/scripts/smoke_macos_app.sh +21 -21
  88. package/src/native/Recordings/App/ContentView.swift +53 -68
  89. package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
  90. package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
  91. package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
  92. package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
  93. package/src/native/Recordings/App/RecordingsApp.swift +185 -9
  94. package/src/native/Recordings/App/RecordingsListView.swift +60 -112
  95. package/src/native/Recordings/App/RecordingsStore.swift +124 -109
  96. package/src/native/Recordings/App/Theme.swift +73 -61
  97. package/src/native/Recordings/Package.swift +1 -0
  98. package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
  99. package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
  100. package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
  101. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
  102. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
  103. package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
  104. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +539 -89
  105. package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
  106. package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
  107. package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +112 -0
  108. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
  109. package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
  110. package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
  111. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
  112. package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
  113. package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
  114. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +454 -76
  115. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
  116. package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
  117. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
  118. package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
  119. package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
  120. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
  121. package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
  122. package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
  123. package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +308 -0
  124. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
  125. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +123 -21
  126. package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
  127. package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
  128. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
  129. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
  130. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
  131. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
  132. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
  133. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
  134. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
  135. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
  136. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
  137. package/src/native/Recordings/build.sh +8 -7
  138. package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
  139. package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
  140. package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
  141. package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
  142. package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
  143. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
  144. package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
  145. package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
  146. package/dist/__tests__/preload.d.ts +0 -2
  147. package/dist/__tests__/preload.d.ts.map +0 -1
  148. package/src/native/Recordings/App/SidebarView.swift +0 -212
@@ -1,468 +1,66 @@
1
1
  import SwiftUI
2
2
  import RecordingsLib
3
- @preconcurrency import KeyboardShortcuts
4
3
 
5
- /// The Recordings workspace — the app's first screen. One Liquid-Glass hero drives the whole
6
- /// flow: speak, and the app types it, answers a question, or edits the selection. There is no
7
- /// mode selector; the phase (idle → listening → finalizing → processing → ready/error) is the
8
- /// only state the page renders.
9
4
  struct RecordWorkspaceView: View {
10
5
  @ObservedObject var store: RecordingsStore
11
- @Environment(\.colorScheme) private var colorScheme
12
- @Environment(\.accessibilityReduceMotion) private var reduceMotion
13
- @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
14
- @Namespace private var glass
15
-
16
6
  private var engine: RecordingEngine { store.engine }
17
- private var phase: RecordingFlowPhase { engine.flowPhase }
18
-
19
- var body: some View {
20
- ScrollView {
21
- VStack(spacing: 22) {
22
- GlassEffectContainer(spacing: 12) {
23
- hero
24
- .frame(maxWidth: 560)
25
- }
26
- .padding(.top, 28)
27
-
28
- if let reply = engine.conversationReply {
29
- replyCard(reply)
30
- }
31
-
32
- activeProjectRow
33
-
34
- if let synchronizationError = store.projectStore.synchronizationError {
35
- projectSynchronizationWarning(synchronizationError)
36
- }
37
-
38
- if !engine.recentTranscriptions.isEmpty {
39
- recentStrip
40
- }
41
-
42
- Spacer(minLength: 12)
43
- }
44
- .frame(maxWidth: .infinity)
45
- .padding(.horizontal, 28)
46
- .padding(.bottom, 24)
47
- }
48
- .animation(reduceMotion ? nil : .smooth(duration: 0.28), value: phase)
49
- .onChange(of: engine.isTranscribing) { wasTranscribing, isTranscribing in
50
- // Refresh promptly when the foreground path finishes. RecordingsStore also
51
- // observes confirmed persistence so async saves still refresh after this view
52
- // has been unmounted, without treating failed saves as completed recordings.
53
- if wasTranscribing && !isTranscribing { store.loadLibrary() }
54
- }
55
- }
56
-
57
- // MARK: - Hero (single morphing glass surface)
58
-
59
- @ViewBuilder
60
- private var hero: some View {
61
- // The hidden template is the tallest phase layout (listening, with the full
62
- // six-line live-text reservation). Every phase renders inside that fixed envelope,
63
- // so streaming live text and phase transitions can never shift the content below
64
- // the hero. The template scales with Dynamic Type because it uses the real fonts.
65
- let content = ZStack {
66
- heroSizingTemplate
67
- .hidden()
68
- .accessibilityHidden(true)
69
- VStack(spacing: 14) { heroContent }
70
- }
71
- .frame(maxWidth: .infinity, minHeight: 208)
72
- .padding(26)
73
- switch ChromeSurface.forReducedTransparency(reduceTransparency) {
74
- case .opaque:
75
- // Reduce Transparency: a fully opaque system background — never a material,
76
- // which still composites the desktop through the window.
77
- content
78
- .background(
79
- Color(NSColor.windowBackgroundColor),
80
- in: .rect(cornerRadius: Theme.cornerLarge)
81
- )
82
- .overlay(
83
- RoundedRectangle(cornerRadius: Theme.cornerLarge, style: .continuous)
84
- .strokeBorder(.separator, lineWidth: 1)
85
- )
86
- case .liquidGlass:
87
- content
88
- .glassEffect(heroGlass, in: .rect(cornerRadius: Theme.cornerLarge))
89
- .glassEffectID("record-hero", in: glass)
90
- }
91
- }
92
-
93
- private var heroGlass: Glass {
94
- switch phase {
95
- case .idle: return .regular.interactive()
96
- case .listening: return .regular.tint(Theme.recordRed.opacity(0.18))
97
- case .finalizing, .processing: return .regular.tint(.orange.opacity(0.12))
98
- case .ready: return .regular.tint(.green.opacity(0.10))
99
- case .failed: return .regular.tint(.orange.opacity(0.16))
100
- }
101
- }
102
-
103
- @ViewBuilder
104
- private var heroContent: some View {
105
- switch phase {
106
- case .idle:
107
- idleContent
108
- case .listening:
109
- listeningContent
110
- case .finalizing:
111
- busyContent(label: "Finishing up…", detail: "Capturing the last words", showsLiveText: true)
112
- case .processing(let label):
113
- busyContent(
114
- label: label,
115
- detail: nil,
116
- showsLiveText: false,
117
- showsCancel: engine.canCancelIntentDelivery
118
- )
119
- case .ready(let summary):
120
- readyContent(summary: summary)
121
- case .failed(let message):
122
- failedContent(message: message)
123
- }
7
+ private var busy: Bool { !engine.canStartRecording && !engine.captureIsActive }
8
+ private var symbol: String {
9
+ if engine.captureIsActive { return engine.isPaused ? "play.fill" : "stop.fill" }
10
+ if busy { return "ellipsis" }
11
+ return store.hasPlayback ? (store.isPlaying ? "pause.fill" : "play.fill") : "mic.fill"
124
12
  }
125
13
 
126
- /// Invisible layout twin of the listening phase; see `hero`. Uses the same controls and
127
- /// fonts so its height tracks the real layout at every Dynamic Type size.
128
- private var heroSizingTemplate: some View {
129
- VStack(spacing: 14) {
130
- HStack(spacing: 12) {
131
- Image(systemName: "waveform").font(.largeTitle)
132
- Text("0:00:00")
133
- .font(.system(size: 34, weight: .semibold, design: .rounded).monospacedDigit())
134
- }
135
- liveTextReservation
136
- HStack(spacing: 12) {
137
- Button {} label: { Label("Discard", systemImage: "xmark") }
138
- .buttonStyle(.glass)
139
- Button {} label: { Label("Stop & Transcribe", systemImage: "stop.fill") }
140
- .buttonStyle(.glassProminent)
141
- }
142
- .controlSize(.large)
143
- .disabled(true)
144
- }
145
- }
146
-
147
- /// Reserves exactly six lines at the live-text font so the region cannot grow as words
148
- /// stream in.
149
- private var liveTextReservation: some View {
150
- Text(String(repeating: "M\n", count: 5) + "M")
151
- .font(.system(.title3, design: .rounded))
152
- .lineLimit(6)
153
- .frame(maxWidth: .infinity, alignment: .leading)
154
- }
155
-
156
- // MARK: Idle
157
-
158
- private var idleContent: some View {
159
- let presentation = RecordingStartControlPresentation(
160
- kind: .record,
161
- canStartRecording: engine.canStartRecording
162
- )
163
- return VStack(spacing: 14) {
14
+ var body: some View {
15
+ VStack(spacing: 0) {
16
+ Spacer().frame(height: 16)
164
17
  Button {
165
- engine.startRecording()
18
+ if engine.isPaused { engine.togglePause() }
19
+ else if engine.captureIsActive { engine.stopAndTranscribe() }
20
+ else if store.hasPlayback { store.toggleLatestPlayback() }
21
+ else { store.beginRecording() }
166
22
  } label: {
167
- VStack(spacing: 10) {
168
- Image(systemName: "mic.fill").font(.system(size: 44, weight: .semibold)).foregroundStyle(.tint)
169
- Text(presentation.title).font(.system(.title, design: .rounded).weight(.semibold)).foregroundStyle(.primary)
170
- Text(idleHint).font(.callout).foregroundStyle(.secondary)
171
- }
172
- .frame(maxWidth: .infinity).padding(.vertical, 14).contentShape(.rect)
23
+ GlassCircle(symbol: symbol, size: 88,
24
+ red: engine.captureIsActive && !engine.isPaused,
25
+ progress: store.hasPlayback && !engine.captureIsActive ? store.playbackProgress : nil)
173
26
  }
174
27
  .buttonStyle(.plain)
28
+ .disabled(busy)
29
+ .accessibilityLabel(engine.captureIsActive ? (engine.isPaused ? "Resume recording" : "Stop and transcribe") : (store.hasPlayback ? "Play recording" : "Start recording"))
175
30
  .keyboardShortcut(.defaultAction)
176
- .disabled(!presentation.isEnabled)
177
- .accessibilityLabel(presentation.accessibilityLabel)
178
-
179
- Text("Speak Recordings types what you say, answers questions, and edits selected text.")
180
- .font(.caption)
181
- .foregroundStyle(.tertiary)
182
- .multilineTextAlignment(.center)
183
-
184
- if engine.statusMessage != "Ready" {
185
- Text(engine.statusMessage)
186
- .font(.caption)
187
- .foregroundStyle(Color.orange)
188
- .multilineTextAlignment(.center)
189
- }
190
- }
191
- .tint(Theme.accent)
192
- }
193
-
194
- private var idleHint: String {
195
- if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
196
- return "Click, or hold \(shortcut.description)"
197
- }
198
- return "Click to start"
199
- }
200
-
201
- // MARK: Listening
202
-
203
- private var listeningContent: some View {
204
- VStack(spacing: 14) {
205
- HStack(spacing: 12) {
206
- Image(systemName: "waveform")
207
- .symbolEffect(.variableColor.iterative, isActive: !reduceMotion)
208
- .foregroundStyle(Theme.recordRed).font(.largeTitle)
209
- .accessibilityHidden(true)
210
- Text(fmt(engine.recordingDuration))
211
- .font(.system(size: 34, weight: .semibold, design: .rounded).monospacedDigit())
212
- .contentTransition(reduceMotion ? .identity : .numericText())
213
- .accessibilityLabel("Recording, \(fmt(engine.recordingDuration))")
214
- }
215
- liveText(placeholder: "Listening…")
216
- HStack(spacing: 12) {
217
- Button(role: .cancel) { engine.cancelRecording() } label: {
218
- Label("Discard", systemImage: "xmark")
219
- }
220
- .buttonStyle(.glass)
221
- .keyboardShortcut(.cancelAction)
222
- .accessibilityLabel("Discard recording")
223
- Button { engine.stopAndTranscribe() } label: {
224
- Label("Stop & Transcribe", systemImage: "stop.fill")
225
- }
226
- .buttonStyle(.glassProminent).tint(Theme.recordRed)
227
- .keyboardShortcut(.defaultAction)
228
- .accessibilityLabel("Stop and transcribe")
229
- }
230
- .controlSize(.large)
231
- }
232
- }
233
-
234
- // MARK: Finalizing / Processing
235
-
236
- private func busyContent(
237
- label: String,
238
- detail: String?,
239
- showsLiveText: Bool,
240
- showsCancel: Bool = false
241
- ) -> some View {
242
- VStack(spacing: 14) {
243
- HStack(spacing: 10) {
244
- ProgressView().controlSize(.large)
245
- Text(label).font(.system(.title2, design: .rounded)).foregroundStyle(.secondary)
246
- }
247
- .accessibilityElement(children: .combine)
248
- if let detail {
249
- Text(detail).font(.caption).foregroundStyle(.tertiary)
250
- }
251
- if showsLiveText {
252
- liveText(placeholder: "Finishing up…")
253
- }
254
- if showsCancel {
255
- Button(role: .cancel) {
256
- engine.cancelIntentProcessing()
257
- } label: {
258
- Label("Cancel", systemImage: "xmark")
259
- }
260
- .buttonStyle(.glass)
261
- .controlSize(.large)
262
- .keyboardShortcut(.cancelAction)
263
- .help("Stop waiting — the transcript stays in Recent")
264
- .accessibilityLabel("Cancel and keep the transcript")
265
- }
266
- }
267
- }
268
-
269
- // MARK: Ready
270
-
271
- private func readyContent(summary: String) -> some View {
272
- let presentation = RecordingStartControlPresentation(
273
- kind: .recordAgain,
274
- canStartRecording: engine.canStartRecording
275
- )
276
- return VStack(spacing: 14) {
277
- Image(systemName: "checkmark.circle.fill")
278
- .font(.system(size: 40, weight: .semibold))
279
- .foregroundStyle(.green)
280
- .accessibilityHidden(true)
281
- Text(summary)
282
- .font(.system(.title3, design: .rounded))
283
- .multilineTextAlignment(.center)
284
- Button {
285
- engine.startRecording()
286
- } label: {
287
- Label(presentation.title, systemImage: "mic.fill")
288
- }
289
- .buttonStyle(.glass)
290
- .controlSize(.large)
291
- .keyboardShortcut(.defaultAction)
292
- .disabled(!presentation.isEnabled)
293
- .accessibilityLabel(presentation.accessibilityLabel)
294
- }
295
- .accessibilityElement(children: .contain)
296
- }
297
-
298
- // MARK: Error
299
-
300
- private func failedContent(message: String) -> some View {
301
- let presentation = RecordingStartControlPresentation(
302
- kind: .tryAgain,
303
- canStartRecording: engine.canStartRecording
304
- )
305
- return VStack(spacing: 14) {
306
- Image(systemName: "exclamationmark.triangle.fill")
307
- .font(.system(size: 36, weight: .semibold))
308
- .foregroundStyle(.orange)
309
- .accessibilityHidden(true)
310
- Text(message)
311
- .font(.system(.title3, design: .rounded))
312
- .multilineTextAlignment(.center)
313
- Button {
314
- engine.startRecording()
315
- } label: {
316
- Label(presentation.title, systemImage: "mic.fill")
317
- }
318
- .buttonStyle(.glass)
319
- .controlSize(.large)
320
- .keyboardShortcut(.defaultAction)
321
- .disabled(!presentation.isEnabled)
322
- .accessibilityLabel(presentation.accessibilityLabel)
323
- }
324
- .accessibilityElement(children: .contain)
325
- }
326
-
327
- /// Live text renders inside a fixed six-line reservation: the region's size never
328
- /// depends on how much has been transcribed, so nothing below it can shift.
329
- private func liveText(placeholder: String) -> some View {
330
- ZStack(alignment: .topLeading) {
331
- liveTextReservation
332
- .hidden()
333
- .accessibilityHidden(true)
334
- if !engine.liveTranscriptionText.isEmpty {
335
- Text(engine.liveTranscriptionText)
336
- .font(.system(.title3, design: .rounded))
337
- .frame(maxWidth: .infinity, alignment: .leading)
338
- .lineLimit(6).contentTransition(.opacity)
339
- } else {
340
- Text(placeholder).font(.callout).foregroundStyle(.secondary)
341
- .frame(maxWidth: .infinity, alignment: .leading)
342
- }
343
- }
344
- }
345
-
346
- // MARK: - Conversation reply
347
-
348
- /// Content surface, deliberately not glass: answers must stay highly readable.
349
- private func replyCard(_ reply: ConversationReply) -> some View {
350
- VStack(alignment: .leading, spacing: 8) {
351
- HStack {
352
- Label("You asked", systemImage: "questionmark.bubble")
353
- .font(.caption.weight(.semibold)).foregroundStyle(.secondary)
354
- Spacer()
355
- Button {
356
- engine.copyToClipboard(reply.answer)
357
- } label: {
358
- Label("Copy", systemImage: "doc.on.doc")
31
+ .contextMenu {
32
+ if engine.captureIsActive {
33
+ Button(engine.isPaused ? "Resume recording" : "Pause recording") { engine.togglePause() }.disabled(!engine.isRecording)
34
+ Button("Discard recording") { engine.cancelRecording() }
35
+ } else if store.hasPlayback {
36
+ Button("New recording") { store.clearPlayback(); store.beginRecording() }
359
37
  }
360
- .buttonStyle(.borderless)
361
- .help("Copy the answer")
362
- .accessibilityLabel("Copy answer")
363
38
  }
364
- Text(reply.question)
365
- .font(.callout).foregroundStyle(.secondary)
366
- .frame(maxWidth: .infinity, alignment: .leading)
367
- Divider().opacity(0.4)
368
- Text(reply.answer)
369
- .font(.body)
370
- .textSelection(.enabled)
371
- .frame(maxWidth: .infinity, alignment: .leading)
372
- }
373
- .padding(14)
374
- .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: Theme.cornerMedium))
375
- .frame(maxWidth: 560)
376
- .accessibilityElement(children: .contain)
377
- .accessibilityLabel("Answer: \(reply.answer)")
378
- }
379
-
380
- @ViewBuilder
381
- private var activeProjectRow: some View {
382
- if !store.projects.isEmpty {
383
- HStack(spacing: 8) {
384
- Image(systemName: "folder.fill")
385
- .foregroundStyle(store.projectStore.activeProject != nil ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
386
- Menu {
387
- Button("None") { selectProject(nil) }
388
- Divider()
389
- ForEach(store.projects) { project in
390
- Button {
391
- selectProject(project.id)
392
- } label: {
393
- if project.id == store.projectStore.settings.activeProjectId {
394
- Label(project.name, systemImage: "checkmark")
395
- } else { Text(project.name) }
396
- }
39
+ Text(Theme.clock(store.hasPlayback && !engine.captureIsActive ? store.playbackTime : engine.recordingDuration))
40
+ .font(.system(size: 17, weight: .regular)).monospacedDigit()
41
+ .foregroundStyle(.secondary).shadow(color: .white.opacity(0.85), radius: 0, y: 1)
42
+ .padding(.top, 12)
43
+ if busy {
44
+ HStack(spacing: 6) {
45
+ ProgressView().controlSize(.mini)
46
+ Text(engine.statusMessage).lineLimit(1)
47
+ if engine.canCancelIntentDelivery {
48
+ Button { engine.cancelIntentProcessing() } label: { Image(systemName: "xmark.circle") }
49
+ .buttonStyle(.plain).accessibilityLabel("Cancel processing")
397
50
  }
398
- } label: {
399
- Text(store.projectStore.activeProject?.name ?? "No project")
400
51
  }
401
- .menuStyle(.borderlessButton).fixedSize()
402
- .disabled(!store.projectStore.canMutateProjects)
403
- Text("· transcripts are tagged to this project").font(.caption).foregroundStyle(.tertiary)
404
- Spacer()
405
- }
406
- .font(.system(.callout, design: .rounded))
407
- .tint(Theme.accent)
408
- .frame(maxWidth: 560)
409
- }
410
- }
411
-
412
- private func selectProject(_ id: String?) {
413
- do {
414
- try store.projectStore.setActive(id)
415
- } catch {
416
- store.operationError = store.projectStore.persistenceError ?? error.localizedDescription
417
- }
418
- }
419
-
420
- private func projectSynchronizationWarning(_ message: String) -> some View {
421
- HStack(spacing: 8) {
422
- Image(systemName: "exclamationmark.triangle.fill")
423
- .foregroundStyle(.orange)
424
- Text(message)
425
- .font(.caption)
426
- .foregroundStyle(.secondary)
427
- .lineLimit(2)
428
- Spacer()
429
- Button("Retry") { store.reconcileProjects() }
430
- .buttonStyle(.borderless)
431
- .disabled(store.projectStore.isSynchronizingProjects)
432
- }
433
- .frame(maxWidth: 560)
434
- .accessibilityElement(children: .combine)
435
- }
436
-
437
- // MARK: - Recent strip
438
-
439
- private var recentStrip: some View {
440
- let items = Array(engine.recentTranscriptions.prefix(3))
441
- return VStack(alignment: .leading, spacing: 6) {
442
- Text("JUST NOW").font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
443
- ForEach(Array(items.enumerated()), id: \.element.id) { i, item in
444
- HStack(alignment: .top, spacing: 8) {
445
- Image(systemName: "text.alignleft").font(.caption).foregroundStyle(.tertiary).padding(.top, 2)
446
- Text(item.displayText).font(.callout).lineLimit(2)
447
- .frame(maxWidth: .infinity, alignment: .leading)
448
- Button {
449
- engine.pasteIntoFrontApp(item.displayText)
450
- } label: { Image(systemName: "arrow.up.right.square") }
451
- .buttonStyle(.plain).foregroundStyle(.secondary).help("Paste into front app")
452
- .accessibilityLabel("Paste transcript into front app")
453
- }
454
- .padding(.vertical, 4)
455
- if i < items.count - 1 { Divider().opacity(0.3) }
456
- }
457
- }
458
- .frame(maxWidth: 560)
459
- .padding(14)
460
- .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: Theme.cornerMedium))
461
- }
462
-
463
- private func fmt(_ t: TimeInterval) -> String {
464
- let total = Int(t)
465
- let h = total / 3600, m = (total % 3600) / 60, s = total % 60
466
- return h > 0 ? String(format: "%d:%02d:%02d", h, m, s) : String(format: "%d:%02d", m, s)
52
+ .font(.system(size: 11)).foregroundStyle(.secondary).padding(.top, 13)
53
+ } else if let blocked = engine.blockedReason {
54
+ Text(blocked).font(.system(size: 10)).foregroundStyle(.orange).lineLimit(2)
55
+ .multilineTextAlignment(.center).padding(.horizontal, 20).padding(.top, 8)
56
+ } else if case .failed(let message) = engine.flowPhase {
57
+ Text(message).font(.system(size: 10)).foregroundStyle(.orange).lineLimit(2)
58
+ .multilineTextAlignment(.center).padding(.horizontal, 20).padding(.top, 8)
59
+ } else if engine.canCancelIntentDelivery {
60
+ Button("Cancel") { engine.cancelIntentProcessing() }.font(.caption)
61
+ }
62
+ Spacer(minLength: 0)
63
+ }
64
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
467
65
  }
468
66
  }
@@ -0,0 +1,75 @@
1
+ import SwiftUI
2
+ import AVFoundation
3
+ import RecordingsLib
4
+
5
+ struct RecorderSettingsView: View {
6
+ @ObservedObject var store: RecordingsStore
7
+ let close: () -> Void
8
+ let advanced: () -> Void
9
+ @State private var showsAudioInput = false
10
+ @AppStorage("recordingsNotificationSound") private var notificationSound = false
11
+
12
+ var body: some View {
13
+ VStack(spacing: 0) {
14
+ HStack {
15
+ Button(action: close) { Image(systemName: "chevron.left").font(.system(size: 12)) }
16
+ .buttonStyle(GlassButtonStyle()).accessibilityLabel("Back to recorder")
17
+ Text("Settings").font(.system(size: 13, weight: .semibold))
18
+ Spacer()
19
+ }.frame(height: 18)
20
+ Spacer().frame(height: 18)
21
+ HStack {
22
+ Text("Audio Input")
23
+ Spacer()
24
+ Button { showsAudioInput.toggle() } label: {
25
+ HStack {
26
+ Image(systemName: "mic").font(.system(size: 14))
27
+ Text(AVCaptureDevice.default(for: .audio)?.localizedName ?? "System microphone").lineLimit(1)
28
+ Spacer(); Image(systemName: "chevron.down").font(.system(size: 12))
29
+ }.padding(.horizontal, 10).frame(width: 230, height: 30).background(field)
30
+ }.buttonStyle(.plain)
31
+ .popover(isPresented: $showsAudioInput) {
32
+ VStack(alignment: .leading, spacing: 14) {
33
+ Label("System default microphone", systemImage: "checkmark")
34
+ Button("Sound Settings…") {
35
+ if let url = URL(string: "x-apple.systempreferences:com.apple.Sound-Settings.extension") { NSWorkspace.shared.open(url) }
36
+ }
37
+ }.font(.system(size: 13)).padding(12).background(FrostedBackground())
38
+ }
39
+
40
+ }
41
+ Spacer().frame(height: 16)
42
+ HStack {
43
+ Text("Audio Quality")
44
+ Spacer()
45
+ HStack { Text("High (24 kHz PCM)"); Spacer() }
46
+ .padding(.horizontal, 10).frame(width: 230, height: 30).background(field)
47
+ .help("Uncompressed audio at the native transcription sample rate.")
48
+ }
49
+ Spacer().frame(height: 20)
50
+ settingToggle("Auto-paste transcriptions", isOn: Binding(get: { store.engine.autoPasteEnabled }, set: { store.engine.autoPasteEnabled = $0 }))
51
+ Spacer().frame(height: 18)
52
+ settingToggle("Play notification sound", isOn: $notificationSound)
53
+ Spacer().frame(height: 18)
54
+ settingToggle("Show in menu bar", isOn: $store.showMenuBar)
55
+ Spacer().frame(height: 16)
56
+ HStack {
57
+ Text("Recordings save automatically.").foregroundStyle(.secondary)
58
+ Spacer()
59
+ Button("API & Advanced…", action: advanced).buttonStyle(.plain)
60
+ }.font(.system(size: 11))
61
+ Spacer(minLength: 0)
62
+ }
63
+ .font(.system(size: 13)).toggleStyle(.switch).controlSize(.small)
64
+ .padding(.horizontal, 16).padding(.top, 8)
65
+ .frame(width: 420, height: 314)
66
+
67
+ }
68
+
69
+ private var field: some View {
70
+ GlassInset()
71
+ }
72
+ private func settingToggle(_ label: String, isOn: Binding<Bool>) -> some View {
73
+ HStack { Text(label); Spacer(); Toggle(label, isOn: isOn).labelsHidden() }.frame(height: 26)
74
+ }
75
+ }
@@ -26,7 +26,7 @@ struct RecordingDetailView: View {
26
26
  VStack(alignment: .leading, spacing: 0) {
27
27
  HStack(spacing: 10) {
28
28
  Text(rec.createdDate.map { dateLabel($0) } ?? "Recording")
29
- .font(.system(.title2, design: .rounded).weight(.semibold))
29
+ .font(.system(size: 13, weight: .semibold))
30
30
  Spacer()
31
31
  toolbar(rec)
32
32
  }
@@ -35,7 +35,7 @@ struct RecordingDetailView: View {
35
35
 
36
36
  ScrollView {
37
37
  Text(rec.displayText.isEmpty ? "No transcript" : rec.displayText)
38
- .font(.system(.title3, design: .rounded))
38
+ .font(.system(size: 13))
39
39
  .textSelection(.enabled)
40
40
  .frame(maxWidth: .infinity, alignment: .leading)
41
41
  .padding(.horizontal, 18).padding(.top, 14)
@@ -80,7 +80,7 @@ struct RecordingDetailView: View {
80
80
 
81
81
  private func iconButton(_ name: String, help: String, action: @escaping () -> Void) -> some View {
82
82
  Button(action: action) { Image(systemName: name).frame(width: 24, height: 24) }
83
- .buttonStyle(.plain).help(help)
83
+ .buttonStyle(GlassButtonStyle()).help(help)
84
84
  }
85
85
 
86
86
  private func metadata(_ rec: Recording) -> some View {
@@ -88,7 +88,6 @@ struct RecordingDetailView: View {
88
88
  if rec.durationMs > 0 { metaItem("clock", rec.durationLabel) }
89
89
  if let model = rec.modelUsed { metaItem("cpu", model) }
90
90
  if let lang = rec.language, !lang.isEmpty { metaItem("globe", lang) }
91
- if let name = store.projectName(rec.projectId) { metaItem("folder", name) }
92
91
  if let machine = rec.machineId, !machine.isEmpty { metaItem("desktopcomputer", machine) }
93
92
  if !rec.tags.isEmpty { metaItem("number", rec.tags.joined(separator: ", ")) }
94
93
  Spacer()