@agent-native/core 0.114.5 → 0.114.7

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 (210) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +14 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/mcp-config-writers.ts +6 -4
  5. package/corpus/core/src/cli/mcp.ts +124 -0
  6. package/corpus/core/src/cli/skills-content/help.ts +4 -3
  7. package/corpus/core/src/cli/skills-content/index.ts +1 -0
  8. package/corpus/core/src/cli/skills-content/rewind-skill.ts +52 -0
  9. package/corpus/core/src/cli/skills.ts +115 -13
  10. package/corpus/core/src/client/resources/ResourceTree.tsx +11 -5
  11. package/corpus/core/src/client/resources/ResourcesPanel.tsx +141 -18
  12. package/corpus/core/src/mcp/screen-memory-stdio.ts +1360 -104
  13. package/corpus/core/src/mcp-client/index.ts +9 -0
  14. package/corpus/core/src/mcp-client/screen-memory-local.ts +847 -13
  15. package/corpus/templates/clips/actions/apply-rewind-extension.ts +195 -0
  16. package/corpus/templates/clips/actions/delete-agent-recording-if-unpromoted.ts +64 -0
  17. package/corpus/templates/clips/actions/get-rewind-extension-request.ts +22 -0
  18. package/corpus/templates/clips/actions/get-screen-memory-status.ts +18 -2
  19. package/corpus/templates/clips/actions/list-rewind-extension-requests.ts +52 -0
  20. package/corpus/templates/clips/actions/make-recording-private-for-rewind.ts +55 -0
  21. package/corpus/templates/clips/actions/query-screen-memory-context.ts +4 -4
  22. package/corpus/templates/clips/actions/request-rewind-extension.ts +81 -0
  23. package/corpus/templates/clips/actions/update-rewind-extension-request.ts +47 -0
  24. package/corpus/templates/clips/app/components/editor/editor-layout.tsx +25 -0
  25. package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +36 -0
  26. package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +338 -0
  27. package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +1 -16
  28. package/corpus/templates/clips/app/components/editor/timeline.tsx +26 -0
  29. package/corpus/templates/clips/app/components/meetings/meeting-card.tsx +1 -2
  30. package/corpus/templates/clips/app/i18n/en-US.ts +18 -0
  31. package/corpus/templates/clips/app/lib/ffmpeg-export.ts +19 -12
  32. package/corpus/templates/clips/app/lib/rewind-visibility.ts +31 -0
  33. package/corpus/templates/clips/app/lib/timestamp-mapping.ts +7 -0
  34. package/corpus/templates/clips/app/lib/upload-file-client.ts +17 -0
  35. package/corpus/templates/clips/changelog/2026-07-14-rewind-keeps-recent-screen-context-local-and-turns-it-into-clips.md +6 -0
  36. package/corpus/templates/clips/changelog/2026-07-17-active-clips-keep-rewind-stable-and-settings-clear.md +12 -0
  37. package/corpus/templates/clips/changelog/2026-07-17-clicking-the-clips-icon-during-a-recording-now-opens-the-app.md +6 -0
  38. package/corpus/templates/clips/changelog/2026-07-17-default-mic-now-uses-the-mac-default-and-transcription-shares-one-capture.md +6 -0
  39. package/corpus/templates/clips/changelog/2026-07-17-full-screen-clips-finalize-even-when-screencapturekit-stop-hangs.md +6 -0
  40. package/corpus/templates/clips/changelog/2026-07-17-normal-full-screen-clips-no-longer-stop-immediately-after-th.md +6 -0
  41. package/corpus/templates/clips/changelog/2026-07-17-rewind-can-hand-one-bounded-private-clip-to-your-agent.md +12 -0
  42. package/corpus/templates/clips/changelog/2026-07-17-rewind-home-can-copy-a-local-agent-prompt.md +6 -0
  43. package/corpus/templates/clips/changelog/2026-07-17-rewind-now-has-a-calm-home-status-focused-first-run-consent-.md +6 -0
  44. package/corpus/templates/clips/changelog/2026-07-17-rewind-settings-are-smaller-clearer-and-agent-first.md +6 -0
  45. package/corpus/templates/clips/changelog/2026-07-17-the-full-clips-interface-stays-available-while-recording.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-07-18-rewind-organizes-recent-work-into-local-searchable-chapters.md +8 -0
  47. package/corpus/templates/clips/changelog/2026-07-18-searchable-rewind-chapters.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-19-rewind-backed-recordings-now-start-immediately-after-the-cou.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-20-opening-zoom-meeting-launches-native-app.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-07-20-rewind-can-set-up-your-local-agent-once-so-future-requests-w.md +6 -0
  51. package/corpus/templates/clips/desktop/package.json +1 -0
  52. package/corpus/templates/clips/desktop/scripts/sign-macos-local.ts +12 -1
  53. package/corpus/templates/clips/desktop/src/app.tsx +2478 -223
  54. package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +21 -1
  55. package/corpus/templates/clips/desktop/src/components/Switch.tsx +3 -0
  56. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +156 -24
  57. package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +6 -7
  58. package/corpus/templates/clips/desktop/src/lib/open-meeting-join-url.ts +22 -0
  59. package/corpus/templates/clips/desktop/src/lib/recorder.ts +1763 -1160
  60. package/corpus/templates/clips/desktop/src/lib/rewind-agent-prompt.ts +9 -0
  61. package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +20 -0
  62. package/corpus/templates/clips/desktop/src/main.tsx +3 -0
  63. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +14 -14
  64. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +2 -3
  65. package/corpus/templates/clips/desktop/src/overlays/onboarding.tsx +4 -4
  66. package/corpus/templates/clips/desktop/src/overlays/preparing.tsx +13 -0
  67. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +27 -2
  68. package/corpus/templates/clips/desktop/src/shared/config.ts +16 -0
  69. package/corpus/templates/clips/desktop/src/styles.css +956 -134
  70. package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +5 -0
  71. package/corpus/templates/clips/desktop/src-tauri/build.rs +69 -1
  72. package/corpus/templates/clips/desktop/src-tauri/src/accessibility.rs +386 -12
  73. package/corpus/templates/clips/desktop/src-tauri/src/capture_audio_bus.rs +382 -0
  74. package/corpus/templates/clips/desktop/src-tauri/src/capture_graph.rs +777 -0
  75. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +249 -22
  76. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +148 -1
  77. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +58 -4
  78. package/corpus/templates/clips/desktop/src-tauri/src/logfile.rs +13 -1
  79. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +1505 -51
  80. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +124 -0
  81. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +1006 -95
  82. package/corpus/templates/clips/desktop/src-tauri/src/rewind_capture_suspension.rs +165 -0
  83. package/corpus/templates/clips/desktop/src-tauri/src/rewind_chapters.rs +2434 -0
  84. package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +1650 -0
  85. package/corpus/templates/clips/desktop/src-tauri/src/rewind_egress.rs +643 -0
  86. package/corpus/templates/clips/desktop/src-tauri/src/rewind_local_ask.rs +803 -0
  87. package/corpus/templates/clips/desktop/src-tauri/src/rewind_meeting_history.rs +595 -0
  88. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +2900 -91
  89. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_ocr.rs +360 -0
  90. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_transcript.rs +408 -0
  91. package/corpus/templates/clips/desktop/src-tauri/src/shortcuts.rs +114 -33
  92. package/corpus/templates/clips/desktop/src-tauri/src/state.rs +2 -2
  93. package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +13 -3
  94. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +4 -1
  95. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +7 -13
  96. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +198 -11
  97. package/corpus/templates/clips/desktop/src-tauri/tauri.alpha.conf.json +17 -0
  98. package/corpus/templates/clips/shared/meeting-join-url.ts +0 -5
  99. package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +9 -1
  100. package/corpus/templates/content/AGENTS.md +4 -1
  101. package/corpus/templates/content/actions/_content-spaces.ts +6 -1
  102. package/corpus/templates/content/actions/_database-row-batch.ts +15 -2
  103. package/corpus/templates/content/actions/_database-utils.ts +83 -15
  104. package/corpus/templates/content/actions/add-database-item.ts +3 -0
  105. package/corpus/templates/content/actions/connect-local-folder-source.ts +2 -1
  106. package/corpus/templates/content/actions/delete-content-database.ts +12 -9
  107. package/corpus/templates/content/actions/delete-database-items.ts +3 -2
  108. package/corpus/templates/content/actions/delete-document.ts +233 -6
  109. package/corpus/templates/content/actions/duplicate-database-item.ts +1 -0
  110. package/corpus/templates/content/actions/get-document.ts +4 -1
  111. package/corpus/templates/content/actions/list-content-databases.ts +1 -0
  112. package/corpus/templates/content/actions/list-documents.ts +1 -0
  113. package/corpus/templates/content/actions/list-trashed-content-databases.ts +8 -2
  114. package/corpus/templates/content/actions/list-trashed-documents.ts +50 -0
  115. package/corpus/templates/content/actions/permanently-delete-document.ts +28 -0
  116. package/corpus/templates/content/actions/pull-document.ts +15 -2
  117. package/corpus/templates/content/actions/restore-content-database.ts +48 -11
  118. package/corpus/templates/content/actions/restore-document.ts +28 -0
  119. package/corpus/templates/content/actions/search-documents.ts +2 -1
  120. package/corpus/templates/content/actions/view-screen.ts +2 -1
  121. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +149 -55
  122. package/corpus/templates/content/app/components/editor/database/GalleryView.tsx +7 -2
  123. package/corpus/templates/content/app/components/editor/database/ListView.tsx +7 -2
  124. package/corpus/templates/content/app/components/editor/database/TimelineView.tsx +3 -0
  125. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +26 -5
  126. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +243 -163
  127. package/corpus/templates/content/app/components/sidebar/WorkspaceSourceMenu.tsx +154 -0
  128. package/corpus/templates/content/app/components/sidebar/select-content-space.ts +11 -0
  129. package/corpus/templates/content/app/hooks/use-content-database.ts +6 -0
  130. package/corpus/templates/content/app/hooks/use-documents.ts +57 -0
  131. package/corpus/templates/content/app/i18n-data.ts +83 -2
  132. package/corpus/templates/content/app/routes/_app.local-files.tsx +8 -1
  133. package/corpus/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md +6 -0
  134. package/corpus/templates/content/changelog/2026-07-20-trash-actions-now-preserve-independent-archived-pages-enforc.md +6 -0
  135. package/corpus/templates/content/changelog/2026-07-20-workspace-toggles-now-stay-independently-open-or-closed-and-.md +6 -0
  136. package/corpus/templates/content/changelog/2026-07-20-workspaces-can-be-added-from-a-blank-workspace-or-a-connecte.md +6 -0
  137. package/corpus/templates/content/parity/matrix.md +1 -1
  138. package/corpus/templates/content/parity/matrix.ts +3 -0
  139. package/corpus/templates/content/server/db/schema.ts +2 -0
  140. package/corpus/templates/content/server/lib/public-documents.ts +8 -2
  141. package/corpus/templates/content/server/plugins/db.ts +52 -0
  142. package/corpus/templates/content/server/routes/api/document-agent-context.json.get.ts +2 -2
  143. package/corpus/templates/content/shared/api.ts +10 -0
  144. package/corpus/toolkit/CHANGELOG.md +6 -0
  145. package/corpus/toolkit/package.json +1 -1
  146. package/corpus/toolkit/src/ui/progress.tsx +3 -2
  147. package/dist/cli/mcp-config-writers.d.ts +2 -2
  148. package/dist/cli/mcp-config-writers.d.ts.map +1 -1
  149. package/dist/cli/mcp-config-writers.js +6 -6
  150. package/dist/cli/mcp-config-writers.js.map +1 -1
  151. package/dist/cli/mcp.d.ts +14 -0
  152. package/dist/cli/mcp.d.ts.map +1 -1
  153. package/dist/cli/mcp.js +79 -0
  154. package/dist/cli/mcp.js.map +1 -1
  155. package/dist/cli/skills-content/help.d.ts +1 -1
  156. package/dist/cli/skills-content/help.d.ts.map +1 -1
  157. package/dist/cli/skills-content/help.js +4 -3
  158. package/dist/cli/skills-content/help.js.map +1 -1
  159. package/dist/cli/skills-content/index.d.ts +1 -0
  160. package/dist/cli/skills-content/index.d.ts.map +1 -1
  161. package/dist/cli/skills-content/index.js +1 -0
  162. package/dist/cli/skills-content/index.js.map +1 -1
  163. package/dist/cli/skills-content/rewind-skill.d.ts +2 -0
  164. package/dist/cli/skills-content/rewind-skill.d.ts.map +1 -0
  165. package/dist/cli/skills-content/rewind-skill.js +53 -0
  166. package/dist/cli/skills-content/rewind-skill.js.map +1 -0
  167. package/dist/cli/skills.d.ts +6 -0
  168. package/dist/cli/skills.d.ts.map +1 -1
  169. package/dist/cli/skills.js +90 -14
  170. package/dist/cli/skills.js.map +1 -1
  171. package/dist/client/resources/ResourceTree.d.ts +3 -1
  172. package/dist/client/resources/ResourceTree.d.ts.map +1 -1
  173. package/dist/client/resources/ResourceTree.js +4 -4
  174. package/dist/client/resources/ResourceTree.js.map +1 -1
  175. package/dist/client/resources/ResourcesPanel.d.ts.map +1 -1
  176. package/dist/client/resources/ResourcesPanel.js +55 -6
  177. package/dist/client/resources/ResourcesPanel.js.map +1 -1
  178. package/dist/collab/routes.d.ts +1 -1
  179. package/dist/collab/struct-routes.d.ts +1 -1
  180. package/dist/mcp/screen-memory-stdio.d.ts +365 -0
  181. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
  182. package/dist/mcp/screen-memory-stdio.js +1014 -91
  183. package/dist/mcp/screen-memory-stdio.js.map +1 -1
  184. package/dist/mcp-client/index.d.ts +1 -1
  185. package/dist/mcp-client/index.d.ts.map +1 -1
  186. package/dist/mcp-client/index.js +1 -1
  187. package/dist/mcp-client/index.js.map +1 -1
  188. package/dist/mcp-client/screen-memory-local.d.ts +90 -0
  189. package/dist/mcp-client/screen-memory-local.d.ts.map +1 -1
  190. package/dist/mcp-client/screen-memory-local.js +590 -12
  191. package/dist/mcp-client/screen-memory-local.js.map +1 -1
  192. package/dist/notifications/routes.d.ts +2 -2
  193. package/dist/observability/routes.d.ts +5 -5
  194. package/dist/progress/routes.d.ts +1 -1
  195. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  196. package/dist/resources/handlers.d.ts +1 -1
  197. package/dist/secrets/routes.d.ts +9 -9
  198. package/dist/server/transcribe-voice.d.ts +1 -1
  199. package/package.json +2 -2
  200. package/src/cli/mcp-config-writers.ts +6 -4
  201. package/src/cli/mcp.ts +124 -0
  202. package/src/cli/skills-content/help.ts +4 -3
  203. package/src/cli/skills-content/index.ts +1 -0
  204. package/src/cli/skills-content/rewind-skill.ts +52 -0
  205. package/src/cli/skills.ts +115 -13
  206. package/src/client/resources/ResourceTree.tsx +11 -5
  207. package/src/client/resources/ResourcesPanel.tsx +141 -18
  208. package/src/mcp/screen-memory-stdio.ts +1360 -104
  209. package/src/mcp-client/index.ts +9 -0
  210. package/src/mcp-client/screen-memory-local.ts +847 -13
@@ -0,0 +1,408 @@
1
+ //! Local post-segment audio transcription for Screen Memory.
2
+ //!
3
+ //! MP4 audio is decoded directly into bounded memory, transcribed through the
4
+ //! process-wide Whisper context, and returned as typed rows. This module does
5
+ //! not persist PCM and never logs transcript text; the capture worker owns the
6
+ //! adjacent JSONL/status files.
7
+ #![allow(dead_code)]
8
+
9
+ use chrono::{DateTime, Duration, Utc};
10
+ use serde::{Deserialize, Serialize};
11
+ use std::io::Read;
12
+ use std::path::Path;
13
+ use std::process::{Command, Stdio};
14
+ use tauri::AppHandle;
15
+
16
+ pub const TRANSCRIPT_SCHEMA_VERSION: u32 = 1;
17
+ pub const MAX_INPUT_BYTES: u64 = 2 * 1024 * 1024 * 1024;
18
+ pub const MAX_DURATION_MS: u64 = 30 * 60 * 1_000;
19
+ pub const MAX_TRANSCRIPT_ROWS: usize = 2_048;
20
+ pub const MAX_ROW_TEXT_BYTES: usize = 8 * 1024;
21
+ pub const MAX_TOTAL_TEXT_BYTES: usize = 1024 * 1024;
22
+ const SAMPLE_RATE: usize = 16_000;
23
+ const BYTES_PER_SAMPLE: usize = std::mem::size_of::<f32>();
24
+ const FFMPEG_CANDIDATE_PATHS: &[&str] = &[
25
+ "ffmpeg",
26
+ "/opt/homebrew/bin/ffmpeg",
27
+ "/usr/local/bin/ffmpeg",
28
+ "/opt/local/bin/ffmpeg",
29
+ ];
30
+
31
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32
+ #[serde(rename_all = "kebab-case")]
33
+ pub enum ScreenMemoryTranscriptState {
34
+ Pending,
35
+ Transcribing,
36
+ Ready,
37
+ Failed,
38
+ Skipped,
39
+ }
40
+
41
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42
+ #[serde(rename_all = "camelCase")]
43
+ pub struct ScreenMemoryTranscriptStatus {
44
+ pub state: ScreenMemoryTranscriptState,
45
+ pub row_count: usize,
46
+ pub started_at: Option<String>,
47
+ pub finished_at: Option<String>,
48
+ pub error: Option<String>,
49
+ }
50
+
51
+ impl ScreenMemoryTranscriptStatus {
52
+ pub fn pending() -> Self {
53
+ Self::new(ScreenMemoryTranscriptState::Pending)
54
+ }
55
+
56
+ pub fn transcribing(started_at: impl Into<String>) -> Self {
57
+ Self {
58
+ started_at: Some(started_at.into()),
59
+ ..Self::new(ScreenMemoryTranscriptState::Transcribing)
60
+ }
61
+ }
62
+
63
+ pub fn ready(
64
+ row_count: usize,
65
+ started_at: impl Into<String>,
66
+ finished_at: impl Into<String>,
67
+ ) -> Self {
68
+ Self {
69
+ state: ScreenMemoryTranscriptState::Ready,
70
+ row_count: row_count.min(MAX_TRANSCRIPT_ROWS),
71
+ started_at: Some(started_at.into()),
72
+ finished_at: Some(finished_at.into()),
73
+ error: None,
74
+ }
75
+ }
76
+
77
+ pub fn failed(
78
+ started_at: impl Into<String>,
79
+ finished_at: impl Into<String>,
80
+ error: impl AsRef<str>,
81
+ ) -> Self {
82
+ Self {
83
+ state: ScreenMemoryTranscriptState::Failed,
84
+ row_count: 0,
85
+ started_at: Some(started_at.into()),
86
+ finished_at: Some(finished_at.into()),
87
+ error: Some(bounded_text(error.as_ref(), MAX_ROW_TEXT_BYTES)),
88
+ }
89
+ }
90
+
91
+ pub fn skipped(finished_at: impl Into<String>) -> Self {
92
+ Self {
93
+ finished_at: Some(finished_at.into()),
94
+ ..Self::new(ScreenMemoryTranscriptState::Skipped)
95
+ }
96
+ }
97
+
98
+ fn new(state: ScreenMemoryTranscriptState) -> Self {
99
+ Self {
100
+ state,
101
+ row_count: 0,
102
+ started_at: None,
103
+ finished_at: None,
104
+ error: None,
105
+ }
106
+ }
107
+ }
108
+
109
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110
+ #[serde(rename_all = "camelCase")]
111
+ pub struct ScreenMemoryTranscriptRow {
112
+ pub schema_version: u32,
113
+ pub segment_id: String,
114
+ pub source: String,
115
+ pub captured_at: String,
116
+ pub start_ms: u64,
117
+ pub end_ms: u64,
118
+ pub text: String,
119
+ }
120
+
121
+ /// The exact background-worker entry point intended for `screen_memory.rs`.
122
+ /// `duration_ms` comes from finalized segment metadata and is checked before
123
+ /// ffmpeg is started. `source` should describe the recorded audio track (for
124
+ /// example `mixed-audio`); rows retain that source verbatim after validation.
125
+ pub fn transcribe_segment(
126
+ app: &AppHandle,
127
+ segment_path: &Path,
128
+ segment_id: &str,
129
+ source: &str,
130
+ audio_stream_index: usize,
131
+ segment_started_at: DateTime<Utc>,
132
+ duration_ms: u64,
133
+ language: Option<&str>,
134
+ ) -> Result<Vec<ScreenMemoryTranscriptRow>, String> {
135
+ validate_request(segment_path, segment_id, source, duration_ms)?;
136
+ let samples = decode_audio(segment_path, audio_stream_index, duration_ms)?;
137
+ let raw = crate::whisper_speech::transcribe_offline_file_samples(app, &samples, language)?;
138
+ Ok(clean_rows(
139
+ segment_id,
140
+ source,
141
+ segment_started_at,
142
+ duration_ms,
143
+ raw,
144
+ ))
145
+ }
146
+
147
+ fn validate_request(
148
+ segment_path: &Path,
149
+ segment_id: &str,
150
+ source: &str,
151
+ duration_ms: u64,
152
+ ) -> Result<(), String> {
153
+ if segment_id.trim().is_empty() || segment_id.len() > 255 {
154
+ return Err("invalid transcript segment id".to_owned());
155
+ }
156
+ if source.trim().is_empty() || source.len() > 128 {
157
+ return Err("invalid transcript source".to_owned());
158
+ }
159
+ if duration_ms == 0 || duration_ms > MAX_DURATION_MS {
160
+ return Err(format!(
161
+ "transcript duration must be between 1 and {MAX_DURATION_MS} ms"
162
+ ));
163
+ }
164
+ let metadata = std::fs::metadata(segment_path)
165
+ .map_err(|e| format!("transcript input unavailable: {e}"))?;
166
+ if !metadata.is_file() || metadata.len() == 0 || metadata.len() > MAX_INPUT_BYTES {
167
+ return Err("transcript input size is outside the allowed bounds".to_owned());
168
+ }
169
+ Ok(())
170
+ }
171
+
172
+ fn decode_audio(
173
+ segment_path: &Path,
174
+ audio_stream_index: usize,
175
+ duration_ms: u64,
176
+ ) -> Result<Vec<f32>, String> {
177
+ let ffmpeg = resolve_ffmpeg_path()
178
+ .ok_or_else(|| "ffmpeg is required for local segment transcription".to_owned())?;
179
+ let max_samples = ((duration_ms as usize)
180
+ .saturating_mul(SAMPLE_RATE)
181
+ .saturating_add(999))
182
+ / 1_000;
183
+ let max_bytes = max_samples.saturating_mul(BYTES_PER_SAMPLE);
184
+ let duration_seconds = format!("{:.3}", duration_ms as f64 / 1_000.0);
185
+ let audio_map = format!("0:a:{audio_stream_index}?");
186
+ let mut child = Command::new(ffmpeg)
187
+ .args(["-v", "error", "-nostdin", "-i"])
188
+ .arg(segment_path)
189
+ .args(["-map", &audio_map, "-vn", "-t", &duration_seconds])
190
+ .args(["-ac", "1", "-ar", "16000", "-f", "f32le", "pipe:1"])
191
+ .stdout(Stdio::piped())
192
+ .stderr(Stdio::null())
193
+ .spawn()
194
+ .map_err(|e| format!("ffmpeg audio decode spawn failed: {e}"))?;
195
+ let mut bytes = Vec::with_capacity(max_bytes.min(8 * 1024 * 1024));
196
+ child
197
+ .stdout
198
+ .take()
199
+ .ok_or_else(|| "ffmpeg audio decode stdout unavailable".to_owned())?
200
+ .take((max_bytes as u64).saturating_add(1))
201
+ .read_to_end(&mut bytes)
202
+ .map_err(|e| format!("ffmpeg audio decode read failed: {e}"))?;
203
+ if bytes.len() > max_bytes {
204
+ let _ = child.kill();
205
+ let _ = child.wait();
206
+ return Err("decoded audio exceeded the declared segment duration".to_owned());
207
+ }
208
+ let status = child
209
+ .wait()
210
+ .map_err(|e| format!("ffmpeg audio decode wait failed: {e}"))?;
211
+ if !status.success() {
212
+ return Err("ffmpeg could not decode segment audio".to_owned());
213
+ }
214
+ if bytes.is_empty() {
215
+ return Err("segment has no decodable audio track".to_owned());
216
+ }
217
+ if bytes.len() % BYTES_PER_SAMPLE != 0 {
218
+ return Err("ffmpeg returned malformed PCM audio".to_owned());
219
+ }
220
+ let samples = bytes
221
+ .chunks_exact(BYTES_PER_SAMPLE)
222
+ .map(|chunk| f32::from_le_bytes(chunk.try_into().expect("four-byte PCM chunk")))
223
+ .collect::<Vec<_>>();
224
+ if samples.iter().any(|sample| !sample.is_finite()) {
225
+ return Err("ffmpeg returned invalid PCM audio".to_owned());
226
+ }
227
+ Ok(samples)
228
+ }
229
+
230
+ fn clean_rows(
231
+ segment_id: &str,
232
+ source: &str,
233
+ segment_started_at: DateTime<Utc>,
234
+ duration_ms: u64,
235
+ raw: impl IntoIterator<Item = crate::whisper_speech::OfflineTranscriptSegment>,
236
+ ) -> Vec<ScreenMemoryTranscriptRow> {
237
+ let mut total_text_bytes = 0usize;
238
+ raw.into_iter()
239
+ .filter_map(|segment| {
240
+ let text = clean_text(&segment.text)?;
241
+ let remaining = MAX_TOTAL_TEXT_BYTES.saturating_sub(total_text_bytes);
242
+ if remaining == 0 {
243
+ return None;
244
+ }
245
+ let text = bounded_text(&text, MAX_ROW_TEXT_BYTES.min(remaining));
246
+ if text.is_empty() {
247
+ return None;
248
+ }
249
+ total_text_bytes = total_text_bytes.saturating_add(text.len());
250
+ let start_ms = segment.start_ms.max(0) as u64;
251
+ let end_ms = segment.end_ms.max(segment.start_ms).max(0) as u64;
252
+ Some(ScreenMemoryTranscriptRow {
253
+ schema_version: TRANSCRIPT_SCHEMA_VERSION,
254
+ segment_id: segment_id.to_owned(),
255
+ source: source.to_owned(),
256
+ captured_at: (segment_started_at
257
+ + Duration::milliseconds(start_ms.min(duration_ms) as i64))
258
+ .to_rfc3339(),
259
+ start_ms: start_ms.min(duration_ms),
260
+ end_ms: end_ms.min(duration_ms),
261
+ text,
262
+ })
263
+ })
264
+ .filter(|row| row.end_ms > row.start_ms)
265
+ .take(MAX_TRANSCRIPT_ROWS)
266
+ .collect()
267
+ }
268
+
269
+ fn clean_text(text: &str) -> Option<String> {
270
+ let trimmed = text.trim();
271
+ if trimmed.is_empty() || !trimmed.chars().any(char::is_alphanumeric) {
272
+ return None;
273
+ }
274
+ let normalized = trimmed
275
+ .trim_matches(|character: char| !character.is_alphanumeric() && character != '_')
276
+ .to_ascii_lowercase();
277
+ if matches!(
278
+ normalized.as_str(),
279
+ "blank_audio" | "silence" | "music" | "no speech"
280
+ ) {
281
+ return None;
282
+ }
283
+ if (trimmed.starts_with('[') && trimmed.ends_with(']'))
284
+ || (trimmed.starts_with('(') && trimmed.ends_with(')'))
285
+ {
286
+ return None;
287
+ }
288
+ Some(trimmed.to_owned())
289
+ }
290
+
291
+ fn bounded_text(text: &str, max_bytes: usize) -> String {
292
+ if text.len() <= max_bytes {
293
+ return text.to_owned();
294
+ }
295
+ let mut end = max_bytes;
296
+ while end > 0 && !text.is_char_boundary(end) {
297
+ end -= 1;
298
+ }
299
+ text[..end].to_owned()
300
+ }
301
+
302
+ fn resolve_ffmpeg_path() -> Option<String> {
303
+ if let Ok(path) = std::env::var("CLIPS_FFMPEG_PATH") {
304
+ let trimmed = path.trim();
305
+ if !trimmed.is_empty() && command_available(trimmed) {
306
+ return Some(trimmed.to_owned());
307
+ }
308
+ }
309
+ FFMPEG_CANDIDATE_PATHS
310
+ .iter()
311
+ .copied()
312
+ .find(|candidate| command_available(candidate))
313
+ .map(str::to_owned)
314
+ }
315
+
316
+ fn command_available(command: &str) -> bool {
317
+ Command::new(command)
318
+ .arg("-version")
319
+ .stdout(Stdio::null())
320
+ .stderr(Stdio::null())
321
+ .status()
322
+ .map(|status| status.success())
323
+ .unwrap_or(false)
324
+ }
325
+
326
+ #[cfg(test)]
327
+ mod tests {
328
+ use super::*;
329
+ use crate::whisper_speech::OfflineTranscriptSegment;
330
+
331
+ fn segment(start_ms: i64, end_ms: i64, text: impl Into<String>) -> OfflineTranscriptSegment {
332
+ OfflineTranscriptSegment {
333
+ start_ms,
334
+ end_ms,
335
+ text: text.into(),
336
+ }
337
+ }
338
+
339
+ #[test]
340
+ fn rejects_unbounded_requests_before_decode() {
341
+ let path = Path::new("does-not-matter.mp4");
342
+ assert!(validate_request(path, "segment", "mixed-audio", 0).is_err());
343
+ assert!(validate_request(path, "segment", "mixed-audio", MAX_DURATION_MS + 1).is_err());
344
+ assert!(validate_request(path, "", "mixed-audio", 1).is_err());
345
+ assert!(validate_request(path, "segment", "", 1).is_err());
346
+ }
347
+
348
+ #[test]
349
+ fn cleans_rows_and_rejects_silence_placeholders() {
350
+ let rows = clean_rows(
351
+ "segment-1",
352
+ "mixed-audio",
353
+ "2026-07-14T12:00:00Z".parse().unwrap(),
354
+ 2_000,
355
+ [
356
+ segment(0, 250, " [BLANK_AUDIO]. "),
357
+ segment(250, 500, "(silence)"),
358
+ segment(-50, 750, " hello there "),
359
+ segment(1_900, 2_500, "last words"),
360
+ ],
361
+ );
362
+ assert_eq!(rows.len(), 2);
363
+ assert_eq!(rows[0].text, "hello there");
364
+ assert_eq!(rows[0].captured_at, "2026-07-14T12:00:00+00:00");
365
+ assert_eq!((rows[0].start_ms, rows[0].end_ms), (0, 750));
366
+ assert_eq!(rows[1].source, "mixed-audio");
367
+ assert_eq!((rows[1].start_ms, rows[1].end_ms), (1_900, 2_000));
368
+ assert_eq!(rows[1].captured_at, "2026-07-14T12:00:01.900+00:00");
369
+ }
370
+
371
+ #[test]
372
+ fn bounds_unicode_row_and_total_output() {
373
+ let text = "é".repeat(MAX_ROW_TEXT_BYTES);
374
+ let rows = clean_rows(
375
+ "segment-1",
376
+ "mixed-audio",
377
+ "2026-07-14T12:00:00Z".parse().unwrap(),
378
+ MAX_DURATION_MS,
379
+ (0..MAX_TRANSCRIPT_ROWS + 10)
380
+ .map(|index| segment(index as i64, index as i64 + 1, text.clone())),
381
+ );
382
+ assert!(rows.len() <= MAX_TRANSCRIPT_ROWS);
383
+ assert!(rows.iter().all(|row| row.text.len() <= MAX_ROW_TEXT_BYTES));
384
+ assert!(rows.iter().map(|row| row.text.len()).sum::<usize>() <= MAX_TOTAL_TEXT_BYTES);
385
+ assert!(rows
386
+ .iter()
387
+ .all(|row| row.text.is_char_boundary(row.text.len())));
388
+ }
389
+
390
+ #[test]
391
+ fn status_helpers_have_stable_lifecycle_fields() {
392
+ assert_eq!(
393
+ ScreenMemoryTranscriptStatus::pending().state,
394
+ ScreenMemoryTranscriptState::Pending
395
+ );
396
+ let active = ScreenMemoryTranscriptStatus::transcribing("start");
397
+ assert_eq!(active.started_at.as_deref(), Some("start"));
398
+ let ready = ScreenMemoryTranscriptStatus::ready(MAX_TRANSCRIPT_ROWS + 1, "start", "end");
399
+ assert_eq!(ready.row_count, MAX_TRANSCRIPT_ROWS);
400
+ assert_eq!(ready.finished_at.as_deref(), Some("end"));
401
+ let failed = ScreenMemoryTranscriptStatus::failed("start", "end", "x".repeat(20_000));
402
+ assert_eq!(failed.error.unwrap().len(), MAX_ROW_TEXT_BYTES);
403
+ assert_eq!(
404
+ ScreenMemoryTranscriptStatus::skipped("end").state,
405
+ ScreenMemoryTranscriptState::Skipped
406
+ );
407
+ }
408
+ }
@@ -1,12 +1,12 @@
1
1
  use std::str::FromStr;
2
- use std::sync::atomic::{AtomicBool, Ordering};
2
+ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
3
3
  use std::sync::{Mutex, OnceLock};
4
4
  use std::thread;
5
5
  use std::time::Duration;
6
6
  use tauri::{AppHandle, Emitter, Listener, Manager, PhysicalPosition, PhysicalSize};
7
7
  use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut};
8
8
 
9
- use crate::clips::{remember_voice_target, toggle_popover};
9
+ use crate::clips::{force_show_popover, remember_voice_target, toggle_popover};
10
10
  use crate::dlog;
11
11
  use crate::state::{DictationActive, VoiceWakePopover};
12
12
  use crate::util::{
@@ -33,6 +33,8 @@ static FN_TAP_ENABLED: AtomicBool = AtomicBool::new(false);
33
33
  static FN_TAP_INSTALL_STARTED: AtomicBool = AtomicBool::new(false);
34
34
  static POPOVER_DISMISS_SHORTCUT_ACTIVE: AtomicBool = AtomicBool::new(false);
35
35
  static COUNTDOWN_SHORTCUTS_ACTIVE: AtomicBool = AtomicBool::new(false);
36
+ static COUNTDOWN_SHORTCUTS_GENERATION: AtomicU64 = AtomicU64::new(0);
37
+ static COUNTDOWN_SHORTCUTS_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
36
38
  // P1: tracks whether Escape is currently registered *for dictation-cancel*
37
39
  // specifically (independent of POPOVER_DISMISS_SHORTCUT_ACTIVE, which tracks
38
40
  // the popover's own reason to want Escape registered). Escape should stay
@@ -324,54 +326,130 @@ fn sync_dictation_escape_shortcut(app: AppHandle, active: bool) {
324
326
  });
325
327
  }
326
328
 
327
- pub fn install_countdown_shortcut_handler(app: &tauri::App) {
328
- let handle = app.handle().clone();
329
- app.listen("clips:countdown-shortcuts-active", move |event| {
330
- let payload = event.payload().to_string();
331
- let handle = handle.clone();
332
- let active: bool = serde_json::from_str(&payload).unwrap_or(false);
333
- set_countdown_shortcuts_active(handle, active);
334
- });
335
- }
336
-
337
- fn set_countdown_shortcuts_active(app: AppHandle, active: bool) {
338
- COUNTDOWN_SHORTCUTS_ACTIVE.store(active, Ordering::SeqCst);
339
- thread::spawn(move || {
329
+ /// Register the countdown shortcuts before its window becomes visible.
330
+ ///
331
+ /// The event-driven synchronizer above intentionally hops off Carbon's hotkey
332
+ /// callback stack, but that also means it cannot be used for the initial
333
+ /// activation: a fast Return at the visible `3` can otherwise beat the worker
334
+ /// thread and leave the recorder waiting for the full timer. The countdown
335
+ /// command awaits this worker before showing the window, preserving the
336
+ /// non-reentrant teardown path while making the first visible frame actionable.
337
+ pub(crate) async fn prepare_countdown_shortcuts(app: AppHandle) -> Result<u64, String> {
338
+ tauri::async_runtime::spawn_blocking(move || {
339
+ let _guard = COUNTDOWN_SHORTCUTS_LOCK
340
+ .get_or_init(|| Mutex::new(()))
341
+ .lock()
342
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
343
+ let generation = COUNTDOWN_SHORTCUTS_GENERATION.fetch_add(1, Ordering::SeqCst) + 1;
344
+ COUNTDOWN_SHORTCUTS_ACTIVE.store(true, Ordering::SeqCst);
340
345
  let gs = app.global_shortcut();
341
- let escape = escape_shortcut();
342
- let enter = enter_shortcut();
343
- let numpad_enter = numpad_enter_shortcut();
344
- if active {
345
- for shortcut in [escape, enter, numpad_enter] {
346
- if !gs.is_registered(shortcut) {
347
- if let Err(err) = gs.register(shortcut) {
348
- eprintln!("[clips-tray] failed to register countdown shortcut: {err}");
346
+ let mut newly_registered = Vec::new();
347
+ for shortcut in [escape_shortcut(), enter_shortcut(), numpad_enter_shortcut()] {
348
+ if !gs.is_registered(shortcut) {
349
+ if let Err(error) = gs.register(shortcut) {
350
+ for registered in newly_registered {
351
+ let _ = gs.unregister(registered);
349
352
  }
353
+ COUNTDOWN_SHORTCUTS_ACTIVE.store(false, Ordering::SeqCst);
354
+ return Err(format!("failed to register countdown shortcut: {error}"));
350
355
  }
356
+ newly_registered.push(shortcut);
351
357
  }
352
- return;
353
358
  }
359
+ Ok::<u64, String>(generation)
360
+ })
361
+ .await
362
+ .map_err(|error| format!("countdown shortcut worker stopped unexpectedly: {error}"))?
363
+ }
354
364
 
355
- for shortcut in [enter, numpad_enter] {
365
+ /// Tear down only the countdown generation that the caller prepared. A late
366
+ /// cleanup from an older overlay must not unregister Return beneath the next
367
+ /// visible countdown.
368
+ pub(crate) async fn finish_countdown_shortcuts(
369
+ app: AppHandle,
370
+ generation: u64,
371
+ ) -> Result<(), String> {
372
+ tauri::async_runtime::spawn_blocking(move || {
373
+ let _guard = COUNTDOWN_SHORTCUTS_LOCK
374
+ .get_or_init(|| Mutex::new(()))
375
+ .lock()
376
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
377
+ if COUNTDOWN_SHORTCUTS_GENERATION.load(Ordering::SeqCst) != generation {
378
+ return;
379
+ }
380
+ COUNTDOWN_SHORTCUTS_ACTIVE.store(false, Ordering::SeqCst);
381
+ let gs = app.global_shortcut();
382
+ for shortcut in [enter_shortcut(), numpad_enter_shortcut()] {
356
383
  if gs.is_registered(shortcut) {
357
384
  let _ = gs.unregister(shortcut);
358
385
  }
359
386
  }
387
+ let escape = escape_shortcut();
360
388
  if !POPOVER_DISMISS_SHORTCUT_ACTIVE.load(Ordering::SeqCst)
361
389
  && !DICTATION_ESCAPE_SHORTCUT_ACTIVE.load(Ordering::SeqCst)
362
390
  && gs.is_registered(escape)
363
391
  {
364
392
  let _ = gs.unregister(escape);
365
393
  }
394
+ })
395
+ .await
396
+ .map_err(|error| format!("countdown shortcut cleanup worker stopped unexpectedly: {error}"))
397
+ }
398
+
399
+ /// Observe key-down events only while Clips itself is the active application.
400
+ /// AppKit local monitors need no Input Monitoring permission and never receive
401
+ /// keystrokes destined for another app. The active-generation gate keeps this
402
+ /// listener inert outside the three-second countdown.
403
+ #[cfg(target_os = "macos")]
404
+ pub fn install_countdown_local_key_monitor(app: &tauri::App) {
405
+ use std::ptr::NonNull;
406
+
407
+ use block2::RcBlock;
408
+ use objc2_app_kit::{NSEvent, NSEventMask};
409
+
410
+ let app = app.handle().clone();
411
+ let handler = RcBlock::new(move |event: NonNull<NSEvent>| -> *mut NSEvent {
412
+ if COUNTDOWN_SHORTCUTS_ACTIVE.load(Ordering::SeqCst) {
413
+ let key_code = unsafe { event.as_ref().keyCode() };
414
+ let countdown_event = match key_code {
415
+ 36 | 76 => Some("clips:countdown-done"),
416
+ 53 => Some("clips:countdown-cancel"),
417
+ _ => None,
418
+ };
419
+ if let Some(countdown_event) = countdown_event {
420
+ let app = app.clone();
421
+ thread::spawn(move || finish_countdown_from_shortcut(&app, countdown_event));
422
+ }
423
+ }
424
+ event.as_ptr()
366
425
  });
426
+ let monitor = unsafe {
427
+ NSEvent::addLocalMonitorForEventsMatchingMask_handler(NSEventMask::KeyDown, &handler)
428
+ };
429
+ if monitor.is_none() {
430
+ eprintln!("[clips-tray] failed to install app-local countdown key monitor");
431
+ }
367
432
  }
368
433
 
434
+ #[cfg(not(target_os = "macos"))]
435
+ pub fn install_countdown_local_key_monitor(_app: &tauri::App) {}
436
+
369
437
  fn finish_countdown_from_shortcut(app: &AppHandle, event: &'static str) {
370
- let _ = app.emit(event, ());
438
+ dlog!("[clips-tray] countdown shortcut accepted: {event}");
439
+ let cause = if event == "clips:countdown-cancel" {
440
+ "escape"
441
+ } else {
442
+ "return"
443
+ };
444
+ let _ = app.emit(event, serde_json::json!({ "cause": cause }));
371
445
  if let Some(window) = app.get_webview_window("countdown") {
372
446
  let _ = window.close();
373
447
  }
374
- set_countdown_shortcuts_active(app.clone(), false);
448
+ let app = app.clone();
449
+ let generation = COUNTDOWN_SHORTCUTS_GENERATION.load(Ordering::SeqCst);
450
+ tauri::async_runtime::spawn(async move {
451
+ let _ = finish_countdown_shortcuts(app, generation).await;
452
+ });
375
453
  }
376
454
 
377
455
  /// Build the global shortcut plugin with its handler. Called from `run()` to
@@ -411,7 +489,11 @@ pub fn build_shortcut_plugin() -> tauri_plugin_global_shortcut::Builder<tauri::W
411
489
  finish_countdown_from_shortcut(app, event);
412
490
  return;
413
491
  }
414
- set_countdown_shortcuts_active(app.clone(), false);
492
+ let app = app.clone();
493
+ let generation = COUNTDOWN_SHORTCUTS_GENERATION.load(Ordering::SeqCst);
494
+ tauri::async_runtime::spawn(async move {
495
+ let _ = finish_countdown_shortcuts(app, generation).await;
496
+ });
415
497
  }
416
498
  if is_escape {
417
499
  if event.state() != tauri_plugin_global_shortcut::ShortcutState::Pressed {
@@ -509,12 +591,11 @@ pub fn build_shortcut_plugin() -> tauri_plugin_global_shortcut::Builder<tauri::W
509
591
  return;
510
592
  }
511
593
  if is_cmd || is_ctrl || is_custom_popover {
512
- // Loom-style: if a recording is already active, the
513
- // global shortcut stops it rather than re-opening the
514
- // popover. Keeps parity with the tray-icon click
515
- // behaviour in `on_tray_icon_event`.
594
+ // The dedicated record shortcut remains the fast start/stop
595
+ // gesture. The separate Open Clips shortcut follows the app-icon
596
+ // behavior: it opens the popover and never stops capture.
516
597
  if is_recording_active(app) && !is_meeting_active(app) {
517
- let _ = app.emit("clips:recorder-stop", ());
598
+ force_show_popover(app);
518
599
  } else {
519
600
  toggle_popover(app);
520
601
  }
@@ -23,8 +23,8 @@ pub struct TrayMeetings(pub Mutex<Vec<MeetingItem>>);
23
23
  pub struct PopoverShownAt(pub Mutex<Option<Instant>>);
24
24
 
25
25
  /// Whether a recording is currently in progress. Set from JS via
26
- /// `set_recording_state`. Used to re-purpose the tray icon click as a
27
- /// stop-recording shortcut while recording, matching Loom.
26
+ /// `set_recording_state`. Keeps the parked popover reachable while recording
27
+ /// and enables the explicit Stop item in the tray menu.
28
28
  #[derive(Default)]
29
29
  pub struct RecordingActive(pub Mutex<bool>);
30
30
 
@@ -119,7 +119,8 @@ pub async fn audio_transcription_start(
119
119
  owner: Option<String>,
120
120
  ) -> Result<(), String> {
121
121
  let _ = meeting_id;
122
- crate::whisper_speech::whisper_transcription_start(
122
+ crate::logfile::diagnostic("[meeting-audio] transcription engine start requested");
123
+ let result = crate::whisper_speech::whisper_transcription_start(
123
124
  app,
124
125
  locale,
125
126
  mic_device_id,
@@ -134,7 +135,16 @@ pub async fn audio_transcription_start(
134
135
  emit_partials.unwrap_or(true),
135
136
  owner,
136
137
  )
137
- .await
138
+ .await;
139
+ if let Err(error) = &result {
140
+ crate::logfile::diagnostic(&format!(
141
+ "[meeting-audio] transcription engine start failed: {error}"
142
+ ));
143
+ eprintln!("[clips-tray] meeting transcription engine start failed: {error}");
144
+ } else {
145
+ crate::logfile::diagnostic("[meeting-audio] transcription engine started");
146
+ }
147
+ result
138
148
  }
139
149
 
140
150
  #[tauri::command]
@@ -221,7 +231,7 @@ pub(crate) mod macos {
221
231
  /// open System Settings manually).
222
232
  pub fn open_screen_recording_settings() -> Result<(), String> {
223
233
  use std::process::Command;
224
- let url = "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture";
234
+ let url = "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture";
225
235
  Command::new("open")
226
236
  .arg(url)
227
237
  .status()
@@ -227,7 +227,10 @@ pub fn build_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
227
227
  meeting_active
228
228
  );
229
229
  if active && !meeting_active {
230
- let _ = app.emit("clips:recorder-stop", ());
230
+ // Opening Clips must never double as an implicit Stop.
231
+ // Keep the recording alive and restore the parked popover;
232
+ // its active-recording view exposes an explicit Stop button.
233
+ force_show_popover(app);
231
234
  } else {
232
235
  toggle_popover(app);
233
236
  }