@agent-native/core 0.114.6 → 0.114.8
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/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +15 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/mcp-config-writers.ts +6 -4
- package/corpus/core/src/cli/mcp.ts +124 -0
- package/corpus/core/src/cli/skills-content/help.ts +4 -3
- package/corpus/core/src/cli/skills-content/index.ts +1 -0
- package/corpus/core/src/cli/skills-content/rewind-skill.ts +52 -0
- package/corpus/core/src/cli/skills.ts +115 -13
- package/corpus/core/src/mcp/screen-memory-stdio.ts +1360 -104
- package/corpus/core/src/mcp-client/index.ts +9 -0
- package/corpus/core/src/mcp-client/screen-memory-local.ts +847 -13
- package/corpus/core/src/server/agent-chat-plugin.ts +16 -9
- package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +19 -7
- package/corpus/templates/analytics/app/hooks/use-chart-tooltip-portal.ts +60 -0
- package/corpus/templates/analytics/app/routes/chart.tsx +1 -1
- package/corpus/templates/analytics/changelog/2026-07-20-dashboard-requests-no-longer-stall-without-progress.md +6 -0
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +6 -0
- package/corpus/templates/clips/actions/apply-rewind-extension.ts +195 -0
- package/corpus/templates/clips/actions/delete-agent-recording-if-unpromoted.ts +64 -0
- package/corpus/templates/clips/actions/get-rewind-extension-request.ts +22 -0
- package/corpus/templates/clips/actions/get-screen-memory-status.ts +18 -2
- package/corpus/templates/clips/actions/list-rewind-extension-requests.ts +52 -0
- package/corpus/templates/clips/actions/make-recording-private-for-rewind.ts +55 -0
- package/corpus/templates/clips/actions/query-screen-memory-context.ts +4 -4
- package/corpus/templates/clips/actions/request-rewind-extension.ts +81 -0
- package/corpus/templates/clips/actions/update-rewind-extension-request.ts +47 -0
- package/corpus/templates/clips/app/components/editor/editor-layout.tsx +25 -0
- package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +36 -0
- package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +338 -0
- package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +1 -16
- package/corpus/templates/clips/app/components/editor/timeline.tsx +26 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +18 -0
- package/corpus/templates/clips/app/lib/ffmpeg-export.ts +19 -12
- package/corpus/templates/clips/app/lib/rewind-visibility.ts +31 -0
- package/corpus/templates/clips/app/lib/timestamp-mapping.ts +7 -0
- package/corpus/templates/clips/app/lib/upload-file-client.ts +17 -0
- package/corpus/templates/clips/changelog/2026-07-14-rewind-keeps-recent-screen-context-local-and-turns-it-into-clips.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-active-clips-keep-rewind-stable-and-settings-clear.md +12 -0
- package/corpus/templates/clips/changelog/2026-07-17-clicking-the-clips-icon-during-a-recording-now-opens-the-app.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-default-mic-now-uses-the-mac-default-and-transcription-shares-one-capture.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-full-screen-clips-finalize-even-when-screencapturekit-stop-hangs.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-normal-full-screen-clips-no-longer-stop-immediately-after-th.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-can-hand-one-bounded-private-clip-to-your-agent.md +12 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-home-can-copy-a-local-agent-prompt.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-now-has-a-calm-home-status-focused-first-run-consent-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-settings-are-smaller-clearer-and-agent-first.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-the-full-clips-interface-stays-available-while-recording.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-18-rewind-organizes-recent-work-into-local-searchable-chapters.md +8 -0
- package/corpus/templates/clips/changelog/2026-07-18-searchable-rewind-chapters.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-19-rewind-backed-recordings-now-start-immediately-after-the-cou.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-20-rewind-can-set-up-your-local-agent-once-so-future-requests-w.md +6 -0
- package/corpus/templates/clips/desktop/package.json +1 -0
- package/corpus/templates/clips/desktop/scripts/sign-macos-local.ts +12 -1
- package/corpus/templates/clips/desktop/src/app.tsx +2498 -239
- package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +21 -1
- package/corpus/templates/clips/desktop/src/components/Switch.tsx +3 -0
- package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +156 -24
- package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +6 -7
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +1763 -1160
- package/corpus/templates/clips/desktop/src/lib/rewind-agent-prompt.ts +9 -0
- package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +20 -0
- package/corpus/templates/clips/desktop/src/main.tsx +3 -0
- package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +14 -14
- package/corpus/templates/clips/desktop/src/overlays/onboarding.tsx +4 -4
- package/corpus/templates/clips/desktop/src/overlays/preparing.tsx +13 -0
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +27 -2
- package/corpus/templates/clips/desktop/src/shared/config.ts +16 -0
- package/corpus/templates/clips/desktop/src/styles.css +956 -134
- package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +5 -0
- package/corpus/templates/clips/desktop/src-tauri/build.rs +69 -1
- package/corpus/templates/clips/desktop/src-tauri/src/accessibility.rs +386 -12
- package/corpus/templates/clips/desktop/src-tauri/src/capture_audio_bus.rs +382 -0
- package/corpus/templates/clips/desktop/src-tauri/src/capture_graph.rs +777 -0
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +249 -22
- package/corpus/templates/clips/desktop/src-tauri/src/config.rs +148 -1
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +58 -4
- package/corpus/templates/clips/desktop/src-tauri/src/logfile.rs +13 -1
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +1505 -51
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +124 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +1006 -95
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_capture_suspension.rs +165 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_chapters.rs +2434 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +1650 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_egress.rs +643 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_local_ask.rs +803 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_meeting_history.rs +595 -0
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +2900 -91
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_ocr.rs +360 -0
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_transcript.rs +408 -0
- package/corpus/templates/clips/desktop/src-tauri/src/shortcuts.rs +114 -33
- package/corpus/templates/clips/desktop/src-tauri/src/state.rs +2 -2
- package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +13 -3
- package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +4 -1
- package/corpus/templates/clips/desktop/src-tauri/src/util.rs +7 -13
- package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +198 -11
- package/corpus/templates/clips/desktop/src-tauri/tauri.alpha.conf.json +17 -0
- package/corpus/toolkit/CHANGELOG.md +6 -0
- package/corpus/toolkit/package.json +1 -1
- package/dist/cli/mcp-config-writers.d.ts +2 -2
- package/dist/cli/mcp-config-writers.d.ts.map +1 -1
- package/dist/cli/mcp-config-writers.js +6 -6
- package/dist/cli/mcp-config-writers.js.map +1 -1
- package/dist/cli/mcp.d.ts +14 -0
- package/dist/cli/mcp.d.ts.map +1 -1
- package/dist/cli/mcp.js +79 -0
- package/dist/cli/mcp.js.map +1 -1
- package/dist/cli/skills-content/help.d.ts +1 -1
- package/dist/cli/skills-content/help.d.ts.map +1 -1
- package/dist/cli/skills-content/help.js +4 -3
- package/dist/cli/skills-content/help.js.map +1 -1
- package/dist/cli/skills-content/index.d.ts +1 -0
- package/dist/cli/skills-content/index.d.ts.map +1 -1
- package/dist/cli/skills-content/index.js +1 -0
- package/dist/cli/skills-content/index.js.map +1 -1
- package/dist/cli/skills-content/rewind-skill.d.ts +2 -0
- package/dist/cli/skills-content/rewind-skill.d.ts.map +1 -0
- package/dist/cli/skills-content/rewind-skill.js +53 -0
- package/dist/cli/skills-content/rewind-skill.js.map +1 -0
- package/dist/cli/skills.d.ts +6 -0
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +90 -14
- package/dist/cli/skills.js.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/mcp/screen-memory-stdio.d.ts +365 -0
- package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
- package/dist/mcp/screen-memory-stdio.js +1014 -91
- package/dist/mcp/screen-memory-stdio.js.map +1 -1
- package/dist/mcp-client/index.d.ts +1 -1
- package/dist/mcp-client/index.d.ts.map +1 -1
- package/dist/mcp-client/index.js +1 -1
- package/dist/mcp-client/index.js.map +1 -1
- package/dist/mcp-client/screen-memory-local.d.ts +90 -0
- package/dist/mcp-client/screen-memory-local.d.ts.map +1 -1
- package/dist/mcp-client/screen-memory-local.js +590 -12
- package/dist/mcp-client/screen-memory-local.js.map +1 -1
- package/dist/observability/routes.d.ts +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/provider-api.d.ts +4 -4
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/agent-chat-plugin.d.ts +5 -0
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +10 -9
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/package.json +2 -2
- package/src/cli/mcp-config-writers.ts +6 -4
- package/src/cli/mcp.ts +124 -0
- package/src/cli/skills-content/help.ts +4 -3
- package/src/cli/skills-content/index.ts +1 -0
- package/src/cli/skills-content/rewind-skill.ts +52 -0
- package/src/cli/skills.ts +115 -13
- package/src/mcp/screen-memory-stdio.ts +1360 -104
- package/src/mcp-client/index.ts +9 -0
- package/src/mcp-client/screen-memory-local.ts +847 -13
- package/src/server/agent-chat-plugin.ts +16 -9
|
@@ -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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
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
|
-
|
|
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`.
|
|
27
|
-
///
|
|
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::
|
|
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.
|
|
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
|
-
|
|
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
|
}
|