@hasna/recordings 0.2.10 → 0.2.13
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.
- package/README.md +350 -35
- package/bun.lock +3 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts +2 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -0
- package/dist/cli/index.js +1728 -320
- package/dist/cli/macos-permissions.d.ts +13 -0
- package/dist/cli/macos-permissions.d.ts.map +1 -0
- package/dist/cli/options.d.ts +12 -0
- package/dist/cli/options.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +2 -1
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/db/remote-storage.d.ts +5 -1
- package/dist/db/remote-storage.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +628 -147
- package/dist/lib/bun-runtime.d.ts +11 -0
- package/dist/lib/bun-runtime.d.ts.map +1 -0
- package/dist/lib/config.d.ts +8 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/enhancer.d.ts +10 -2
- package/dist/lib/enhancer.d.ts.map +1 -1
- package/dist/lib/install-maintenance.d.ts +24 -0
- package/dist/lib/install-maintenance.d.ts.map +1 -0
- package/dist/lib/machine.d.ts +2 -0
- package/dist/lib/machine.d.ts.map +1 -0
- package/dist/lib/recorder.d.ts +2 -1
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/recording-create-identity.d.ts +13 -0
- package/dist/lib/recording-create-identity.d.ts.map +1 -0
- package/dist/lib/release-install-policy.d.ts +31 -0
- package/dist/lib/release-install-policy.d.ts.map +1 -0
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +977 -194
- package/dist/sdk/index.js +5 -1
- package/dist/sdk/v1.generated.d.ts +4 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/cloud-config.d.ts +9 -0
- package/dist/server/cloud-config.d.ts.map +1 -0
- package/dist/server/cloud-readiness.d.ts +10 -0
- package/dist/server/cloud-readiness.d.ts.map +1 -0
- package/dist/server/cloud.d.ts +9 -18
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +1654 -304
- package/dist/server/migrate-command.d.ts +11 -0
- package/dist/server/migrate-command.d.ts.map +1 -0
- package/dist/server/openapi.d.ts +22 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.d.ts +8 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/dist/server/serve.d.ts +6 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage.js +515 -80
- package/dist/store.d.ts +3 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +7 -4
- package/packaging/macos/Empty.entitlements +5 -0
- package/packaging/macos/Library/LaunchDaemons/com.hasna.recordings.updater.plist +34 -0
- package/packaging/macos/Verifier.entitlements +10 -0
- package/packaging/macos/artifact-verifier.sb +33 -0
- package/packaging/macos/build_release_pkg.sh +872 -0
- package/packaging/macos/managed_bootstrap.sh +623 -0
- package/packaging/macos/pkgutil_fingerprint.awk +37 -0
- package/packaging/macos/release_lifecycle.ts +463 -0
- package/packaging/macos/scripts/postinstall +565 -0
- package/packaging/macos/scripts/preinstall +297 -0
- package/scripts/build_companion_cli.sh +337 -0
- package/scripts/build_native_fs_guard.sh +59 -0
- package/scripts/generate-sdk.ts +19 -1
- package/scripts/install_macos_app.sh +1922 -77
- package/scripts/macos_artifact.ts +5316 -0
- package/scripts/migrate.ts +38 -10
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
- package/scripts/native/recordings_fs_guard.c +1167 -0
- package/scripts/native_fs_guard.ts +158 -0
- package/scripts/release-guard.ts +20 -1
- package/scripts/resolve_tailscale_cli.sh +389 -0
- package/scripts/smoke_macos_app.sh +573 -0
- package/src/native/Recordings/App/ContentView.swift +83 -0
- package/src/native/Recordings/App/MenuBarStatusView.swift +102 -0
- package/src/native/Recordings/App/RecordWorkspaceView.swift +468 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +138 -0
- package/src/native/Recordings/App/RecordingsApp.swift +367 -44
- package/src/native/Recordings/App/RecordingsListView.swift +130 -0
- package/src/native/Recordings/App/RecordingsStore.swift +206 -0
- package/src/native/Recordings/App/RuntimeSmoke.swift +158 -0
- package/src/native/Recordings/App/SidebarView.swift +212 -0
- package/src/native/Recordings/App/Theme.swift +87 -0
- package/src/native/Recordings/Package.resolved +5 -5
- package/src/native/Recordings/Package.swift +62 -2
- package/src/native/Recordings/RecordingsLib/AccessibilityPromptGate.swift +104 -0
- package/src/native/Recordings/RecordingsLib/BrandAssets.swift +0 -32
- package/src/native/Recordings/RecordingsLib/ChromeSurface.swift +17 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +7 -3
- package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +37 -0
- package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +32 -2
- package/src/native/Recordings/RecordingsLib/NativeMachineIdentity.swift +24 -0
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +262 -23
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +56 -0
- package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +56 -0
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +321 -25
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +605 -90
- package/src/native/Recordings/RecordingsLib/Recording.swift +141 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +3704 -376
- package/src/native/Recordings/RecordingsLib/RecordingStartControlPresentation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.entitlements +10 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +222 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -11
- package/src/native/Recordings/RecordingsLib/SpeechIntent.swift +331 -0
- package/src/native/Recordings/RecordingsLib/SpeechIntentClassifier.swift +232 -0
- package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +17 -7
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +1343 -0
- package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +98 -0
- package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +20 -0
- package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +370 -1
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +987 -1
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +385 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +835 -8
- package/src/native/Recordings/RecordingsTests/RecordingBridgeTests.swift +180 -0
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +760 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartControlPresentationTests.swift +42 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +264 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +129 -0
- package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +600 -0
- package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +62 -1
- package/src/native/Recordings/RecordingsTests/TranscriptionResultIdentityTests.swift +21 -0
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +739 -0
- package/src/native/Recordings/Updater/Broker/ActivationRecoveryPolicy.swift +254 -0
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +463 -0
- package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +55 -0
- package/src/native/Recordings/Updater/Broker/ArtifactIngest.swift +341 -0
- package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +477 -0
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +624 -0
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +63 -0
- package/src/native/Recordings/Updater/Broker/CodeValidation.swift +561 -0
- package/src/native/Recordings/Updater/Broker/DarwinACLValidator.swift +66 -0
- package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +50 -0
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +362 -0
- package/src/native/Recordings/Updater/Broker/InstallRecovery.swift +662 -0
- package/src/native/Recordings/Updater/Broker/MonotonicState.swift +456 -0
- package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +274 -0
- package/src/native/Recordings/Updater/Broker/VerifierRunner.swift +81 -0
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +259 -0
- package/src/native/Recordings/Updater/BrokerTests/CanonicalReleaseOrderTests.swift +21 -0
- package/src/native/Recordings/Updater/Client/ClientMain.swift +119 -0
- package/src/native/Recordings/Updater/Protocol/BoundedProcess.swift +159 -0
- package/src/native/Recordings/Updater/Protocol/CandidateMetadataPolicy.swift +214 -0
- package/src/native/Recordings/Updater/Protocol/HostOSVersionPolicy.swift +55 -0
- package/src/native/Recordings/Updater/Protocol/MonotonicReleasePolicy.swift +152 -0
- package/src/native/Recordings/Updater/Protocol/ReleaseEnvelope.swift +229 -0
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +119 -0
- package/src/native/Recordings/Updater/ProtocolTests/BoundedProcessRunnerTests.swift +60 -0
- package/src/native/Recordings/Updater/ProtocolTests/CandidateMetadataPolicyTests.swift +168 -0
- package/src/native/Recordings/Updater/ProtocolTests/HostOSVersionPolicyTests.swift +62 -0
- package/src/native/Recordings/Updater/ProtocolTests/MonotonicReleasePolicyTests.swift +172 -0
- package/src/native/Recordings/Updater/ProtocolTests/ReleaseEnvelopeValidationTests.swift +65 -0
- package/src/native/Recordings/Updater/Signer/SignerMain.swift +210 -0
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +725 -0
- package/src/native/Recordings/Updater/VerifierLauncher/include/RecordingsVerifierLauncher.h +33 -0
- package/src/native/Recordings/build.sh +1990 -29
- package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +0 -380
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
@preconcurrency import Cocoa
|
|
2
|
+
import SwiftUI
|
|
3
|
+
import RecordingsLib
|
|
4
|
+
|
|
5
|
+
struct MenuBarStatusLabel: View {
|
|
6
|
+
@ObservedObject var store: RecordingsStore
|
|
7
|
+
|
|
8
|
+
var body: some View {
|
|
9
|
+
Image(systemName: presentation.iconName)
|
|
10
|
+
.accessibilityLabel(presentation.accessibilityLabel)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
private var presentation: MenuBarPresentation {
|
|
14
|
+
MenuBarPresentation(
|
|
15
|
+
isRecording: store.engine.isRecording,
|
|
16
|
+
canStartRecording: store.engine.canStartRecording,
|
|
17
|
+
statusMessage: store.engine.statusMessage
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
struct MenuBarStatusView: View {
|
|
23
|
+
@ObservedObject var store: RecordingsStore
|
|
24
|
+
let openRecordings: () -> Void
|
|
25
|
+
|
|
26
|
+
var body: some View {
|
|
27
|
+
VStack(alignment: .leading, spacing: 12) {
|
|
28
|
+
HStack(spacing: 8) {
|
|
29
|
+
Image(systemName: presentation.iconName)
|
|
30
|
+
.foregroundStyle(statusColor)
|
|
31
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
32
|
+
Text("Recordings")
|
|
33
|
+
.font(.headline)
|
|
34
|
+
Text(presentation.statusText)
|
|
35
|
+
.font(.caption)
|
|
36
|
+
.foregroundStyle(.secondary)
|
|
37
|
+
}
|
|
38
|
+
Spacer()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
Button(action: toggleRecording) {
|
|
42
|
+
Label(recordButtonTitle, systemImage: recordButtonIcon)
|
|
43
|
+
.frame(maxWidth: .infinity)
|
|
44
|
+
}
|
|
45
|
+
.buttonStyle(.borderedProminent)
|
|
46
|
+
.tint(store.engine.isRecording ? .red : .accentColor)
|
|
47
|
+
.disabled(!presentation.primaryActionEnabled)
|
|
48
|
+
|
|
49
|
+
Divider()
|
|
50
|
+
|
|
51
|
+
Button(action: openRecordings) {
|
|
52
|
+
Label("Open Recordings", systemImage: "macwindow")
|
|
53
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
54
|
+
}
|
|
55
|
+
.buttonStyle(.plain)
|
|
56
|
+
|
|
57
|
+
SettingsLink {
|
|
58
|
+
Label("Settings", systemImage: "gearshape")
|
|
59
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
60
|
+
}
|
|
61
|
+
.buttonStyle(.plain)
|
|
62
|
+
|
|
63
|
+
Button {
|
|
64
|
+
NSApplication.shared.terminate(nil)
|
|
65
|
+
} label: {
|
|
66
|
+
Label("Quit Recordings", systemImage: "power")
|
|
67
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
68
|
+
}
|
|
69
|
+
.buttonStyle(.plain)
|
|
70
|
+
}
|
|
71
|
+
.padding(14)
|
|
72
|
+
.frame(width: 260)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private var statusColor: Color {
|
|
76
|
+
store.engine.isRecording ? .red : .accentColor
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private var presentation: MenuBarPresentation {
|
|
80
|
+
MenuBarPresentation(
|
|
81
|
+
isRecording: store.engine.isRecording,
|
|
82
|
+
canStartRecording: store.engine.canStartRecording,
|
|
83
|
+
statusMessage: store.engine.statusMessage
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private var recordButtonTitle: String {
|
|
88
|
+
store.engine.isRecording ? "Stop and Transcribe" : "Start Recording"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private var recordButtonIcon: String {
|
|
92
|
+
store.engine.isRecording ? "stop.fill" : "mic.fill"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private func toggleRecording() {
|
|
96
|
+
if store.engine.isRecording {
|
|
97
|
+
store.engine.stopAndTranscribe()
|
|
98
|
+
} else {
|
|
99
|
+
store.engine.startRecording()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import RecordingsLib
|
|
3
|
+
@preconcurrency import KeyboardShortcuts
|
|
4
|
+
|
|
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
|
+
struct RecordWorkspaceView: View {
|
|
10
|
+
@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
|
+
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
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
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) {
|
|
164
|
+
Button {
|
|
165
|
+
engine.startRecording()
|
|
166
|
+
} 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)
|
|
173
|
+
}
|
|
174
|
+
.buttonStyle(.plain)
|
|
175
|
+
.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")
|
|
359
|
+
}
|
|
360
|
+
.buttonStyle(.borderless)
|
|
361
|
+
.help("Copy the answer")
|
|
362
|
+
.accessibilityLabel("Copy answer")
|
|
363
|
+
}
|
|
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
|
+
}
|
|
397
|
+
}
|
|
398
|
+
} label: {
|
|
399
|
+
Text(store.projectStore.activeProject?.name ?? "No project")
|
|
400
|
+
}
|
|
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)
|
|
467
|
+
}
|
|
468
|
+
}
|