@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
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
use crate::
|
|
1
|
+
use crate::capture_graph::{
|
|
2
|
+
CaptureConsumer, CaptureGraphError, CaptureGraphState, CaptureSource, CoverageGapReason,
|
|
3
|
+
};
|
|
4
|
+
use crate::config::{FeatureConfig, RewindCaptureMode, ScreenMemoryConfig};
|
|
2
5
|
use crate::native_screen::{
|
|
3
6
|
self, NativeFullscreenBackend, DISK_SPACE_BLOCK_BYTES, MP4_RECORDING_MIME_TYPE,
|
|
4
7
|
QUICKTIME_RECORDING_MIME_TYPE,
|
|
5
8
|
};
|
|
6
9
|
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
|
7
10
|
use serde::{Deserialize, Serialize};
|
|
11
|
+
use std::collections::{BTreeSet, HashMap, VecDeque};
|
|
8
12
|
use std::fs::{File, OpenOptions};
|
|
9
13
|
use std::io::{BufRead, BufReader, Write};
|
|
10
14
|
use std::path::{Path, PathBuf};
|
|
@@ -19,23 +23,74 @@ const SCREEN_MEMORY_EVENTS_JSONL: &str = "events.jsonl";
|
|
|
19
23
|
const MIN_SEGMENT_SECONDS: u64 = 15;
|
|
20
24
|
const MAX_SEGMENT_SECONDS: u64 = 30 * 60;
|
|
21
25
|
const MIN_RETENTION_HOURS: u32 = 1;
|
|
22
|
-
const MAX_RETENTION_HOURS: u32 = 24
|
|
26
|
+
const MAX_RETENTION_HOURS: u32 = 24;
|
|
23
27
|
const MIN_MAX_BYTES: u64 = 100 * 1024 * 1024;
|
|
24
28
|
const MAX_MAX_BYTES: u64 = 1024 * 1024 * 1024 * 1024;
|
|
25
29
|
const ROTATOR_TICK: Duration = Duration::from_millis(500);
|
|
30
|
+
/// Privacy detection must not inherit the user-facing context sampling cadence.
|
|
31
|
+
/// This bounds how long recognized excluded pixels/audio may reach the current
|
|
32
|
+
/// logical segment before that entire segment is discarded.
|
|
33
|
+
const EXCLUSION_POLL_INTERVAL: Duration = Duration::from_millis(250);
|
|
34
|
+
const SCREEN_MEMORY_MCP_NAME: &str = "clips-screen-memory";
|
|
26
35
|
|
|
27
36
|
static SEGMENT_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
28
37
|
|
|
29
38
|
#[derive(Default)]
|
|
30
39
|
pub struct ScreenMemoryState {
|
|
31
40
|
inner: Mutex<ScreenMemoryRuntime>,
|
|
41
|
+
/// OCR and Whisper indexing share this gate so completed segments never
|
|
42
|
+
/// fan out into concurrent CPU/memory-heavy post-processing jobs.
|
|
43
|
+
indexing: Mutex<()>,
|
|
44
|
+
/// Serializes physical producer stop/start transitions. Callers that hold
|
|
45
|
+
/// this lock use `rotate_segment_inner`; ordinary callers use
|
|
46
|
+
/// `rotate_segment`, which acquires it for them.
|
|
47
|
+
transition: Mutex<()>,
|
|
32
48
|
}
|
|
33
49
|
|
|
34
50
|
#[derive(Default)]
|
|
35
51
|
struct ScreenMemoryRuntime {
|
|
36
52
|
active: Option<ActiveScreenMemorySegment>,
|
|
37
53
|
worker_stop: Option<Arc<AtomicBool>>,
|
|
54
|
+
rewind_lease_id: Option<String>,
|
|
55
|
+
exclusion_active: bool,
|
|
56
|
+
exclusion_gap: Option<ActiveExclusionGap>,
|
|
57
|
+
temporary_audio_consumers: HashMap<String, TemporaryAudioConsumer>,
|
|
38
58
|
last_error: Option<String>,
|
|
59
|
+
ocr_queue: VecDeque<ScreenMemoryOcrJob>,
|
|
60
|
+
ocr_worker_running: bool,
|
|
61
|
+
ocr_active_segment: Option<String>,
|
|
62
|
+
ocr_cancelled_segments: BTreeSet<String>,
|
|
63
|
+
transcript_queue: VecDeque<ScreenMemoryTranscriptJob>,
|
|
64
|
+
transcript_worker_running: bool,
|
|
65
|
+
transcript_active_segment: Option<String>,
|
|
66
|
+
transcript_cancelled_segments: BTreeSet<String>,
|
|
67
|
+
/// Ephemeral artifact pins. These intentionally do not survive a crash:
|
|
68
|
+
/// callers must have copied any artifact they need before relying on it.
|
|
69
|
+
segment_pins: HashMap<String, BTreeSet<String>>,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
struct ActiveExclusionGap {
|
|
73
|
+
started_at: Instant,
|
|
74
|
+
sources: Vec<CaptureSource>,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fn active_exclusion_gap(
|
|
78
|
+
started_at: Instant,
|
|
79
|
+
capture_mode: RewindCaptureMode,
|
|
80
|
+
) -> ActiveExclusionGap {
|
|
81
|
+
ActiveExclusionGap {
|
|
82
|
+
started_at,
|
|
83
|
+
sources: requested_sources(capture_mode),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
struct TemporaryAudioConsumer {
|
|
88
|
+
graph_lease_id: String,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
92
|
+
pub(crate) struct TemporaryAudioLease {
|
|
93
|
+
owner_id: String,
|
|
39
94
|
}
|
|
40
95
|
|
|
41
96
|
struct ActiveScreenMemorySegment {
|
|
@@ -47,6 +102,21 @@ struct ActiveScreenMemorySegment {
|
|
|
47
102
|
started_at_iso: String,
|
|
48
103
|
width: Option<u32>,
|
|
49
104
|
height: Option<u32>,
|
|
105
|
+
capture_mode: RewindCaptureMode,
|
|
106
|
+
exclusion_tainted: Arc<AtomicBool>,
|
|
107
|
+
graph_epoch_id: String,
|
|
108
|
+
graph_started_elapsed_ms: u64,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#[derive(Clone)]
|
|
112
|
+
struct ScreenMemoryOcrJob {
|
|
113
|
+
segment: ScreenMemorySegmentMetadata,
|
|
114
|
+
sample_interval_seconds: u64,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#[derive(Clone)]
|
|
118
|
+
struct ScreenMemoryTranscriptJob {
|
|
119
|
+
segment: ScreenMemorySegmentMetadata,
|
|
50
120
|
}
|
|
51
121
|
|
|
52
122
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
@@ -62,8 +132,55 @@ pub struct ScreenMemorySegmentMetadata {
|
|
|
62
132
|
pub width: Option<u32>,
|
|
63
133
|
pub height: Option<u32>,
|
|
64
134
|
pub bytes: u64,
|
|
135
|
+
#[serde(default)]
|
|
136
|
+
pub system_audio_path: Option<PathBuf>,
|
|
137
|
+
#[serde(default)]
|
|
138
|
+
pub microphone_path: Option<PathBuf>,
|
|
65
139
|
pub corrupt: bool,
|
|
66
140
|
pub error: Option<String>,
|
|
141
|
+
#[serde(default)]
|
|
142
|
+
pub capture_mode: RewindCaptureMode,
|
|
143
|
+
#[serde(default)]
|
|
144
|
+
pub exclusion_tainted: bool,
|
|
145
|
+
/// Monotonic offsets from the shared capture graph; wall-clock fields stay
|
|
146
|
+
/// for human-facing local files and backwards compatibility.
|
|
147
|
+
#[serde(default)]
|
|
148
|
+
pub graph_epoch_id: Option<String>,
|
|
149
|
+
#[serde(default)]
|
|
150
|
+
pub graph_started_elapsed_ms: u64,
|
|
151
|
+
#[serde(default)]
|
|
152
|
+
pub graph_ended_elapsed_ms: u64,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
fn audio_sidecar_path(video_path: &Path, source: &str) -> PathBuf {
|
|
156
|
+
let stem = video_path
|
|
157
|
+
.file_stem()
|
|
158
|
+
.and_then(|value| value.to_str())
|
|
159
|
+
.unwrap_or("segment");
|
|
160
|
+
video_path.with_file_name(format!("{stem}.{source}.wav"))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
fn existing_audio_sidecars(
|
|
164
|
+
video_path: &Path,
|
|
165
|
+
mode: RewindCaptureMode,
|
|
166
|
+
) -> (Option<PathBuf>, Option<PathBuf>) {
|
|
167
|
+
if mode != RewindCaptureMode::VisualsAudio {
|
|
168
|
+
return (None, None);
|
|
169
|
+
}
|
|
170
|
+
let system = audio_sidecar_path(video_path, "system");
|
|
171
|
+
let microphone = audio_sidecar_path(video_path, "microphone");
|
|
172
|
+
(
|
|
173
|
+
system.exists().then_some(system),
|
|
174
|
+
microphone.exists().then_some(microphone),
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
fn media_unit_bytes(video_path: &Path, system: Option<&Path>, microphone: Option<&Path>) -> u64 {
|
|
179
|
+
[Some(video_path), system, microphone]
|
|
180
|
+
.into_iter()
|
|
181
|
+
.flatten()
|
|
182
|
+
.filter_map(|path| std::fs::metadata(path).ok().map(|metadata| metadata.len()))
|
|
183
|
+
.fold(0u64, u64::saturating_add)
|
|
67
184
|
}
|
|
68
185
|
|
|
69
186
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
@@ -74,6 +191,14 @@ pub struct ScreenMemoryEvent {
|
|
|
74
191
|
pub window_title: Option<String>,
|
|
75
192
|
pub bundle_id: Option<String>,
|
|
76
193
|
pub source: String,
|
|
194
|
+
/// Present only for explicit coverage-gap markers. Kept optional so old
|
|
195
|
+
/// event logs remain readable and ordinary context rows stay compact.
|
|
196
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
197
|
+
pub coverage_gap_reason: Option<String>,
|
|
198
|
+
/// A small local semantic summary, never a raw accessibility tree. Older
|
|
199
|
+
/// events intentionally deserialize without this field.
|
|
200
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
201
|
+
pub accessibility: Option<crate::accessibility::AccessibilityFingerprint>,
|
|
77
202
|
}
|
|
78
203
|
|
|
79
204
|
#[derive(Debug, Clone, Serialize)]
|
|
@@ -116,6 +241,8 @@ pub struct ScreenMemoryStatus {
|
|
|
116
241
|
pub active_segment: Option<ScreenMemoryActiveSegment>,
|
|
117
242
|
pub recent_segments: Vec<ScreenMemorySegmentMetadata>,
|
|
118
243
|
pub last_error: Option<String>,
|
|
244
|
+
pub exclusion_active: bool,
|
|
245
|
+
pub coverage: String,
|
|
119
246
|
}
|
|
120
247
|
|
|
121
248
|
#[derive(Debug, Clone, Serialize)]
|
|
@@ -153,6 +280,29 @@ pub fn sync_from_config(app: &AppHandle, feature_config: &FeatureConfig) {
|
|
|
153
280
|
return;
|
|
154
281
|
}
|
|
155
282
|
|
|
283
|
+
let effective_mode = effective_capture_mode(app, config.capture_mode);
|
|
284
|
+
let mode_changed_while_active = app
|
|
285
|
+
.try_state::<ScreenMemoryState>()
|
|
286
|
+
.and_then(|state| {
|
|
287
|
+
state
|
|
288
|
+
.inner
|
|
289
|
+
.lock()
|
|
290
|
+
.ok()
|
|
291
|
+
.and_then(|runtime| runtime.active.as_ref().map(|active| active.capture_mode))
|
|
292
|
+
})
|
|
293
|
+
.is_some_and(|active_mode| active_mode != effective_mode);
|
|
294
|
+
if mode_changed_while_active {
|
|
295
|
+
// Reconfigure the one physical producer at a segment boundary rather
|
|
296
|
+
// than letting its audio contract drift under an existing lease.
|
|
297
|
+
// This is deliberately unlike an ordinary rotation: the requested
|
|
298
|
+
// source set changed, so retaining the old lease would leave the graph
|
|
299
|
+
// claiming the previous mode even after the producer restarts.
|
|
300
|
+
if let Err(err) = rotate_segment(app, &config) {
|
|
301
|
+
record_error(app, err);
|
|
302
|
+
}
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
156
306
|
if let Err(err) = ensure_running(app, &config) {
|
|
157
307
|
record_error(app, err);
|
|
158
308
|
}
|
|
@@ -191,6 +341,8 @@ pub async fn screen_memory_pause(app: AppHandle) -> Result<ScreenMemoryStatus, S
|
|
|
191
341
|
feature_config.screen_memory.paused = true;
|
|
192
342
|
feature_config.screen_memory = normalize_screen_memory_config(feature_config.screen_memory);
|
|
193
343
|
crate::config::set_feature_config(app.clone(), feature_config).await?;
|
|
344
|
+
append_event(&app, coverage_gap_event("user-paused"));
|
|
345
|
+
let _ = refresh_rewind_chapters(&app);
|
|
194
346
|
build_status(&app)
|
|
195
347
|
}
|
|
196
348
|
|
|
@@ -201,6 +353,8 @@ pub async fn screen_memory_stop(app: AppHandle) -> Result<ScreenMemoryStatus, St
|
|
|
201
353
|
feature_config.screen_memory.paused = false;
|
|
202
354
|
feature_config.screen_memory = normalize_screen_memory_config(feature_config.screen_memory);
|
|
203
355
|
crate::config::set_feature_config(app.clone(), feature_config).await?;
|
|
356
|
+
append_event(&app, coverage_gap_event("user-disabled"));
|
|
357
|
+
let _ = refresh_rewind_chapters(&app);
|
|
204
358
|
build_status(&app)
|
|
205
359
|
}
|
|
206
360
|
|
|
@@ -242,6 +396,159 @@ pub async fn screen_memory_open_folder(app: AppHandle) -> Result<(), String> {
|
|
|
242
396
|
crate::clips::open_local_recording_folder(dir.to_string_lossy().to_string())
|
|
243
397
|
}
|
|
244
398
|
|
|
399
|
+
#[derive(Debug, Clone, Serialize)]
|
|
400
|
+
#[serde(rename_all = "camelCase")]
|
|
401
|
+
pub struct ScreenMemoryAgentConnectionStatus {
|
|
402
|
+
pub client: String,
|
|
403
|
+
pub configured: bool,
|
|
404
|
+
pub config_path: String,
|
|
405
|
+
pub store_dir: String,
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
fn home_dir() -> Result<PathBuf, String> {
|
|
409
|
+
std::env::var_os("HOME")
|
|
410
|
+
.map(PathBuf::from)
|
|
411
|
+
.ok_or_else(|| "Could not resolve the current user's home directory.".to_string())
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
fn toml_quote(value: &str) -> String {
|
|
415
|
+
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
fn codex_screen_memory_block(store_dir: &Path) -> String {
|
|
419
|
+
let args = [
|
|
420
|
+
"-y",
|
|
421
|
+
"@agent-native/core@latest",
|
|
422
|
+
"mcp",
|
|
423
|
+
"screen-memory",
|
|
424
|
+
"--dir",
|
|
425
|
+
&store_dir.to_string_lossy(),
|
|
426
|
+
];
|
|
427
|
+
format!(
|
|
428
|
+
"[mcp_servers.\"{SCREEN_MEMORY_MCP_NAME}\"]\ncommand = \"npx\"\nargs = [{}]\n",
|
|
429
|
+
args.iter()
|
|
430
|
+
.map(|arg| toml_quote(arg))
|
|
431
|
+
.collect::<Vec<_>>()
|
|
432
|
+
.join(", ")
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
fn table_header(line: &str) -> Option<&str> {
|
|
437
|
+
let trimmed = line.trim();
|
|
438
|
+
if !trimmed.starts_with('[') {
|
|
439
|
+
return None;
|
|
440
|
+
}
|
|
441
|
+
let end = trimmed.find(']')?;
|
|
442
|
+
Some(trimmed[1..end].trim())
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
fn is_screen_memory_table(header: &str) -> bool {
|
|
446
|
+
header == format!("mcp_servers.\"{SCREEN_MEMORY_MCP_NAME}\"")
|
|
447
|
+
|| header == format!("mcp_servers.{SCREEN_MEMORY_MCP_NAME}")
|
|
448
|
+
|| header.starts_with(&format!("mcp_servers.\"{SCREEN_MEMORY_MCP_NAME}\"."))
|
|
449
|
+
|| header.starts_with(&format!("mcp_servers.{SCREEN_MEMORY_MCP_NAME}."))
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
fn replace_codex_screen_memory_block(existing: &str, block: &str) -> String {
|
|
453
|
+
let lines = existing.lines().collect::<Vec<_>>();
|
|
454
|
+
let mut kept = Vec::new();
|
|
455
|
+
let mut index = 0;
|
|
456
|
+
while index < lines.len() {
|
|
457
|
+
if table_header(lines[index]).is_some_and(is_screen_memory_table) {
|
|
458
|
+
index += 1;
|
|
459
|
+
while index < lines.len() && table_header(lines[index]).is_none() {
|
|
460
|
+
index += 1;
|
|
461
|
+
}
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
kept.push(lines[index]);
|
|
465
|
+
index += 1;
|
|
466
|
+
}
|
|
467
|
+
let base = kept.join("\n").trim_end().to_string();
|
|
468
|
+
if base.is_empty() {
|
|
469
|
+
format!("{block}\n")
|
|
470
|
+
} else {
|
|
471
|
+
format!("{base}\n\n{block}\n")
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
fn write_atomic(path: &Path, contents: &[u8]) -> Result<(), String> {
|
|
476
|
+
if let Some(parent) = path.parent() {
|
|
477
|
+
std::fs::create_dir_all(parent)
|
|
478
|
+
.map_err(|err| format!("Could not create {}: {err}", parent.display()))?;
|
|
479
|
+
}
|
|
480
|
+
let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
|
|
481
|
+
std::fs::write(&temporary, contents)
|
|
482
|
+
.map_err(|err| format!("Could not write {}: {err}", temporary.display()))?;
|
|
483
|
+
std::fs::rename(&temporary, path)
|
|
484
|
+
.map_err(|err| format!("Could not replace {}: {err}", path.display()))
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
fn agent_connection_config_path(client: &str) -> Result<PathBuf, String> {
|
|
488
|
+
let home = home_dir()?;
|
|
489
|
+
match client {
|
|
490
|
+
"codex" => Ok(home.join(".codex/config.toml")),
|
|
491
|
+
"claude-code" => Ok(home.join(".claude.json")),
|
|
492
|
+
_ => Err("Supported agents are Codex and Claude Code.".to_string()),
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
fn install_agent_connection(client: &str, store_dir: &Path) -> Result<PathBuf, String> {
|
|
497
|
+
let config_path = agent_connection_config_path(client)?;
|
|
498
|
+
if client == "codex" {
|
|
499
|
+
let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
|
|
500
|
+
let next =
|
|
501
|
+
replace_codex_screen_memory_block(&existing, &codex_screen_memory_block(store_dir));
|
|
502
|
+
write_atomic(&config_path, next.as_bytes())?;
|
|
503
|
+
} else {
|
|
504
|
+
let mut root = std::fs::read_to_string(&config_path)
|
|
505
|
+
.ok()
|
|
506
|
+
.and_then(|value| serde_json::from_str::<serde_json::Value>(&value).ok())
|
|
507
|
+
.unwrap_or_else(|| serde_json::json!({}));
|
|
508
|
+
let object = root
|
|
509
|
+
.as_object_mut()
|
|
510
|
+
.ok_or_else(|| format!("{} is not a JSON object.", config_path.display()))?;
|
|
511
|
+
let servers = object
|
|
512
|
+
.entry("mcpServers")
|
|
513
|
+
.or_insert_with(|| serde_json::json!({}))
|
|
514
|
+
.as_object_mut()
|
|
515
|
+
.ok_or_else(|| "Claude Code mcpServers is not a JSON object.".to_string())?;
|
|
516
|
+
servers.insert(
|
|
517
|
+
SCREEN_MEMORY_MCP_NAME.to_string(),
|
|
518
|
+
serde_json::json!({
|
|
519
|
+
"command": "npx",
|
|
520
|
+
"args": [
|
|
521
|
+
"-y",
|
|
522
|
+
"@agent-native/core@latest",
|
|
523
|
+
"mcp",
|
|
524
|
+
"screen-memory",
|
|
525
|
+
"--dir",
|
|
526
|
+
store_dir.to_string_lossy()
|
|
527
|
+
]
|
|
528
|
+
}),
|
|
529
|
+
);
|
|
530
|
+
let bytes = serde_json::to_vec_pretty(&root)
|
|
531
|
+
.map_err(|err| format!("Could not encode Claude Code configuration: {err}"))?;
|
|
532
|
+
write_atomic(&config_path, &bytes)?;
|
|
533
|
+
}
|
|
534
|
+
Ok(config_path)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
#[tauri::command]
|
|
538
|
+
pub async fn screen_memory_install_agent_connection(
|
|
539
|
+
app: AppHandle,
|
|
540
|
+
client: String,
|
|
541
|
+
) -> Result<ScreenMemoryAgentConnectionStatus, String> {
|
|
542
|
+
let store_dir = screen_memory_dir(&app)?;
|
|
543
|
+
let config_path = install_agent_connection(&client, &store_dir)?;
|
|
544
|
+
Ok(ScreenMemoryAgentConnectionStatus {
|
|
545
|
+
client,
|
|
546
|
+
configured: true,
|
|
547
|
+
config_path: config_path.to_string_lossy().to_string(),
|
|
548
|
+
store_dir: store_dir.to_string_lossy().to_string(),
|
|
549
|
+
})
|
|
550
|
+
}
|
|
551
|
+
|
|
245
552
|
#[tauri::command]
|
|
246
553
|
pub async fn screen_memory_delete(
|
|
247
554
|
app: AppHandle,
|
|
@@ -279,9 +586,21 @@ fn normalize_screen_memory_config(mut config: ScreenMemoryConfig) -> ScreenMemor
|
|
|
279
586
|
config.sample_interval_seconds = config
|
|
280
587
|
.sample_interval_seconds
|
|
281
588
|
.clamp(1, config.segment_seconds);
|
|
589
|
+
config.excluded_bundle_ids = normalize_excluded_bundle_ids(config.excluded_bundle_ids);
|
|
282
590
|
config
|
|
283
591
|
}
|
|
284
592
|
|
|
593
|
+
fn normalize_excluded_bundle_ids(bundle_ids: Vec<String>) -> Vec<String> {
|
|
594
|
+
let mut normalized = bundle_ids
|
|
595
|
+
.into_iter()
|
|
596
|
+
.map(|bundle_id| bundle_id.trim().to_ascii_lowercase())
|
|
597
|
+
.filter(|bundle_id| !bundle_id.is_empty())
|
|
598
|
+
.collect::<Vec<_>>();
|
|
599
|
+
normalized.sort();
|
|
600
|
+
normalized.dedup();
|
|
601
|
+
normalized
|
|
602
|
+
}
|
|
603
|
+
|
|
285
604
|
fn screen_memory_available() -> bool {
|
|
286
605
|
#[cfg(target_os = "macos")]
|
|
287
606
|
{
|
|
@@ -301,6 +620,12 @@ fn screen_memory_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
|
|
301
620
|
.join(SCREEN_MEMORY_DIR);
|
|
302
621
|
std::fs::create_dir_all(&dir)
|
|
303
622
|
.map_err(|e| format!("screen memory directory unavailable: {e}"))?;
|
|
623
|
+
#[cfg(unix)]
|
|
624
|
+
{
|
|
625
|
+
use std::os::unix::fs::PermissionsExt;
|
|
626
|
+
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
|
|
627
|
+
.map_err(|e| format!("screen memory directory protection failed: {e}"))?;
|
|
628
|
+
}
|
|
304
629
|
Ok(dir)
|
|
305
630
|
}
|
|
306
631
|
|
|
@@ -308,10 +633,229 @@ fn screen_memory_events_path(app: &AppHandle) -> Result<PathBuf, String> {
|
|
|
308
633
|
Ok(screen_memory_dir(app)?.join(SCREEN_MEMORY_EVENTS_JSONL))
|
|
309
634
|
}
|
|
310
635
|
|
|
636
|
+
fn agent_handoffs_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
|
637
|
+
let dir = screen_memory_dir(app)?.join("agent-handoffs");
|
|
638
|
+
std::fs::create_dir_all(&dir)
|
|
639
|
+
.map_err(|err| format!("agent handoff directory unavailable: {err}"))?;
|
|
640
|
+
Ok(dir)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
fn agent_handoff_path(app: &AppHandle, request_id: &str) -> Result<PathBuf, String> {
|
|
644
|
+
let safe = sanitize_segment_id(request_id);
|
|
645
|
+
if safe != request_id || !safe.starts_with("handoff-") {
|
|
646
|
+
return Err("Invalid Rewind handoff request ID.".to_string());
|
|
647
|
+
}
|
|
648
|
+
Ok(agent_handoffs_dir(app)?.join(format!("{safe}.json")))
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
fn read_agent_handoff(path: &Path) -> Result<serde_json::Value, String> {
|
|
652
|
+
serde_json::from_slice(
|
|
653
|
+
&std::fs::read(path).map_err(|err| format!("Could not read {}: {err}", path.display()))?,
|
|
654
|
+
)
|
|
655
|
+
.map_err(|err| format!("Could not parse {}: {err}", path.display()))
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
fn write_agent_handoff(path: &Path, value: &serde_json::Value) -> Result<(), String> {
|
|
659
|
+
let bytes = serde_json::to_vec_pretty(value)
|
|
660
|
+
.map_err(|err| format!("Could not encode agent handoff: {err}"))?;
|
|
661
|
+
write_atomic(path, &bytes)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
#[tauri::command]
|
|
665
|
+
pub async fn screen_memory_next_agent_handoff(
|
|
666
|
+
app: AppHandle,
|
|
667
|
+
) -> Result<Option<serde_json::Value>, String> {
|
|
668
|
+
let mut pending = Vec::new();
|
|
669
|
+
for entry in std::fs::read_dir(agent_handoffs_dir(&app)?)
|
|
670
|
+
.map_err(|err| format!("Could not list agent handoffs: {err}"))?
|
|
671
|
+
{
|
|
672
|
+
let path = entry.map_err(|err| err.to_string())?.path();
|
|
673
|
+
if path.extension().and_then(|value| value.to_str()) != Some("json") {
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
let value = match read_agent_handoff(&path) {
|
|
677
|
+
Ok(value) => value,
|
|
678
|
+
Err(_) => continue,
|
|
679
|
+
};
|
|
680
|
+
if value.get("status").and_then(|value| value.as_str()) == Some("pending") {
|
|
681
|
+
let requested_at = value
|
|
682
|
+
.get("requestedAt")
|
|
683
|
+
.and_then(|value| value.as_str())
|
|
684
|
+
.unwrap_or_default()
|
|
685
|
+
.to_string();
|
|
686
|
+
pending.push((requested_at, value));
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
pending.sort_by(|left, right| left.0.cmp(&right.0));
|
|
690
|
+
Ok(pending.into_iter().next().map(|(_, value)| value))
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
#[tauri::command]
|
|
694
|
+
pub async fn screen_memory_update_agent_handoff(
|
|
695
|
+
app: AppHandle,
|
|
696
|
+
request_id: String,
|
|
697
|
+
status: String,
|
|
698
|
+
result: Option<serde_json::Value>,
|
|
699
|
+
error: Option<String>,
|
|
700
|
+
) -> Result<(), String> {
|
|
701
|
+
if !matches!(
|
|
702
|
+
status.as_str(),
|
|
703
|
+
"processing" | "ready" | "declined" | "failed"
|
|
704
|
+
) {
|
|
705
|
+
return Err("Invalid Rewind handoff status.".to_string());
|
|
706
|
+
}
|
|
707
|
+
let path = agent_handoff_path(&app, &request_id)?;
|
|
708
|
+
let mut value = read_agent_handoff(&path)?;
|
|
709
|
+
let object = value
|
|
710
|
+
.as_object_mut()
|
|
711
|
+
.ok_or_else(|| "Rewind handoff is not an object.".to_string())?;
|
|
712
|
+
object.insert("status".to_string(), serde_json::json!(status));
|
|
713
|
+
object.insert("updatedAt".to_string(), serde_json::json!(now_iso()));
|
|
714
|
+
scrub_terminal_handoff_fields(object, &status);
|
|
715
|
+
if let Some(result) = result.and_then(|value| value.as_object().cloned()) {
|
|
716
|
+
for (key, value) in result {
|
|
717
|
+
if matches!(
|
|
718
|
+
key.as_str(),
|
|
719
|
+
"recordingId" | "agentUrl" | "contextUrl" | "expiresAt" | "autoDeleteAt"
|
|
720
|
+
) {
|
|
721
|
+
object.insert(key, value);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if let Some(error) = error {
|
|
726
|
+
object.insert(
|
|
727
|
+
"error".to_string(),
|
|
728
|
+
serde_json::json!(error.chars().take(1_000).collect::<String>()),
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
write_agent_handoff(&path, &value)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
fn scrub_terminal_handoff_fields(
|
|
735
|
+
object: &mut serde_json::Map<String, serde_json::Value>,
|
|
736
|
+
status: &str,
|
|
737
|
+
) {
|
|
738
|
+
if matches!(status, "ready" | "declined" | "failed") {
|
|
739
|
+
// The request reason is useful while the user is reviewing or the
|
|
740
|
+
// handoff is processing, but it must not become a second durable
|
|
741
|
+
// transcript once the request reaches a terminal state.
|
|
742
|
+
object.remove("reason");
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
#[tauri::command]
|
|
747
|
+
pub async fn screen_memory_due_agent_handoffs(
|
|
748
|
+
app: AppHandle,
|
|
749
|
+
) -> Result<Vec<serde_json::Value>, String> {
|
|
750
|
+
let now = Utc::now();
|
|
751
|
+
let mut due = Vec::new();
|
|
752
|
+
for entry in std::fs::read_dir(agent_handoffs_dir(&app)?)
|
|
753
|
+
.map_err(|err| format!("Could not list agent handoffs: {err}"))?
|
|
754
|
+
{
|
|
755
|
+
let path = entry.map_err(|err| err.to_string())?.path();
|
|
756
|
+
if path.extension().and_then(|value| value.to_str()) != Some("json") {
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
let value = match read_agent_handoff(&path) {
|
|
760
|
+
Ok(value) => value,
|
|
761
|
+
Err(_) => continue,
|
|
762
|
+
};
|
|
763
|
+
if value.get("status").and_then(|value| value.as_str()) != Some("ready")
|
|
764
|
+
|| value.get("autoDeletedAt").is_some()
|
|
765
|
+
{
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
let Some(auto_delete_at) = value
|
|
769
|
+
.get("autoDeleteAt")
|
|
770
|
+
.and_then(|value| value.as_str())
|
|
771
|
+
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
|
772
|
+
else {
|
|
773
|
+
continue;
|
|
774
|
+
};
|
|
775
|
+
if auto_delete_at.with_timezone(&Utc) <= now {
|
|
776
|
+
due.push(serde_json::json!({
|
|
777
|
+
"requestId": value.get("requestId"),
|
|
778
|
+
"recordingId": value.get("recordingId")
|
|
779
|
+
}));
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
Ok(due)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
#[tauri::command]
|
|
786
|
+
pub async fn screen_memory_mark_agent_handoff_deleted(
|
|
787
|
+
app: AppHandle,
|
|
788
|
+
request_id: String,
|
|
789
|
+
) -> Result<(), String> {
|
|
790
|
+
let path = agent_handoff_path(&app, &request_id)?;
|
|
791
|
+
let mut value = read_agent_handoff(&path)?;
|
|
792
|
+
let object = value
|
|
793
|
+
.as_object_mut()
|
|
794
|
+
.ok_or_else(|| "Rewind handoff is not an object.".to_string())?;
|
|
795
|
+
object.insert("status".to_string(), serde_json::json!("deleted"));
|
|
796
|
+
object.insert("autoDeletedAt".to_string(), serde_json::json!(now_iso()));
|
|
797
|
+
write_agent_handoff(&path, &value)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
#[tauri::command]
|
|
801
|
+
pub async fn screen_memory_cancel_agent_handoff_cleanup(
|
|
802
|
+
app: AppHandle,
|
|
803
|
+
request_id: String,
|
|
804
|
+
) -> Result<(), String> {
|
|
805
|
+
let path = agent_handoff_path(&app, &request_id)?;
|
|
806
|
+
let mut value = read_agent_handoff(&path)?;
|
|
807
|
+
let object = value
|
|
808
|
+
.as_object_mut()
|
|
809
|
+
.ok_or_else(|| "Rewind handoff is not an object.".to_string())?;
|
|
810
|
+
object.remove("autoDeleteAt");
|
|
811
|
+
object.insert(
|
|
812
|
+
"cleanupCanceledAt".to_string(),
|
|
813
|
+
serde_json::json!(now_iso()),
|
|
814
|
+
);
|
|
815
|
+
object.insert(
|
|
816
|
+
"cleanupCanceledReason".to_string(),
|
|
817
|
+
serde_json::json!("promoted"),
|
|
818
|
+
);
|
|
819
|
+
write_agent_handoff(&path, &value)
|
|
820
|
+
}
|
|
821
|
+
|
|
311
822
|
fn segment_metadata_path(app: &AppHandle, segment_id: &str) -> Result<PathBuf, String> {
|
|
312
823
|
Ok(screen_memory_dir(app)?.join(format!("{}.json", sanitize_segment_id(segment_id))))
|
|
313
824
|
}
|
|
314
825
|
|
|
826
|
+
fn segment_ocr_rows_path(app: &AppHandle, segment_id: &str) -> Result<PathBuf, String> {
|
|
827
|
+
Ok(screen_memory_dir(app)?.join(format!("{}.ocr.jsonl", sanitize_segment_id(segment_id))))
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
fn segment_ocr_status_path(app: &AppHandle, segment_id: &str) -> Result<PathBuf, String> {
|
|
831
|
+
Ok(screen_memory_dir(app)?.join(format!(
|
|
832
|
+
"{}.ocr-status.json",
|
|
833
|
+
sanitize_segment_id(segment_id)
|
|
834
|
+
)))
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
fn segment_transcript_rows_path(app: &AppHandle, segment_id: &str) -> Result<PathBuf, String> {
|
|
838
|
+
Ok(transcript_sidecar_paths(&screen_memory_dir(app)?, segment_id).0)
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
fn segment_transcript_status_path(app: &AppHandle, segment_id: &str) -> Result<PathBuf, String> {
|
|
842
|
+
Ok(transcript_sidecar_paths(&screen_memory_dir(app)?, segment_id).1)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
fn transcript_sidecar_paths(dir: &Path, segment_id: &str) -> (PathBuf, PathBuf) {
|
|
846
|
+
let id = sanitize_segment_id(segment_id);
|
|
847
|
+
(
|
|
848
|
+
dir.join(format!("{id}.transcript.jsonl")),
|
|
849
|
+
dir.join(format!("{id}.transcript-status.json")),
|
|
850
|
+
)
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
fn remove_transcript_sidecars(dir: &Path, segment_id: &str) -> Result<(), String> {
|
|
854
|
+
let (rows, status) = transcript_sidecar_paths(dir, segment_id);
|
|
855
|
+
remove_file_if_exists(&rows)?;
|
|
856
|
+
remove_file_if_exists(&status)
|
|
857
|
+
}
|
|
858
|
+
|
|
315
859
|
fn segment_media_path(
|
|
316
860
|
app: &AppHandle,
|
|
317
861
|
segment_id: &str,
|
|
@@ -344,7 +888,7 @@ fn next_segment_id() -> String {
|
|
|
344
888
|
fn build_status(app: &AppHandle) -> Result<ScreenMemoryStatus, String> {
|
|
345
889
|
let config = normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
|
|
346
890
|
let storage_dir = screen_memory_dir(app)?;
|
|
347
|
-
let (active_segment, last_error) = {
|
|
891
|
+
let (active_segment, last_error, exclusion_active) = {
|
|
348
892
|
let state = app.state::<ScreenMemoryState>();
|
|
349
893
|
let guard = state.inner.lock().map_err(|e| e.to_string())?;
|
|
350
894
|
(
|
|
@@ -361,6 +905,7 @@ fn build_status(app: &AppHandle) -> Result<ScreenMemoryStatus, String> {
|
|
|
361
905
|
height: active.height,
|
|
362
906
|
}),
|
|
363
907
|
guard.last_error.clone(),
|
|
908
|
+
guard.exclusion_active,
|
|
364
909
|
)
|
|
365
910
|
};
|
|
366
911
|
let state = if !config.enabled {
|
|
@@ -373,6 +918,8 @@ fn build_status(app: &AppHandle) -> Result<ScreenMemoryStatus, String> {
|
|
|
373
918
|
ScreenMemoryRuntimeState::Idle
|
|
374
919
|
};
|
|
375
920
|
|
|
921
|
+
let producer_active = active_segment.is_some();
|
|
922
|
+
let coverage = coverage_language(&config, exclusion_active, producer_active);
|
|
376
923
|
Ok(ScreenMemoryStatus {
|
|
377
924
|
available: screen_memory_available(),
|
|
378
925
|
state,
|
|
@@ -381,10 +928,359 @@ fn build_status(app: &AppHandle) -> Result<ScreenMemoryStatus, String> {
|
|
|
381
928
|
active_segment,
|
|
382
929
|
recent_segments: recent_segments(app, Some(5))?,
|
|
383
930
|
last_error,
|
|
931
|
+
exclusion_active,
|
|
932
|
+
coverage,
|
|
384
933
|
})
|
|
385
934
|
}
|
|
386
935
|
|
|
936
|
+
fn coverage_language(
|
|
937
|
+
config: &ScreenMemoryConfig,
|
|
938
|
+
exclusion_active: bool,
|
|
939
|
+
producer_active: bool,
|
|
940
|
+
) -> String {
|
|
941
|
+
if exclusion_active {
|
|
942
|
+
return "Capture stopped for an excluded app. The entire in-flight media segment was discarded, and this interval is recorded as a source coverage gap.".to_string();
|
|
943
|
+
}
|
|
944
|
+
if !config.enabled {
|
|
945
|
+
return "Rewind capture is disabled. Existing local segments remain available.".to_string();
|
|
946
|
+
}
|
|
947
|
+
if config.paused {
|
|
948
|
+
return "Rewind capture is paused. Existing local segments remain available, and no new source coverage is being retained.".to_string();
|
|
949
|
+
}
|
|
950
|
+
if producer_active {
|
|
951
|
+
return "Rewind is retaining local media coverage. Excluded applications stop capture and create explicit source coverage gaps.".to_string();
|
|
952
|
+
}
|
|
953
|
+
"Rewind is not currently retaining source coverage.".to_string()
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
pub(crate) fn rewind_clip_compatible(app: &AppHandle) -> Result<bool, String> {
|
|
957
|
+
let status = build_status(app)?;
|
|
958
|
+
Ok(status.available
|
|
959
|
+
&& matches!(status.state, ScreenMemoryRuntimeState::Recording)
|
|
960
|
+
&& status.last_error.is_none())
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
pub(crate) fn rewind_clip_sources(app: &AppHandle) -> Vec<CaptureSource> {
|
|
964
|
+
let capture_mode = app
|
|
965
|
+
.try_state::<ScreenMemoryState>()
|
|
966
|
+
.and_then(|state| {
|
|
967
|
+
state
|
|
968
|
+
.inner
|
|
969
|
+
.lock()
|
|
970
|
+
.ok()
|
|
971
|
+
.and_then(|runtime| runtime.active.as_ref().map(|active| active.capture_mode))
|
|
972
|
+
})
|
|
973
|
+
.unwrap_or_else(|| {
|
|
974
|
+
let base_mode = crate::config::feature_config(app)
|
|
975
|
+
.screen_memory
|
|
976
|
+
.capture_mode;
|
|
977
|
+
effective_capture_mode(app, base_mode)
|
|
978
|
+
});
|
|
979
|
+
requested_sources(capture_mode)
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
fn effective_mode(
|
|
983
|
+
base_mode: RewindCaptureMode,
|
|
984
|
+
temporary_audio_consumers: usize,
|
|
985
|
+
) -> RewindCaptureMode {
|
|
986
|
+
if temporary_audio_consumers > 0 {
|
|
987
|
+
RewindCaptureMode::VisualsAudio
|
|
988
|
+
} else {
|
|
989
|
+
base_mode
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
fn effective_capture_mode(app: &AppHandle, base_mode: RewindCaptureMode) -> RewindCaptureMode {
|
|
994
|
+
let temporary_audio_consumers = app
|
|
995
|
+
.try_state::<ScreenMemoryState>()
|
|
996
|
+
.and_then(|state| {
|
|
997
|
+
state
|
|
998
|
+
.inner
|
|
999
|
+
.lock()
|
|
1000
|
+
.ok()
|
|
1001
|
+
.map(|runtime| runtime.temporary_audio_consumers.len())
|
|
1002
|
+
})
|
|
1003
|
+
.unwrap_or(0);
|
|
1004
|
+
effective_mode(base_mode, temporary_audio_consumers)
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
fn temporary_audio_lease_available(
|
|
1008
|
+
enabled: bool,
|
|
1009
|
+
paused: bool,
|
|
1010
|
+
producer_active: bool,
|
|
1011
|
+
include_mic: bool,
|
|
1012
|
+
include_system_audio: bool,
|
|
1013
|
+
) -> bool {
|
|
1014
|
+
enabled && !paused && producer_active && (include_mic || include_system_audio)
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/// Temporarily upgrades the active Rewind producer to its audio-capable mode.
|
|
1018
|
+
/// The persisted Screen Memory mode is never changed. Repeated acquisition by
|
|
1019
|
+
/// the same stable owner is idempotent; distinct owners refcount the effective
|
|
1020
|
+
/// audio demand through separate graph leases.
|
|
1021
|
+
pub(crate) fn acquire_temporary_audio_consumer(
|
|
1022
|
+
app: &AppHandle,
|
|
1023
|
+
owner_id: &str,
|
|
1024
|
+
consumer: CaptureConsumer,
|
|
1025
|
+
include_mic: bool,
|
|
1026
|
+
include_system_audio: bool,
|
|
1027
|
+
) -> Result<Option<TemporaryAudioLease>, String> {
|
|
1028
|
+
if owner_id.trim().is_empty() {
|
|
1029
|
+
return Err("temporary audio consumer owner id cannot be empty".into());
|
|
1030
|
+
}
|
|
1031
|
+
let state = app.state::<ScreenMemoryState>();
|
|
1032
|
+
let _transition = state.transition.lock().map_err(|error| error.to_string())?;
|
|
1033
|
+
let config = normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
|
|
1034
|
+
let (mut producer_active, already_present, exclusion_active) = {
|
|
1035
|
+
let runtime = state.inner.lock().map_err(|error| error.to_string())?;
|
|
1036
|
+
(
|
|
1037
|
+
runtime.active.is_some(),
|
|
1038
|
+
runtime.temporary_audio_consumers.contains_key(owner_id),
|
|
1039
|
+
runtime.exclusion_active,
|
|
1040
|
+
)
|
|
1041
|
+
};
|
|
1042
|
+
// Config writes become visible before their sync callback completes. If a
|
|
1043
|
+
// meeting arrives in that narrow window, finish stopping the old producer
|
|
1044
|
+
// under the same transition lock before returning `None`; the caller can
|
|
1045
|
+
// then open the legacy recorder without ever overlapping Rewind.
|
|
1046
|
+
if !config.enabled || config.paused {
|
|
1047
|
+
if producer_active {
|
|
1048
|
+
stop_active_segment_inner(app)?;
|
|
1049
|
+
}
|
|
1050
|
+
return Ok(None);
|
|
1051
|
+
}
|
|
1052
|
+
if exclusion_active {
|
|
1053
|
+
return Ok(None);
|
|
1054
|
+
}
|
|
1055
|
+
if !producer_active {
|
|
1056
|
+
ensure_running_inner(app, &config)?;
|
|
1057
|
+
producer_active = true;
|
|
1058
|
+
}
|
|
1059
|
+
if already_present {
|
|
1060
|
+
return Ok(Some(TemporaryAudioLease {
|
|
1061
|
+
owner_id: owner_id.to_owned(),
|
|
1062
|
+
}));
|
|
1063
|
+
}
|
|
1064
|
+
if !temporary_audio_lease_available(
|
|
1065
|
+
config.enabled,
|
|
1066
|
+
config.paused,
|
|
1067
|
+
producer_active,
|
|
1068
|
+
include_mic,
|
|
1069
|
+
include_system_audio,
|
|
1070
|
+
) {
|
|
1071
|
+
return Ok(None);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
let mut sources = Vec::new();
|
|
1075
|
+
if include_mic {
|
|
1076
|
+
sources.push(CaptureSource::Microphone);
|
|
1077
|
+
}
|
|
1078
|
+
if include_system_audio {
|
|
1079
|
+
sources.push(CaptureSource::SystemAudio);
|
|
1080
|
+
}
|
|
1081
|
+
let graph_lease = app
|
|
1082
|
+
.state::<CaptureGraphState>()
|
|
1083
|
+
.0
|
|
1084
|
+
.lock()
|
|
1085
|
+
.map_err(|error| error.to_string())?
|
|
1086
|
+
.start_consumer(consumer, sources)
|
|
1087
|
+
.map_err(capture_graph_error)?;
|
|
1088
|
+
{
|
|
1089
|
+
let mut runtime = state.inner.lock().map_err(|error| error.to_string())?;
|
|
1090
|
+
runtime.temporary_audio_consumers.insert(
|
|
1091
|
+
owner_id.to_owned(),
|
|
1092
|
+
TemporaryAudioConsumer {
|
|
1093
|
+
graph_lease_id: graph_lease.id.clone(),
|
|
1094
|
+
},
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
let needs_upgrade = state
|
|
1099
|
+
.inner
|
|
1100
|
+
.lock()
|
|
1101
|
+
.map_err(|error| error.to_string())?
|
|
1102
|
+
.active
|
|
1103
|
+
.as_ref()
|
|
1104
|
+
.is_some_and(|active| active.capture_mode != RewindCaptureMode::VisualsAudio);
|
|
1105
|
+
if needs_upgrade {
|
|
1106
|
+
if let Err(error) = rotate_segment_inner(app, &config) {
|
|
1107
|
+
rollback_temporary_audio_consumer(app, owner_id);
|
|
1108
|
+
return Err(format!("rewind-audio-upgrade-failed: {error}"));
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
Ok(Some(TemporaryAudioLease {
|
|
1112
|
+
owner_id: owner_id.to_owned(),
|
|
1113
|
+
}))
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
pub(crate) fn release_temporary_audio_consumer(
|
|
1117
|
+
app: &AppHandle,
|
|
1118
|
+
lease: TemporaryAudioLease,
|
|
1119
|
+
) -> Result<(), String> {
|
|
1120
|
+
let state = app.state::<ScreenMemoryState>();
|
|
1121
|
+
let _transition = state.transition.lock().map_err(|error| error.to_string())?;
|
|
1122
|
+
let Some(consumer) = state
|
|
1123
|
+
.inner
|
|
1124
|
+
.lock()
|
|
1125
|
+
.map_err(|error| error.to_string())?
|
|
1126
|
+
.temporary_audio_consumers
|
|
1127
|
+
.remove(&lease.owner_id)
|
|
1128
|
+
else {
|
|
1129
|
+
return Ok(());
|
|
1130
|
+
};
|
|
1131
|
+
end_graph_lease(app, &consumer.graph_lease_id);
|
|
1132
|
+
|
|
1133
|
+
let config = normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
|
|
1134
|
+
if !config.enabled || config.paused {
|
|
1135
|
+
return Ok(());
|
|
1136
|
+
}
|
|
1137
|
+
let desired = effective_capture_mode(app, config.capture_mode);
|
|
1138
|
+
let needs_restore = state
|
|
1139
|
+
.inner
|
|
1140
|
+
.lock()
|
|
1141
|
+
.map_err(|error| error.to_string())?
|
|
1142
|
+
.active
|
|
1143
|
+
.as_ref()
|
|
1144
|
+
.is_some_and(|active| active.capture_mode != desired);
|
|
1145
|
+
if needs_restore {
|
|
1146
|
+
rotate_segment_inner(app, &config)?;
|
|
1147
|
+
}
|
|
1148
|
+
Ok(())
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
fn rollback_temporary_audio_consumer(app: &AppHandle, owner_id: &str) {
|
|
1152
|
+
let consumer = app
|
|
1153
|
+
.state::<ScreenMemoryState>()
|
|
1154
|
+
.inner
|
|
1155
|
+
.lock()
|
|
1156
|
+
.ok()
|
|
1157
|
+
.and_then(|mut runtime| runtime.temporary_audio_consumers.remove(owner_id));
|
|
1158
|
+
if let Some(consumer) = consumer {
|
|
1159
|
+
end_graph_lease(app, &consumer.graph_lease_id);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
fn end_graph_lease(app: &AppHandle, lease_id: &str) {
|
|
1164
|
+
if let Ok(mut graph) = app.state::<CaptureGraphState>().0.lock() {
|
|
1165
|
+
if let Err(error) = graph.end_consumer(lease_id) {
|
|
1166
|
+
eprintln!("[clips-tray] temporary audio graph lease close failed: {error}");
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/// Fence the rolling producer at an exact media-fragment edge and persist the
|
|
1172
|
+
/// closed logical segment while keeping the replacement capture active.
|
|
1173
|
+
pub(crate) fn fence_active_for_clip(
|
|
1174
|
+
app: &AppHandle,
|
|
1175
|
+
) -> Result<ScreenMemorySegmentMetadata, String> {
|
|
1176
|
+
let state = app.state::<ScreenMemoryState>();
|
|
1177
|
+
let _transition = state.transition.lock().map_err(|error| error.to_string())?;
|
|
1178
|
+
let active = {
|
|
1179
|
+
let active = state
|
|
1180
|
+
.inner
|
|
1181
|
+
.lock()
|
|
1182
|
+
.map_err(|error| error.to_string())?
|
|
1183
|
+
.active
|
|
1184
|
+
.take();
|
|
1185
|
+
active
|
|
1186
|
+
}
|
|
1187
|
+
.ok_or_else(|| "rewind-not-compatible: Screen Memory is not recording".to_string())?;
|
|
1188
|
+
if active.exclusion_tainted.load(Ordering::SeqCst) {
|
|
1189
|
+
let gap = active_exclusion_gap(active.started_at, active.capture_mode);
|
|
1190
|
+
let segment_id = active.id.clone();
|
|
1191
|
+
{
|
|
1192
|
+
let mut runtime = state.inner.lock().map_err(|error| error.to_string())?;
|
|
1193
|
+
runtime.exclusion_active = true;
|
|
1194
|
+
runtime.exclusion_gap = Some(gap);
|
|
1195
|
+
}
|
|
1196
|
+
discard_active_segment(active);
|
|
1197
|
+
discard_segment_artifacts(app, &segment_id)?;
|
|
1198
|
+
end_rewind_lease(app);
|
|
1199
|
+
let _ = app.emit(SCREEN_MEMORY_EVENT, ());
|
|
1200
|
+
return Err(
|
|
1201
|
+
"rewind-not-compatible: recognized privacy exclusion created a coverage gap".into(),
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
match fence_rotate_segment(app, active) {
|
|
1205
|
+
Ok((segment, next)) => {
|
|
1206
|
+
write_segment_metadata(app, &segment)?;
|
|
1207
|
+
install_rotated_segment(app, next)?;
|
|
1208
|
+
Ok(segment)
|
|
1209
|
+
}
|
|
1210
|
+
Err(active) => {
|
|
1211
|
+
if let Ok(mut runtime) = app.state::<ScreenMemoryState>().inner.lock() {
|
|
1212
|
+
runtime.active = Some(active);
|
|
1213
|
+
}
|
|
1214
|
+
Err("rewind-not-compatible: active Screen Memory backend cannot fence".into())
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/// Prepare an independent Clip artifact writer on the already-running Rewind
|
|
1220
|
+
/// producer. The transition lock keeps the backend stable while the writer is
|
|
1221
|
+
/// installed; no samples enter the Clip until its handle is activated at zero.
|
|
1222
|
+
#[cfg(target_os = "macos")]
|
|
1223
|
+
pub(crate) fn prepare_shared_clip_sink(
|
|
1224
|
+
app: &AppHandle,
|
|
1225
|
+
path: PathBuf,
|
|
1226
|
+
include_mic: bool,
|
|
1227
|
+
include_system_audio: bool,
|
|
1228
|
+
has_camera: bool,
|
|
1229
|
+
upload: native_screen::ClipLiveUploadConfig,
|
|
1230
|
+
) -> Result<native_screen::SharedClipSink, String> {
|
|
1231
|
+
let state = app.state::<ScreenMemoryState>();
|
|
1232
|
+
let _transition = state.transition.lock().map_err(|error| error.to_string())?;
|
|
1233
|
+
let runtime = state.inner.lock().map_err(|error| error.to_string())?;
|
|
1234
|
+
let active = runtime
|
|
1235
|
+
.active
|
|
1236
|
+
.as_ref()
|
|
1237
|
+
.ok_or_else(|| "rewind-not-compatible: Screen Memory is not recording".to_string())?;
|
|
1238
|
+
if active.exclusion_tainted.load(Ordering::SeqCst) {
|
|
1239
|
+
return Err(not_compatible_clip_sink(
|
|
1240
|
+
"recognized privacy exclusion created a coverage gap",
|
|
1241
|
+
));
|
|
1242
|
+
}
|
|
1243
|
+
let width = active
|
|
1244
|
+
.width
|
|
1245
|
+
.ok_or_else(|| not_compatible_clip_sink("Screen Memory width is unavailable"))?;
|
|
1246
|
+
let height = active
|
|
1247
|
+
.height
|
|
1248
|
+
.ok_or_else(|| not_compatible_clip_sink("Screen Memory height is unavailable"))?;
|
|
1249
|
+
native_screen::prepare_shared_clip_sink(
|
|
1250
|
+
app,
|
|
1251
|
+
&active.backend,
|
|
1252
|
+
native_screen::SharedClipSinkRequest {
|
|
1253
|
+
path,
|
|
1254
|
+
width,
|
|
1255
|
+
height,
|
|
1256
|
+
include_mic,
|
|
1257
|
+
include_system_audio,
|
|
1258
|
+
has_camera,
|
|
1259
|
+
upload,
|
|
1260
|
+
},
|
|
1261
|
+
)
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
#[cfg(target_os = "macos")]
|
|
1265
|
+
fn not_compatible_clip_sink(detail: &str) -> String {
|
|
1266
|
+
format!("rewind-not-compatible: {detail}")
|
|
1267
|
+
}
|
|
1268
|
+
|
|
387
1269
|
fn ensure_running(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), String> {
|
|
1270
|
+
let state = app.state::<ScreenMemoryState>();
|
|
1271
|
+
let _transition = state.transition.lock().map_err(|error| error.to_string())?;
|
|
1272
|
+
ensure_running_inner(app, config)
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
fn ensure_running_inner(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), String> {
|
|
1276
|
+
// An incompatible ordinary Clip owns the physical capture graph until its
|
|
1277
|
+
// final suspension lease releases. Config sync and temporary audio demand
|
|
1278
|
+
// must not silently restart Rewind in the middle of that handoff.
|
|
1279
|
+
if crate::rewind_capture_suspension::is_active(app) {
|
|
1280
|
+
return Ok(());
|
|
1281
|
+
}
|
|
1282
|
+
let mut config = config.clone();
|
|
1283
|
+
config.capture_mode = effective_capture_mode(app, config.capture_mode);
|
|
388
1284
|
if !screen_memory_available() {
|
|
389
1285
|
return Err("Screen Memory capture is currently macOS-only.".to_string());
|
|
390
1286
|
}
|
|
@@ -392,12 +1288,12 @@ fn ensure_running(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), St
|
|
|
392
1288
|
{
|
|
393
1289
|
let state = app.state::<ScreenMemoryState>();
|
|
394
1290
|
let guard = state.inner.lock().map_err(|e| e.to_string())?;
|
|
395
|
-
if guard.active.is_some() {
|
|
1291
|
+
if guard.active.is_some() || guard.exclusion_active {
|
|
396
1292
|
return Ok(());
|
|
397
1293
|
}
|
|
398
1294
|
}
|
|
399
1295
|
|
|
400
|
-
let active = start_new_segment(app)?;
|
|
1296
|
+
let active = start_new_segment(app, config.capture_mode)?;
|
|
401
1297
|
let stop = Arc::new(AtomicBool::new(false));
|
|
402
1298
|
{
|
|
403
1299
|
let state = app.state::<ScreenMemoryState>();
|
|
@@ -412,8 +1308,13 @@ fn ensure_running(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), St
|
|
|
412
1308
|
guard.last_error = None;
|
|
413
1309
|
}
|
|
414
1310
|
|
|
1311
|
+
if let Err(err) = start_rewind_lease(app, config.capture_mode) {
|
|
1312
|
+
let _ = stop_active_segment_inner(app);
|
|
1313
|
+
return Err(err);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
415
1316
|
spawn_rotator(app.clone(), stop);
|
|
416
|
-
prune_segments(app, config)?;
|
|
1317
|
+
prune_segments(app, &config)?;
|
|
417
1318
|
let _ = app.emit(SCREEN_MEMORY_EVENT, ());
|
|
418
1319
|
Ok(())
|
|
419
1320
|
}
|
|
@@ -422,8 +1323,10 @@ fn spawn_rotator(app: AppHandle, stop: Arc<AtomicBool>) {
|
|
|
422
1323
|
std::thread::spawn(move || loop {
|
|
423
1324
|
let config =
|
|
424
1325
|
normalize_screen_memory_config(crate::config::feature_config(&app).screen_memory);
|
|
425
|
-
|
|
426
|
-
return
|
|
1326
|
+
match wait_for_rotation(&app, &stop, &config) {
|
|
1327
|
+
RotationWait::Stop => return,
|
|
1328
|
+
RotationWait::Resumed => continue,
|
|
1329
|
+
RotationWait::Rotate => {}
|
|
427
1330
|
}
|
|
428
1331
|
let config =
|
|
429
1332
|
normalize_screen_memory_config(crate::config::feature_config(&app).screen_memory);
|
|
@@ -436,49 +1339,377 @@ fn spawn_rotator(app: AppHandle, stop: Arc<AtomicBool>) {
|
|
|
436
1339
|
});
|
|
437
1340
|
}
|
|
438
1341
|
|
|
439
|
-
|
|
1342
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
1343
|
+
enum RotationWait {
|
|
1344
|
+
Stop,
|
|
1345
|
+
Rotate,
|
|
1346
|
+
Resumed,
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
1350
|
+
enum ExclusionTransition {
|
|
1351
|
+
KeepCapturing,
|
|
1352
|
+
Suspend,
|
|
1353
|
+
StaySuspended,
|
|
1354
|
+
Resume,
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
fn exclusion_transition(exclusion_active: bool, recognized: bool) -> ExclusionTransition {
|
|
1358
|
+
match (exclusion_active, recognized) {
|
|
1359
|
+
(false, false) => ExclusionTransition::KeepCapturing,
|
|
1360
|
+
(false, true) => ExclusionTransition::Suspend,
|
|
1361
|
+
(true, true) => ExclusionTransition::StaySuspended,
|
|
1362
|
+
(true, false) => ExclusionTransition::Resume,
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
fn exclusion_resume_allowed(
|
|
1367
|
+
enabled: bool,
|
|
1368
|
+
paused: bool,
|
|
1369
|
+
stop_requested: bool,
|
|
1370
|
+
recognized: bool,
|
|
1371
|
+
exclusion_active: bool,
|
|
1372
|
+
producer_active: bool,
|
|
1373
|
+
) -> bool {
|
|
1374
|
+
enabled && !paused && !stop_requested && !recognized && exclusion_active && !producer_active
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
fn wait_for_rotation(
|
|
1378
|
+
app: &AppHandle,
|
|
1379
|
+
stop: &AtomicBool,
|
|
1380
|
+
config: &ScreenMemoryConfig,
|
|
1381
|
+
) -> RotationWait {
|
|
440
1382
|
let deadline = Instant::now() + Duration::from_secs(config.segment_seconds);
|
|
441
|
-
let sample_interval = Duration::from_secs(config.sample_interval_seconds.max(1));
|
|
442
1383
|
let mut next_sample = Instant::now();
|
|
1384
|
+
let mut next_exclusion_poll = Instant::now();
|
|
443
1385
|
loop {
|
|
444
1386
|
if stop.load(Ordering::Relaxed) {
|
|
445
|
-
return
|
|
1387
|
+
return RotationWait::Stop;
|
|
446
1388
|
}
|
|
447
1389
|
if Instant::now() >= deadline {
|
|
448
|
-
return
|
|
1390
|
+
return RotationWait::Rotate;
|
|
449
1391
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
1392
|
+
let now = Instant::now();
|
|
1393
|
+
if now >= next_exclusion_poll {
|
|
1394
|
+
// Exclusion edits are privacy controls, so they must take effect on
|
|
1395
|
+
// the next 250 ms poll rather than waiting for the current media
|
|
1396
|
+
// segment (up to five minutes by default) to rotate.
|
|
1397
|
+
let live_config =
|
|
1398
|
+
normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
|
|
1399
|
+
let (event, exclusion_reason) = sample_active_window(&live_config);
|
|
1400
|
+
if exclusion_transition(false, exclusion_reason.is_some())
|
|
1401
|
+
== ExclusionTransition::Suspend
|
|
1402
|
+
{
|
|
1403
|
+
// A concurrent stop/fence that wins the transition lock must
|
|
1404
|
+
// still fail closed after recognition.
|
|
1405
|
+
mark_active_segment_exclusion_tainted(app);
|
|
1406
|
+
append_event(
|
|
1407
|
+
app,
|
|
1408
|
+
coverage_gap_event(exclusion_reason.unwrap_or("privacy-exclusion")),
|
|
1409
|
+
);
|
|
1410
|
+
if let Err(error) = suspend_for_exclusion(app) {
|
|
1411
|
+
record_error(app, error);
|
|
1412
|
+
}
|
|
1413
|
+
if wait_for_exclusion_to_clear(app, stop) {
|
|
1414
|
+
return RotationWait::Stop;
|
|
1415
|
+
}
|
|
1416
|
+
// A resumed producer begins a fresh logical segment and gets a
|
|
1417
|
+
// fresh rotation deadline in the outer worker loop.
|
|
1418
|
+
return RotationWait::Resumed;
|
|
1419
|
+
}
|
|
1420
|
+
if now >= next_sample {
|
|
1421
|
+
append_event(app, event);
|
|
1422
|
+
next_sample = now + Duration::from_secs(live_config.sample_interval_seconds.max(1));
|
|
1423
|
+
}
|
|
1424
|
+
next_exclusion_poll = now + EXCLUSION_POLL_INTERVAL;
|
|
1425
|
+
}
|
|
1426
|
+
let remaining = deadline.saturating_duration_since(Instant::now());
|
|
455
1427
|
let until_sample = next_sample.saturating_duration_since(Instant::now());
|
|
456
|
-
|
|
1428
|
+
let until_exclusion = next_exclusion_poll.saturating_duration_since(Instant::now());
|
|
1429
|
+
std::thread::sleep(
|
|
1430
|
+
remaining
|
|
1431
|
+
.min(until_sample)
|
|
1432
|
+
.min(until_exclusion)
|
|
1433
|
+
.min(ROTATOR_TICK),
|
|
1434
|
+
);
|
|
457
1435
|
}
|
|
458
1436
|
}
|
|
459
1437
|
|
|
460
|
-
fn
|
|
1438
|
+
fn mark_active_segment_exclusion_tainted(app: &AppHandle) {
|
|
1439
|
+
if let Some(state) = app.try_state::<ScreenMemoryState>() {
|
|
1440
|
+
if let Ok(runtime) = state.inner.lock() {
|
|
1441
|
+
if let Some(active) = runtime.active.as_ref() {
|
|
1442
|
+
active.exclusion_tainted.store(true, Ordering::SeqCst);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
fn sample_active_window(config: &ScreenMemoryConfig) -> (ScreenMemoryEvent, Option<&'static str>) {
|
|
461
1449
|
#[cfg(target_os = "macos")]
|
|
462
1450
|
{
|
|
463
|
-
let context =
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
1451
|
+
let (context, owner_pid) =
|
|
1452
|
+
crate::accessibility::macos::active_window_context_with_pid_impl();
|
|
1453
|
+
if let Some(reason) = event_exclusion_reason(
|
|
1454
|
+
config,
|
|
1455
|
+
context.bundle_id.as_deref(),
|
|
1456
|
+
context.window_title.as_deref(),
|
|
1457
|
+
) {
|
|
1458
|
+
return (coverage_gap_event(reason), Some(reason));
|
|
470
1459
|
}
|
|
1460
|
+
(
|
|
1461
|
+
ScreenMemoryEvent {
|
|
1462
|
+
captured_at: now_iso(),
|
|
1463
|
+
app_name: context.app_name,
|
|
1464
|
+
window_title: context.window_title,
|
|
1465
|
+
bundle_id: context.bundle_id,
|
|
1466
|
+
source: context.source,
|
|
1467
|
+
coverage_gap_reason: None,
|
|
1468
|
+
// Exclusion was checked above; this silent AX read never
|
|
1469
|
+
// prompts and degrades to None when macOS denies/unreadable.
|
|
1470
|
+
accessibility: owner_pid
|
|
1471
|
+
.and_then(crate::accessibility::macos::semantic_fingerprint_for_pid_impl),
|
|
1472
|
+
},
|
|
1473
|
+
None,
|
|
1474
|
+
)
|
|
471
1475
|
}
|
|
472
1476
|
#[cfg(not(target_os = "macos"))]
|
|
473
1477
|
{
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
1478
|
+
(
|
|
1479
|
+
ScreenMemoryEvent {
|
|
1480
|
+
captured_at: now_iso(),
|
|
1481
|
+
app_name: None,
|
|
1482
|
+
window_title: None,
|
|
1483
|
+
bundle_id: None,
|
|
1484
|
+
source: "unsupported".to_string(),
|
|
1485
|
+
coverage_gap_reason: None,
|
|
1486
|
+
accessibility: None,
|
|
1487
|
+
},
|
|
1488
|
+
None,
|
|
1489
|
+
)
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
fn event_exclusion_reason(
|
|
1494
|
+
config: &ScreenMemoryConfig,
|
|
1495
|
+
bundle_id: Option<&str>,
|
|
1496
|
+
window_title: Option<&str>,
|
|
1497
|
+
) -> Option<&'static str> {
|
|
1498
|
+
let normalized_bundle_id = bundle_id.map(|value| value.trim().to_ascii_lowercase());
|
|
1499
|
+
if normalized_bundle_id.as_deref().is_some_and(|bundle_id| {
|
|
1500
|
+
config
|
|
1501
|
+
.excluded_bundle_ids
|
|
1502
|
+
.iter()
|
|
1503
|
+
.any(|excluded| excluded.eq_ignore_ascii_case(bundle_id))
|
|
1504
|
+
}) {
|
|
1505
|
+
return Some("excluded-bundle-id");
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
if config.exclude_private_windows
|
|
1509
|
+
&& window_title.is_some_and(|title| {
|
|
1510
|
+
let title = title.to_ascii_lowercase();
|
|
1511
|
+
[
|
|
1512
|
+
"incognito",
|
|
1513
|
+
"inprivate",
|
|
1514
|
+
"private browsing",
|
|
1515
|
+
"private window",
|
|
1516
|
+
]
|
|
1517
|
+
.iter()
|
|
1518
|
+
.any(|marker| title.contains(marker))
|
|
1519
|
+
})
|
|
1520
|
+
{
|
|
1521
|
+
return Some("excluded-private-window");
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
None
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
fn coverage_gap_event(reason: &str) -> ScreenMemoryEvent {
|
|
1528
|
+
ScreenMemoryEvent {
|
|
1529
|
+
captured_at: now_iso(),
|
|
1530
|
+
app_name: None,
|
|
1531
|
+
window_title: None,
|
|
1532
|
+
bundle_id: None,
|
|
1533
|
+
source: "coverage-gap".to_string(),
|
|
1534
|
+
coverage_gap_reason: Some(reason.to_string()),
|
|
1535
|
+
accessibility: None,
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/// Stop the one physical producer and erase the complete logical segment that
|
|
1540
|
+
/// may contain excluded media. The rotator worker deliberately remains alive:
|
|
1541
|
+
/// while this state is active it does nothing except poll the recognized-window
|
|
1542
|
+
/// signal until a safe replacement can be started under the transition lock.
|
|
1543
|
+
fn suspend_for_exclusion(app: &AppHandle) -> Result<(), String> {
|
|
1544
|
+
let state = app.state::<ScreenMemoryState>();
|
|
1545
|
+
let _transition = state.transition.lock().map_err(|error| error.to_string())?;
|
|
1546
|
+
let active = {
|
|
1547
|
+
let mut runtime = state.inner.lock().map_err(|error| error.to_string())?;
|
|
1548
|
+
if runtime.exclusion_active {
|
|
1549
|
+
return Ok(());
|
|
480
1550
|
}
|
|
1551
|
+
let worker_running = runtime
|
|
1552
|
+
.worker_stop
|
|
1553
|
+
.as_ref()
|
|
1554
|
+
.is_some_and(|stop| !stop.load(Ordering::Relaxed));
|
|
1555
|
+
let config = crate::config::feature_config(app).screen_memory;
|
|
1556
|
+
if !worker_running || !config.enabled || config.paused {
|
|
1557
|
+
return Ok(());
|
|
1558
|
+
}
|
|
1559
|
+
let active = runtime.active.take();
|
|
1560
|
+
runtime.exclusion_active = true;
|
|
1561
|
+
runtime.exclusion_gap = Some(match active.as_ref() {
|
|
1562
|
+
Some(active) => active_exclusion_gap(
|
|
1563
|
+
// The whole segment is discarded, so coverage ended when that
|
|
1564
|
+
// segment began—not merely when the excluded window was noticed.
|
|
1565
|
+
active.started_at,
|
|
1566
|
+
active.capture_mode,
|
|
1567
|
+
),
|
|
1568
|
+
None => active_exclusion_gap(
|
|
1569
|
+
Instant::now(),
|
|
1570
|
+
effective_mode(config.capture_mode, runtime.temporary_audio_consumers.len()),
|
|
1571
|
+
),
|
|
1572
|
+
});
|
|
1573
|
+
active
|
|
1574
|
+
};
|
|
1575
|
+
|
|
1576
|
+
if let Some(active) = active {
|
|
1577
|
+
let segment_id = active.id.clone();
|
|
1578
|
+
discard_active_segment(active);
|
|
1579
|
+
discard_segment_artifacts(app, &segment_id)?;
|
|
481
1580
|
}
|
|
1581
|
+
end_rewind_lease(app);
|
|
1582
|
+
let _ = app.emit(SCREEN_MEMORY_EVENT, ());
|
|
1583
|
+
Ok(())
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
fn wait_for_exclusion_to_clear(app: &AppHandle, stop: &AtomicBool) -> bool {
|
|
1587
|
+
loop {
|
|
1588
|
+
if stop.load(Ordering::Relaxed) {
|
|
1589
|
+
return true;
|
|
1590
|
+
}
|
|
1591
|
+
let config =
|
|
1592
|
+
normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
|
|
1593
|
+
if !config.enabled || config.paused {
|
|
1594
|
+
return true;
|
|
1595
|
+
}
|
|
1596
|
+
let recognized = sample_active_window(&config).1.is_some();
|
|
1597
|
+
if exclusion_transition(true, recognized) == ExclusionTransition::Resume {
|
|
1598
|
+
match resume_after_exclusion(app, &config, stop) {
|
|
1599
|
+
Ok(true) => return false,
|
|
1600
|
+
Ok(false) => {}
|
|
1601
|
+
Err(error) => record_error(app, error),
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
std::thread::sleep(EXCLUSION_POLL_INTERVAL);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/// Returns true only after one replacement producer is installed. A second
|
|
1609
|
+
/// recognition check is made while holding the producer transition lock so a
|
|
1610
|
+
/// config callback, temporary-audio request, or stop cannot race a second start.
|
|
1611
|
+
fn resume_after_exclusion(
|
|
1612
|
+
app: &AppHandle,
|
|
1613
|
+
_config: &ScreenMemoryConfig,
|
|
1614
|
+
stop: &AtomicBool,
|
|
1615
|
+
) -> Result<bool, String> {
|
|
1616
|
+
let state = app.state::<ScreenMemoryState>();
|
|
1617
|
+
let _transition = state.transition.lock().map_err(|error| error.to_string())?;
|
|
1618
|
+
if crate::rewind_capture_suspension::is_active(app) {
|
|
1619
|
+
return Ok(false);
|
|
1620
|
+
}
|
|
1621
|
+
let current = normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
|
|
1622
|
+
let recognized = sample_active_window(¤t).1.is_some();
|
|
1623
|
+
let (exclusion_active, producer_active) = {
|
|
1624
|
+
let runtime = state.inner.lock().map_err(|error| error.to_string())?;
|
|
1625
|
+
(runtime.exclusion_active, runtime.active.is_some())
|
|
1626
|
+
};
|
|
1627
|
+
if !exclusion_resume_allowed(
|
|
1628
|
+
current.enabled,
|
|
1629
|
+
current.paused,
|
|
1630
|
+
stop.load(Ordering::Relaxed),
|
|
1631
|
+
recognized,
|
|
1632
|
+
exclusion_active,
|
|
1633
|
+
producer_active,
|
|
1634
|
+
) {
|
|
1635
|
+
return Ok(producer_active);
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
let mode = effective_capture_mode(app, current.capture_mode);
|
|
1639
|
+
let next = start_new_segment(app, mode)?;
|
|
1640
|
+
if let Err(error) = install_rotated_segment(app, next) {
|
|
1641
|
+
let failed = state
|
|
1642
|
+
.inner
|
|
1643
|
+
.lock()
|
|
1644
|
+
.ok()
|
|
1645
|
+
.and_then(|mut runtime| runtime.active.take());
|
|
1646
|
+
if let Some(failed) = failed {
|
|
1647
|
+
discard_active_segment(failed);
|
|
1648
|
+
}
|
|
1649
|
+
end_rewind_lease(app);
|
|
1650
|
+
return Err(error);
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
let gap = {
|
|
1654
|
+
let mut runtime = state.inner.lock().map_err(|error| error.to_string())?;
|
|
1655
|
+
runtime.exclusion_active = false;
|
|
1656
|
+
runtime.exclusion_gap.take()
|
|
1657
|
+
};
|
|
1658
|
+
if let Some(gap) = gap {
|
|
1659
|
+
record_source_coverage_gap(app, gap, Instant::now());
|
|
1660
|
+
}
|
|
1661
|
+
let _ = app.emit(SCREEN_MEMORY_EVENT, ());
|
|
1662
|
+
Ok(true)
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
fn record_source_coverage_gap(app: &AppHandle, gap: ActiveExclusionGap, ended_at: Instant) {
|
|
1666
|
+
let graph_state = app.state::<CaptureGraphState>();
|
|
1667
|
+
let Ok(mut graph) = graph_state.0.lock() else {
|
|
1668
|
+
return;
|
|
1669
|
+
};
|
|
1670
|
+
for source in gap.sources {
|
|
1671
|
+
if let Err(error) = graph.record_coverage_gap_at(
|
|
1672
|
+
source,
|
|
1673
|
+
CoverageGapReason::PrivacyExclusion,
|
|
1674
|
+
gap.started_at,
|
|
1675
|
+
ended_at,
|
|
1676
|
+
) {
|
|
1677
|
+
eprintln!("[clips-tray] exclusion coverage gap record failed: {error}");
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
fn discard_segment_artifacts(app: &AppHandle, segment_id: &str) -> Result<(), String> {
|
|
1683
|
+
let state = app.state::<ScreenMemoryState>();
|
|
1684
|
+
{
|
|
1685
|
+
let mut runtime = state.inner.lock().map_err(|error| error.to_string())?;
|
|
1686
|
+
runtime.ocr_queue.retain(|job| job.segment.id != segment_id);
|
|
1687
|
+
runtime
|
|
1688
|
+
.ocr_cancelled_segments
|
|
1689
|
+
.insert(segment_id.to_string());
|
|
1690
|
+
runtime
|
|
1691
|
+
.transcript_queue
|
|
1692
|
+
.retain(|job| job.segment.id != segment_id);
|
|
1693
|
+
runtime
|
|
1694
|
+
.transcript_cancelled_segments
|
|
1695
|
+
.insert(segment_id.to_string());
|
|
1696
|
+
}
|
|
1697
|
+
let dir = screen_memory_dir(app)?;
|
|
1698
|
+
let (transcript_rows, transcript_status) = transcript_sidecar_paths(&dir, segment_id);
|
|
1699
|
+
remove_discarded_sidecars(&[
|
|
1700
|
+
segment_metadata_path(app, segment_id)?,
|
|
1701
|
+
segment_ocr_rows_path(app, segment_id)?,
|
|
1702
|
+
segment_ocr_status_path(app, segment_id)?,
|
|
1703
|
+
transcript_rows,
|
|
1704
|
+
transcript_status,
|
|
1705
|
+
])
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
fn remove_discarded_sidecars(paths: &[PathBuf]) -> Result<(), String> {
|
|
1709
|
+
for path in paths {
|
|
1710
|
+
remove_file_if_exists(path)?;
|
|
1711
|
+
}
|
|
1712
|
+
Ok(())
|
|
482
1713
|
}
|
|
483
1714
|
|
|
484
1715
|
fn append_event(app: &AppHandle, event: ScreenMemoryEvent) {
|
|
@@ -501,6 +1732,14 @@ fn append_event_inner(app: &AppHandle, event: &ScreenMemoryEvent) -> Result<(),
|
|
|
501
1732
|
}
|
|
502
1733
|
|
|
503
1734
|
fn rotate_segment(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), String> {
|
|
1735
|
+
let state = app.state::<ScreenMemoryState>();
|
|
1736
|
+
let _transition = state.transition.lock().map_err(|error| error.to_string())?;
|
|
1737
|
+
rotate_segment_inner(app, config)
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
fn rotate_segment_inner(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), String> {
|
|
1741
|
+
let mut config = config.clone();
|
|
1742
|
+
config.capture_mode = effective_capture_mode(app, config.capture_mode);
|
|
504
1743
|
let active = {
|
|
505
1744
|
let state = app.state::<ScreenMemoryState>();
|
|
506
1745
|
let mut guard = state.inner.lock().map_err(|e| e.to_string())?;
|
|
@@ -508,18 +1747,96 @@ fn rotate_segment(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), St
|
|
|
508
1747
|
};
|
|
509
1748
|
|
|
510
1749
|
if let Some(active) = active {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
1750
|
+
if active.exclusion_tainted.load(Ordering::SeqCst) {
|
|
1751
|
+
let gap = active_exclusion_gap(active.started_at, active.capture_mode);
|
|
1752
|
+
let segment_id = active.id.clone();
|
|
1753
|
+
{
|
|
1754
|
+
let state = app.state::<ScreenMemoryState>();
|
|
1755
|
+
let mut runtime = state.inner.lock().map_err(|error| error.to_string())?;
|
|
1756
|
+
runtime.exclusion_active = true;
|
|
1757
|
+
runtime.exclusion_gap = Some(gap);
|
|
1758
|
+
}
|
|
1759
|
+
discard_active_segment(active);
|
|
1760
|
+
discard_segment_artifacts(app, &segment_id)?;
|
|
1761
|
+
end_rewind_lease(app);
|
|
1762
|
+
let _ = app.emit(SCREEN_MEMORY_EVENT, ());
|
|
1763
|
+
return Ok(());
|
|
1764
|
+
}
|
|
1765
|
+
// A normal rotation on the segmented custom SCK backend only fences
|
|
1766
|
+
// the fMP4 writer. The stream and physical producer stay alive; a
|
|
1767
|
+
// mode change must still stop/restart because its source contract has
|
|
1768
|
+
// changed.
|
|
1769
|
+
if active.capture_mode == config.capture_mode {
|
|
1770
|
+
match fence_rotate_segment(app, active) {
|
|
1771
|
+
Ok((segment, next)) => {
|
|
1772
|
+
// The physical stream is already writing the next file.
|
|
1773
|
+
// Do not let an auxiliary metadata/OCR failure drop it.
|
|
1774
|
+
if let Err(error) = write_segment_metadata(app, &segment) {
|
|
1775
|
+
record_error(app, error);
|
|
1776
|
+
} else {
|
|
1777
|
+
enqueue_segment_ocr(app, segment.clone(), config.sample_interval_seconds);
|
|
1778
|
+
if let Err(error) = prune_segments(app, &config) {
|
|
1779
|
+
record_error(app, error);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
return install_rotated_segment(app, next);
|
|
1783
|
+
}
|
|
1784
|
+
Err(active) => {
|
|
1785
|
+
// Non-segmented fallback and failed fences retain the
|
|
1786
|
+
// old stop/start behavior. A failed fence never creates
|
|
1787
|
+
// metadata for a path that has not closed.
|
|
1788
|
+
match finalize_active_segment(app, active) {
|
|
1789
|
+
Ok(segment) => {
|
|
1790
|
+
write_segment_metadata(app, &segment)?;
|
|
1791
|
+
enqueue_segment_ocr(
|
|
1792
|
+
app,
|
|
1793
|
+
segment.clone(),
|
|
1794
|
+
config.sample_interval_seconds,
|
|
1795
|
+
);
|
|
1796
|
+
prune_segments(app, &config)?;
|
|
1797
|
+
}
|
|
1798
|
+
Err(err) => {
|
|
1799
|
+
record_error(app, err);
|
|
1800
|
+
end_rewind_lease(app);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
515
1804
|
}
|
|
516
|
-
|
|
517
|
-
|
|
1805
|
+
} else {
|
|
1806
|
+
// The current graph lease must describe the actual producer source
|
|
1807
|
+
// set. End it before the safe stop/start mode transition;
|
|
1808
|
+
// `install_rotated_segment` starts the replacement lease from the
|
|
1809
|
+
// new effective mode.
|
|
1810
|
+
end_rewind_lease(app);
|
|
1811
|
+
match finalize_active_segment(app, active) {
|
|
1812
|
+
Ok(segment) => {
|
|
1813
|
+
write_segment_metadata(app, &segment)?;
|
|
1814
|
+
enqueue_segment_ocr(app, segment.clone(), config.sample_interval_seconds);
|
|
1815
|
+
prune_segments(app, &config)?;
|
|
1816
|
+
}
|
|
1817
|
+
Err(err) => {
|
|
1818
|
+
record_error(app, err);
|
|
1819
|
+
// The rolling producer has reported an error; its lease must
|
|
1820
|
+
// not imply uninterrupted coverage across the replacement.
|
|
1821
|
+
end_rewind_lease(app);
|
|
1822
|
+
}
|
|
518
1823
|
}
|
|
519
1824
|
}
|
|
520
1825
|
}
|
|
521
1826
|
|
|
522
|
-
let
|
|
1827
|
+
let next = match start_new_segment(app, config.capture_mode) {
|
|
1828
|
+
Ok(next) => next,
|
|
1829
|
+
Err(err) => {
|
|
1830
|
+
end_rewind_lease(app);
|
|
1831
|
+
return Err(err);
|
|
1832
|
+
}
|
|
1833
|
+
};
|
|
1834
|
+
install_rotated_segment(app, next)
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
fn install_rotated_segment(app: &AppHandle, next: ActiveScreenMemorySegment) -> Result<(), String> {
|
|
1838
|
+
let capture_mode = next.capture_mode;
|
|
1839
|
+
let mut next = Some(next);
|
|
523
1840
|
let installed = {
|
|
524
1841
|
let state = app.state::<ScreenMemoryState>();
|
|
525
1842
|
let mut guard = state.inner.lock().map_err(|e| e.to_string())?;
|
|
@@ -542,28 +1859,155 @@ fn rotate_segment(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), St
|
|
|
542
1859
|
}
|
|
543
1860
|
}
|
|
544
1861
|
|
|
1862
|
+
if installed {
|
|
1863
|
+
let needs_lease = app
|
|
1864
|
+
.state::<ScreenMemoryState>()
|
|
1865
|
+
.inner
|
|
1866
|
+
.lock()
|
|
1867
|
+
.ok()
|
|
1868
|
+
.is_some_and(|runtime| runtime.rewind_lease_id.is_none());
|
|
1869
|
+
if needs_lease {
|
|
1870
|
+
start_rewind_lease(app, capture_mode)?;
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
|
|
545
1874
|
let _ = app.emit(SCREEN_MEMORY_EVENT, ());
|
|
546
1875
|
Ok(())
|
|
547
1876
|
}
|
|
548
1877
|
|
|
1878
|
+
/// Returns the fenced metadata plus the replacement logical segment while
|
|
1879
|
+
/// preserving the one physical custom SCK backend. On unsupported/failing
|
|
1880
|
+
/// backends it returns the untouched active segment for stop/start fallback.
|
|
1881
|
+
fn fence_rotate_segment(
|
|
1882
|
+
app: &AppHandle,
|
|
1883
|
+
active: ActiveScreenMemorySegment,
|
|
1884
|
+
) -> Result<(ScreenMemorySegmentMetadata, ActiveScreenMemorySegment), ActiveScreenMemorySegment> {
|
|
1885
|
+
#[cfg(not(target_os = "macos"))]
|
|
1886
|
+
{
|
|
1887
|
+
let _ = app;
|
|
1888
|
+
Err(active)
|
|
1889
|
+
}
|
|
1890
|
+
#[cfg(target_os = "macos")]
|
|
1891
|
+
{
|
|
1892
|
+
let next_id = next_segment_id();
|
|
1893
|
+
let Ok(next_path) = segment_media_path(app, &next_id, "mp4") else {
|
|
1894
|
+
return Err(active);
|
|
1895
|
+
};
|
|
1896
|
+
let Ok(fence) = active.backend.request_fragment_fence(next_path.clone()) else {
|
|
1897
|
+
return Err(active);
|
|
1898
|
+
};
|
|
1899
|
+
let Ok(closed) =
|
|
1900
|
+
NativeFullscreenBackend::await_fragment_fence(fence, Duration::from_secs(15))
|
|
1901
|
+
else {
|
|
1902
|
+
return Err(active);
|
|
1903
|
+
};
|
|
1904
|
+
let boundary = Instant::now();
|
|
1905
|
+
let Ok(segment) = finalize_fenced_segment(&active, closed.path, boundary) else {
|
|
1906
|
+
return Err(active);
|
|
1907
|
+
};
|
|
1908
|
+
let next = ActiveScreenMemorySegment {
|
|
1909
|
+
id: next_id,
|
|
1910
|
+
path: next_path,
|
|
1911
|
+
mime_type: MP4_RECORDING_MIME_TYPE,
|
|
1912
|
+
backend: active.backend,
|
|
1913
|
+
started_at: boundary,
|
|
1914
|
+
started_at_iso: now_iso(),
|
|
1915
|
+
width: active.width,
|
|
1916
|
+
height: active.height,
|
|
1917
|
+
capture_mode: active.capture_mode,
|
|
1918
|
+
exclusion_tainted: Arc::new(AtomicBool::new(false)),
|
|
1919
|
+
graph_epoch_id: active.graph_epoch_id,
|
|
1920
|
+
graph_started_elapsed_ms: graph_elapsed_ms(app, boundary),
|
|
1921
|
+
};
|
|
1922
|
+
Ok((segment, next))
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
|
|
549
1926
|
fn stop_active_segment(app: &AppHandle) -> Result<Option<ScreenMemorySegmentMetadata>, String> {
|
|
550
|
-
let
|
|
1927
|
+
let state = app.state::<ScreenMemoryState>();
|
|
1928
|
+
let _transition = state.transition.lock().map_err(|error| error.to_string())?;
|
|
1929
|
+
stop_active_segment_inner(app)
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
/// Temporarily yields the one physical producer to an ordinary recorder while
|
|
1933
|
+
/// leaving the persisted Rewind enabled/paused setting untouched. An active
|
|
1934
|
+
/// privacy exclusion is already physically suspended and remains authoritative.
|
|
1935
|
+
pub(crate) fn suspend_physical_capture(app: &AppHandle) -> Result<(), String> {
|
|
1936
|
+
let state = app.state::<ScreenMemoryState>();
|
|
1937
|
+
let _transition = state.transition.lock().map_err(|error| error.to_string())?;
|
|
1938
|
+
let exclusion_active = state
|
|
1939
|
+
.inner
|
|
1940
|
+
.lock()
|
|
1941
|
+
.map_err(|error| error.to_string())?
|
|
1942
|
+
.exclusion_active;
|
|
1943
|
+
if !exclusion_active {
|
|
1944
|
+
stop_active_segment_inner(app)?;
|
|
1945
|
+
}
|
|
1946
|
+
Ok(())
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
/// Restores Rewind after an ordinary recorder releases the producer. This is a
|
|
1950
|
+
/// no-op while disabled, paused, or privacy-excluded and therefore cannot start
|
|
1951
|
+
/// a competing producer or bypass the exclusion poller.
|
|
1952
|
+
pub(crate) fn resume_physical_capture(app: &AppHandle) -> Result<(), String> {
|
|
1953
|
+
let state = app.state::<ScreenMemoryState>();
|
|
1954
|
+
let _transition = state.transition.lock().map_err(|error| error.to_string())?;
|
|
1955
|
+
let config = normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
|
|
1956
|
+
let exclusion_active = state
|
|
1957
|
+
.inner
|
|
1958
|
+
.lock()
|
|
1959
|
+
.map_err(|error| error.to_string())?
|
|
1960
|
+
.exclusion_active;
|
|
1961
|
+
if config.enabled && !config.paused && !exclusion_active {
|
|
1962
|
+
ensure_running_inner(app, &config)?;
|
|
1963
|
+
}
|
|
1964
|
+
Ok(())
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
fn stop_active_segment_inner(
|
|
1968
|
+
app: &AppHandle,
|
|
1969
|
+
) -> Result<Option<ScreenMemorySegmentMetadata>, String> {
|
|
1970
|
+
let (active, exclusion_gap) = {
|
|
551
1971
|
let state = app.state::<ScreenMemoryState>();
|
|
552
1972
|
let mut guard = state.inner.lock().map_err(|e| e.to_string())?;
|
|
553
1973
|
if let Some(stop) = guard.worker_stop.take() {
|
|
554
1974
|
stop.store(true, Ordering::Relaxed);
|
|
555
1975
|
}
|
|
556
|
-
guard.
|
|
1976
|
+
guard.exclusion_active = false;
|
|
1977
|
+
(guard.active.take(), guard.exclusion_gap.take())
|
|
557
1978
|
};
|
|
558
1979
|
|
|
1980
|
+
if let Some(gap) = exclusion_gap {
|
|
1981
|
+
record_source_coverage_gap(app, gap, Instant::now());
|
|
1982
|
+
}
|
|
1983
|
+
|
|
559
1984
|
let Some(active) = active else {
|
|
1985
|
+
end_rewind_lease(app);
|
|
1986
|
+
let _ = app.emit(SCREEN_MEMORY_EVENT, ());
|
|
560
1987
|
return Ok(None);
|
|
561
1988
|
};
|
|
562
1989
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1990
|
+
if active.exclusion_tainted.load(Ordering::SeqCst) {
|
|
1991
|
+
let gap = active_exclusion_gap(active.started_at, active.capture_mode);
|
|
1992
|
+
let segment_id = active.id.clone();
|
|
1993
|
+
discard_active_segment(active);
|
|
1994
|
+
discard_segment_artifacts(app, &segment_id)?;
|
|
1995
|
+
record_source_coverage_gap(app, gap, Instant::now());
|
|
1996
|
+
end_rewind_lease(app);
|
|
1997
|
+
let _ = app.emit(SCREEN_MEMORY_EVENT, ());
|
|
1998
|
+
return Ok(None);
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
let result = finalize_active_segment(app, active).and_then(|segment| {
|
|
2002
|
+
write_segment_metadata(app, &segment)?;
|
|
2003
|
+
let config =
|
|
2004
|
+
normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
|
|
2005
|
+
enqueue_segment_ocr(app, segment.clone(), config.sample_interval_seconds);
|
|
2006
|
+
prune_segments(app, &config)?;
|
|
2007
|
+
Ok(segment)
|
|
2008
|
+
});
|
|
2009
|
+
end_rewind_lease(app);
|
|
2010
|
+
let segment = result?;
|
|
567
2011
|
let _ = app.emit(SCREEN_MEMORY_EVENT, ());
|
|
568
2012
|
Ok(Some(segment))
|
|
569
2013
|
}
|
|
@@ -584,7 +2028,10 @@ fn stop_active_segment_if_matches(app: &AppHandle, segment_id: &str) -> Result<(
|
|
|
584
2028
|
Ok(())
|
|
585
2029
|
}
|
|
586
2030
|
|
|
587
|
-
fn start_new_segment(
|
|
2031
|
+
fn start_new_segment(
|
|
2032
|
+
app: &AppHandle,
|
|
2033
|
+
capture_mode: RewindCaptureMode,
|
|
2034
|
+
) -> Result<ActiveScreenMemorySegment, String> {
|
|
588
2035
|
#[cfg(not(target_os = "macos"))]
|
|
589
2036
|
{
|
|
590
2037
|
let _ = app;
|
|
@@ -610,10 +2057,12 @@ fn start_new_segment(app: &AppHandle) -> Result<ActiveScreenMemorySegment, Strin
|
|
|
610
2057
|
let _ = std::fs::remove_file(&mp4_path);
|
|
611
2058
|
let (fallback_width, fallback_height) = native_screen::primary_monitor_size(app);
|
|
612
2059
|
|
|
613
|
-
|
|
2060
|
+
let visuals_audio = capture_mode == RewindCaptureMode::VisualsAudio;
|
|
2061
|
+
match native_screen::start_segmented_custom_screencapturekit_backend_at(
|
|
2062
|
+
app,
|
|
614
2063
|
&mp4_path,
|
|
615
|
-
|
|
616
|
-
|
|
2064
|
+
visuals_audio,
|
|
2065
|
+
visuals_audio,
|
|
617
2066
|
None,
|
|
618
2067
|
None,
|
|
619
2068
|
target_display_id,
|
|
@@ -630,9 +2079,20 @@ fn start_new_segment(app: &AppHandle) -> Result<ActiveScreenMemorySegment, Strin
|
|
|
630
2079
|
started_at_iso: now_iso(),
|
|
631
2080
|
width: width.or(fallback_width),
|
|
632
2081
|
height: height.or(fallback_height),
|
|
2082
|
+
capture_mode,
|
|
2083
|
+
exclusion_tainted: Arc::new(AtomicBool::new(false)),
|
|
2084
|
+
graph_epoch_id: graph_epoch_id(app),
|
|
2085
|
+
graph_started_elapsed_ms: graph_elapsed_ms(app, Instant::now()),
|
|
633
2086
|
});
|
|
634
2087
|
}
|
|
635
2088
|
Err(sck_err) => {
|
|
2089
|
+
if visuals_audio {
|
|
2090
|
+
record_requested_source_gaps(app, capture_mode);
|
|
2091
|
+
let _ = std::fs::remove_file(&mp4_path);
|
|
2092
|
+
return Err(format!(
|
|
2093
|
+
"Screen Memory visuals + audio could not start its required ScreenCaptureKit screen, system-audio, and microphone sources: {sck_err}"
|
|
2094
|
+
));
|
|
2095
|
+
}
|
|
636
2096
|
eprintln!(
|
|
637
2097
|
"[clips-tray] Screen Memory ScreenCaptureKit unavailable; falling back to screencapture: {sck_err}"
|
|
638
2098
|
);
|
|
@@ -658,22 +2118,109 @@ fn start_new_segment(app: &AppHandle) -> Result<ActiveScreenMemorySegment, Strin
|
|
|
658
2118
|
started_at_iso: now_iso(),
|
|
659
2119
|
width: width.or(fallback_width),
|
|
660
2120
|
height: height.or(fallback_height),
|
|
2121
|
+
capture_mode,
|
|
2122
|
+
exclusion_tainted: Arc::new(AtomicBool::new(false)),
|
|
2123
|
+
graph_epoch_id: graph_epoch_id(app),
|
|
2124
|
+
graph_started_elapsed_ms: graph_elapsed_ms(app, Instant::now()),
|
|
661
2125
|
})
|
|
662
2126
|
}
|
|
663
2127
|
}
|
|
664
2128
|
|
|
2129
|
+
fn requested_sources(capture_mode: RewindCaptureMode) -> Vec<CaptureSource> {
|
|
2130
|
+
match capture_mode {
|
|
2131
|
+
RewindCaptureMode::Visuals => vec![CaptureSource::Screen],
|
|
2132
|
+
RewindCaptureMode::VisualsAudio => vec![
|
|
2133
|
+
CaptureSource::Screen,
|
|
2134
|
+
CaptureSource::SystemAudio,
|
|
2135
|
+
CaptureSource::Microphone,
|
|
2136
|
+
],
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
fn start_rewind_lease(app: &AppHandle, capture_mode: RewindCaptureMode) -> Result<(), String> {
|
|
2141
|
+
let lease = {
|
|
2142
|
+
let graph = app.state::<CaptureGraphState>();
|
|
2143
|
+
let mut graph = graph.0.lock().map_err(|err| err.to_string())?;
|
|
2144
|
+
graph
|
|
2145
|
+
.start_consumer(CaptureConsumer::Rewind, requested_sources(capture_mode))
|
|
2146
|
+
.map_err(capture_graph_error)?
|
|
2147
|
+
};
|
|
2148
|
+
let state = app.state::<ScreenMemoryState>();
|
|
2149
|
+
let mut runtime = state.inner.lock().map_err(|err| err.to_string())?;
|
|
2150
|
+
runtime.rewind_lease_id = Some(lease.id);
|
|
2151
|
+
Ok(())
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
fn end_rewind_lease(app: &AppHandle) {
|
|
2155
|
+
let lease_id = app
|
|
2156
|
+
.state::<ScreenMemoryState>()
|
|
2157
|
+
.inner
|
|
2158
|
+
.lock()
|
|
2159
|
+
.ok()
|
|
2160
|
+
.and_then(|mut runtime| runtime.rewind_lease_id.take());
|
|
2161
|
+
if let Some(lease_id) = lease_id {
|
|
2162
|
+
let graph_state = app.state::<CaptureGraphState>();
|
|
2163
|
+
if let Ok(mut graph) = graph_state.0.lock() {
|
|
2164
|
+
if let Err(err) = graph.end_consumer(&lease_id) {
|
|
2165
|
+
eprintln!("[clips-tray] Screen Memory Rewind lease close failed: {err}");
|
|
2166
|
+
}
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
fn record_requested_source_gaps(app: &AppHandle, capture_mode: RewindCaptureMode) {
|
|
2172
|
+
let now = Instant::now();
|
|
2173
|
+
let graph_state = app.state::<CaptureGraphState>();
|
|
2174
|
+
let Ok(mut graph) = graph_state.0.lock() else {
|
|
2175
|
+
return;
|
|
2176
|
+
};
|
|
2177
|
+
for source in requested_sources(capture_mode) {
|
|
2178
|
+
if let Err(err) =
|
|
2179
|
+
graph.record_coverage_gap_at(source, CoverageGapReason::ProducerUnavailable, now, now)
|
|
2180
|
+
{
|
|
2181
|
+
eprintln!("[clips-tray] Screen Memory coverage gap record failed: {err}");
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
fn capture_graph_error(error: CaptureGraphError) -> String {
|
|
2187
|
+
format!("Screen Memory capture graph error: {error}")
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
fn graph_elapsed_ms(app: &AppHandle, instant: Instant) -> u64 {
|
|
2191
|
+
app.try_state::<CaptureGraphState>()
|
|
2192
|
+
.and_then(|state| {
|
|
2193
|
+
state
|
|
2194
|
+
.0
|
|
2195
|
+
.lock()
|
|
2196
|
+
.ok()
|
|
2197
|
+
.and_then(|graph| graph.status_at(instant).ok())
|
|
2198
|
+
})
|
|
2199
|
+
.map(|status| status.graph_elapsed_ms)
|
|
2200
|
+
.unwrap_or_default()
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
fn graph_epoch_id(app: &AppHandle) -> String {
|
|
2204
|
+
app.try_state::<CaptureGraphState>()
|
|
2205
|
+
.and_then(|state| state.0.lock().ok().map(|graph| graph.epoch_id()))
|
|
2206
|
+
.unwrap_or_default()
|
|
2207
|
+
}
|
|
2208
|
+
|
|
665
2209
|
fn discard_active_segment(mut active: ActiveScreenMemorySegment) {
|
|
666
2210
|
let _ = native_screen::stop_native_recording(&mut active.backend, false);
|
|
667
|
-
let _ = std::fs::remove_file(active.path);
|
|
2211
|
+
let _ = std::fs::remove_file(&active.path);
|
|
2212
|
+
let _ = std::fs::remove_file(audio_sidecar_path(&active.path, "system"));
|
|
2213
|
+
let _ = std::fs::remove_file(audio_sidecar_path(&active.path, "microphone"));
|
|
668
2214
|
}
|
|
669
2215
|
|
|
670
2216
|
fn finalize_active_segment(
|
|
2217
|
+
app: &AppHandle,
|
|
671
2218
|
mut active: ActiveScreenMemorySegment,
|
|
672
2219
|
) -> Result<ScreenMemorySegmentMetadata, String> {
|
|
673
2220
|
let stop_error = native_screen::stop_native_recording(&mut active.backend, true).err();
|
|
674
2221
|
let ended_at = now_iso();
|
|
675
2222
|
let duration_ms = active.started_at.elapsed().as_millis();
|
|
676
|
-
let
|
|
2223
|
+
let video_bytes = std::fs::metadata(&active.path)
|
|
677
2224
|
.map_err(|e| {
|
|
678
2225
|
let suffix = stop_error
|
|
679
2226
|
.as_ref()
|
|
@@ -682,10 +2229,20 @@ fn finalize_active_segment(
|
|
|
682
2229
|
format!("Screen Memory segment missing{suffix}: {e}")
|
|
683
2230
|
})?
|
|
684
2231
|
.len();
|
|
685
|
-
if
|
|
2232
|
+
if video_bytes == 0 {
|
|
686
2233
|
let _ = std::fs::remove_file(&active.path);
|
|
687
|
-
return Err(
|
|
2234
|
+
return Err(stop_error.unwrap_or_else(|| {
|
|
2235
|
+
"Screen Memory segment produced an empty file with no more specific backend error."
|
|
2236
|
+
.to_string()
|
|
2237
|
+
}));
|
|
688
2238
|
}
|
|
2239
|
+
let (system_audio_path, microphone_path) =
|
|
2240
|
+
existing_audio_sidecars(&active.path, active.capture_mode);
|
|
2241
|
+
let bytes = media_unit_bytes(
|
|
2242
|
+
&active.path,
|
|
2243
|
+
system_audio_path.as_deref(),
|
|
2244
|
+
microphone_path.as_deref(),
|
|
2245
|
+
);
|
|
689
2246
|
|
|
690
2247
|
let corrupt = if active.mime_type == MP4_RECORDING_MIME_TYPE
|
|
691
2248
|
|| active.mime_type == QUICKTIME_RECORDING_MIME_TYPE
|
|
@@ -718,11 +2275,476 @@ fn finalize_active_segment(
|
|
|
718
2275
|
width: active.width,
|
|
719
2276
|
height: active.height,
|
|
720
2277
|
bytes,
|
|
2278
|
+
system_audio_path,
|
|
2279
|
+
microphone_path,
|
|
721
2280
|
corrupt,
|
|
722
2281
|
error,
|
|
2282
|
+
capture_mode: active.capture_mode,
|
|
2283
|
+
exclusion_tainted: active.exclusion_tainted.load(Ordering::Relaxed),
|
|
2284
|
+
graph_epoch_id: Some(active.graph_epoch_id),
|
|
2285
|
+
graph_started_elapsed_ms: active.graph_started_elapsed_ms,
|
|
2286
|
+
graph_ended_elapsed_ms: graph_elapsed_ms(app, Instant::now()),
|
|
723
2287
|
})
|
|
724
2288
|
}
|
|
725
2289
|
|
|
2290
|
+
fn finalize_fenced_segment(
|
|
2291
|
+
active: &ActiveScreenMemorySegment,
|
|
2292
|
+
closed_path: PathBuf,
|
|
2293
|
+
ended_at: Instant,
|
|
2294
|
+
) -> Result<ScreenMemorySegmentMetadata, String> {
|
|
2295
|
+
let video_bytes = std::fs::metadata(&closed_path)
|
|
2296
|
+
.map_err(|error| format!("fenced Screen Memory segment missing: {error}"))?
|
|
2297
|
+
.len();
|
|
2298
|
+
if video_bytes == 0 {
|
|
2299
|
+
return Err("fenced Screen Memory segment produced an empty file.".to_string());
|
|
2300
|
+
}
|
|
2301
|
+
let (system_audio_path, microphone_path) =
|
|
2302
|
+
existing_audio_sidecars(&closed_path, active.capture_mode);
|
|
2303
|
+
let bytes = media_unit_bytes(
|
|
2304
|
+
&closed_path,
|
|
2305
|
+
system_audio_path.as_deref(),
|
|
2306
|
+
microphone_path.as_deref(),
|
|
2307
|
+
);
|
|
2308
|
+
Ok(ScreenMemorySegmentMetadata {
|
|
2309
|
+
id: active.id.clone(),
|
|
2310
|
+
file_name: closed_path
|
|
2311
|
+
.file_name()
|
|
2312
|
+
.map(|value| value.to_string_lossy().to_string())
|
|
2313
|
+
.unwrap_or_default(),
|
|
2314
|
+
path: closed_path.clone(),
|
|
2315
|
+
mime_type: MP4_RECORDING_MIME_TYPE.to_string(),
|
|
2316
|
+
started_at: active.started_at_iso.clone(),
|
|
2317
|
+
ended_at: now_iso(),
|
|
2318
|
+
duration_ms: active.started_at.elapsed().as_millis(),
|
|
2319
|
+
width: active.width,
|
|
2320
|
+
height: active.height,
|
|
2321
|
+
bytes,
|
|
2322
|
+
system_audio_path,
|
|
2323
|
+
microphone_path,
|
|
2324
|
+
// fMP4 finalization is asynchronous. A fence proves a fragment
|
|
2325
|
+
// boundary, not that every player accepts the logical file yet.
|
|
2326
|
+
corrupt: native_screen::mp4_has_moov(&closed_path) == Some(false),
|
|
2327
|
+
error: None,
|
|
2328
|
+
capture_mode: active.capture_mode,
|
|
2329
|
+
exclusion_tainted: active.exclusion_tainted.load(Ordering::Relaxed),
|
|
2330
|
+
graph_epoch_id: Some(active.graph_epoch_id.clone()),
|
|
2331
|
+
graph_started_elapsed_ms: active.graph_started_elapsed_ms,
|
|
2332
|
+
graph_ended_elapsed_ms: active
|
|
2333
|
+
.graph_started_elapsed_ms
|
|
2334
|
+
.saturating_add(ended_at.duration_since(active.started_at).as_millis() as u64),
|
|
2335
|
+
})
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
fn enqueue_segment_ocr(
|
|
2339
|
+
app: &AppHandle,
|
|
2340
|
+
segment: ScreenMemorySegmentMetadata,
|
|
2341
|
+
sample_interval_seconds: u64,
|
|
2342
|
+
) {
|
|
2343
|
+
if !segment_derivatives_eligible(&segment) {
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
if let Err(error) = write_ocr_status(
|
|
2347
|
+
app,
|
|
2348
|
+
&segment.id,
|
|
2349
|
+
&crate::screen_memory_ocr::ScreenMemoryOcrIndexStatus::pending(),
|
|
2350
|
+
) {
|
|
2351
|
+
record_error(app, error);
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
let should_start = app.try_state::<ScreenMemoryState>().is_some_and(|state| {
|
|
2355
|
+
state
|
|
2356
|
+
.inner
|
|
2357
|
+
.lock()
|
|
2358
|
+
.map(|mut runtime| {
|
|
2359
|
+
if runtime.ocr_cancelled_segments.contains(&segment.id) {
|
|
2360
|
+
return false;
|
|
2361
|
+
}
|
|
2362
|
+
runtime.ocr_queue.push_back(ScreenMemoryOcrJob {
|
|
2363
|
+
segment,
|
|
2364
|
+
sample_interval_seconds,
|
|
2365
|
+
});
|
|
2366
|
+
if runtime.ocr_worker_running {
|
|
2367
|
+
false
|
|
2368
|
+
} else {
|
|
2369
|
+
runtime.ocr_worker_running = true;
|
|
2370
|
+
true
|
|
2371
|
+
}
|
|
2372
|
+
})
|
|
2373
|
+
.unwrap_or(false)
|
|
2374
|
+
});
|
|
2375
|
+
if should_start {
|
|
2376
|
+
let app = app.clone();
|
|
2377
|
+
std::thread::spawn(move || run_ocr_queue(app));
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
fn run_ocr_queue(app: AppHandle) {
|
|
2382
|
+
loop {
|
|
2383
|
+
let Some(state) = app.try_state::<ScreenMemoryState>() else {
|
|
2384
|
+
return;
|
|
2385
|
+
};
|
|
2386
|
+
let job = {
|
|
2387
|
+
let Ok(mut runtime) = state.inner.lock() else {
|
|
2388
|
+
return;
|
|
2389
|
+
};
|
|
2390
|
+
let Some(job) = take_next_ocr_job(&mut runtime) else {
|
|
2391
|
+
return;
|
|
2392
|
+
};
|
|
2393
|
+
job
|
|
2394
|
+
};
|
|
2395
|
+
let state = app.state::<ScreenMemoryState>();
|
|
2396
|
+
let Ok(_indexing) = state.indexing.lock() else {
|
|
2397
|
+
return;
|
|
2398
|
+
};
|
|
2399
|
+
let _ = run_ocr_job(&app, job);
|
|
2400
|
+
if let Ok(mut runtime) = state.inner.lock() {
|
|
2401
|
+
if let Some(segment_id) = runtime.ocr_active_segment.take() {
|
|
2402
|
+
runtime.ocr_cancelled_segments.remove(&segment_id);
|
|
2403
|
+
}
|
|
2404
|
+
};
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
fn take_next_ocr_job(runtime: &mut ScreenMemoryRuntime) -> Option<ScreenMemoryOcrJob> {
|
|
2409
|
+
let job = runtime.ocr_queue.pop_front();
|
|
2410
|
+
if let Some(job) = job.as_ref() {
|
|
2411
|
+
runtime.ocr_active_segment = Some(job.segment.id.clone());
|
|
2412
|
+
} else {
|
|
2413
|
+
runtime.ocr_worker_running = false;
|
|
2414
|
+
}
|
|
2415
|
+
job
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
fn write_ocr_status(
|
|
2419
|
+
app: &AppHandle,
|
|
2420
|
+
segment_id: &str,
|
|
2421
|
+
status: &crate::screen_memory_ocr::ScreenMemoryOcrIndexStatus,
|
|
2422
|
+
) -> Result<(), String> {
|
|
2423
|
+
let data = serde_json::to_vec_pretty(status)
|
|
2424
|
+
.map_err(|err| format!("screen memory OCR status encode failed: {err}"))?;
|
|
2425
|
+
let state = app.state::<ScreenMemoryState>();
|
|
2426
|
+
let runtime = state.inner.lock().map_err(|error| error.to_string())?;
|
|
2427
|
+
if runtime.ocr_cancelled_segments.contains(segment_id) {
|
|
2428
|
+
return Ok(());
|
|
2429
|
+
}
|
|
2430
|
+
std::fs::write(segment_ocr_status_path(app, segment_id)?, data)
|
|
2431
|
+
.map_err(|err| format!("screen memory OCR status write failed: {err}"))
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
fn write_ocr_rows(
|
|
2435
|
+
app: &AppHandle,
|
|
2436
|
+
segment_id: &str,
|
|
2437
|
+
rows: &[crate::screen_memory_ocr::ScreenMemoryOcrRow],
|
|
2438
|
+
) -> Result<(), String> {
|
|
2439
|
+
let state = app.state::<ScreenMemoryState>();
|
|
2440
|
+
let runtime = state.inner.lock().map_err(|error| error.to_string())?;
|
|
2441
|
+
if runtime.ocr_cancelled_segments.contains(segment_id) {
|
|
2442
|
+
return Ok(());
|
|
2443
|
+
}
|
|
2444
|
+
let rows_path = segment_ocr_rows_path(app, segment_id)?;
|
|
2445
|
+
let mut file = File::create(rows_path)
|
|
2446
|
+
.map_err(|err| format!("screen memory OCR rows open failed: {err}"))?;
|
|
2447
|
+
for row in rows {
|
|
2448
|
+
serde_json::to_writer(&mut file, row)
|
|
2449
|
+
.map_err(|err| format!("screen memory OCR row encode failed: {err}"))?;
|
|
2450
|
+
file.write_all(b"\n")
|
|
2451
|
+
.map_err(|err| format!("screen memory OCR row write failed: {err}"))?;
|
|
2452
|
+
}
|
|
2453
|
+
Ok(())
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
fn run_ocr_job(app: &AppHandle, job: ScreenMemoryOcrJob) -> Result<(), String> {
|
|
2457
|
+
let started_at = now_iso();
|
|
2458
|
+
if !job.segment.path.exists() {
|
|
2459
|
+
return Ok(());
|
|
2460
|
+
}
|
|
2461
|
+
if job.segment.exclusion_tainted {
|
|
2462
|
+
return write_ocr_status(
|
|
2463
|
+
app,
|
|
2464
|
+
&job.segment.id,
|
|
2465
|
+
&crate::screen_memory_ocr::ScreenMemoryOcrIndexStatus::skipped(now_iso()),
|
|
2466
|
+
);
|
|
2467
|
+
}
|
|
2468
|
+
if job.segment.corrupt || job.segment.error.is_some() {
|
|
2469
|
+
return write_ocr_status(
|
|
2470
|
+
app,
|
|
2471
|
+
&job.segment.id,
|
|
2472
|
+
&crate::screen_memory_ocr::ScreenMemoryOcrIndexStatus::skipped(now_iso()),
|
|
2473
|
+
);
|
|
2474
|
+
}
|
|
2475
|
+
write_ocr_status(
|
|
2476
|
+
app,
|
|
2477
|
+
&job.segment.id,
|
|
2478
|
+
&crate::screen_memory_ocr::ScreenMemoryOcrIndexStatus::indexing(started_at.clone()),
|
|
2479
|
+
)?;
|
|
2480
|
+
let attempted = crate::screen_memory_ocr::requested_frame_offsets(
|
|
2481
|
+
job.segment.duration_ms as u64,
|
|
2482
|
+
job.sample_interval_seconds,
|
|
2483
|
+
)
|
|
2484
|
+
.len();
|
|
2485
|
+
match crate::screen_memory_ocr::recognize_segment(
|
|
2486
|
+
&job.segment.path,
|
|
2487
|
+
&job.segment.id,
|
|
2488
|
+
DateTime::parse_from_rfc3339(&job.segment.started_at)
|
|
2489
|
+
.map_err(|_| "segment start timestamp unavailable".to_string())?
|
|
2490
|
+
.with_timezone(&Utc),
|
|
2491
|
+
job.sample_interval_seconds,
|
|
2492
|
+
) {
|
|
2493
|
+
Ok(rows) => {
|
|
2494
|
+
write_ocr_rows(app, &job.segment.id, &rows)?;
|
|
2495
|
+
let result = write_ocr_status(
|
|
2496
|
+
app,
|
|
2497
|
+
&job.segment.id,
|
|
2498
|
+
&crate::screen_memory_ocr::ScreenMemoryOcrIndexStatus::ready(
|
|
2499
|
+
attempted,
|
|
2500
|
+
rows.len(),
|
|
2501
|
+
started_at,
|
|
2502
|
+
now_iso(),
|
|
2503
|
+
),
|
|
2504
|
+
);
|
|
2505
|
+
let _ = refresh_rewind_chapters(app);
|
|
2506
|
+
result
|
|
2507
|
+
}
|
|
2508
|
+
Err(error) => {
|
|
2509
|
+
let result = write_ocr_status(
|
|
2510
|
+
app,
|
|
2511
|
+
&job.segment.id,
|
|
2512
|
+
&crate::screen_memory_ocr::ScreenMemoryOcrIndexStatus::failed(
|
|
2513
|
+
attempted,
|
|
2514
|
+
0,
|
|
2515
|
+
started_at,
|
|
2516
|
+
now_iso(),
|
|
2517
|
+
error,
|
|
2518
|
+
),
|
|
2519
|
+
);
|
|
2520
|
+
let _ = refresh_rewind_chapters(app);
|
|
2521
|
+
result
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
fn enqueue_segment_transcript(app: &AppHandle, segment: ScreenMemorySegmentMetadata) {
|
|
2527
|
+
use crate::screen_memory_transcript::ScreenMemoryTranscriptStatus;
|
|
2528
|
+
|
|
2529
|
+
if !segment_transcript_eligible(&segment) {
|
|
2530
|
+
if let Err(error) = write_transcript_status(
|
|
2531
|
+
app,
|
|
2532
|
+
&segment.id,
|
|
2533
|
+
&ScreenMemoryTranscriptStatus::skipped(now_iso()),
|
|
2534
|
+
) {
|
|
2535
|
+
record_error(app, error);
|
|
2536
|
+
}
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
if let Err(error) =
|
|
2540
|
+
write_transcript_status(app, &segment.id, &ScreenMemoryTranscriptStatus::pending())
|
|
2541
|
+
{
|
|
2542
|
+
record_error(app, error);
|
|
2543
|
+
return;
|
|
2544
|
+
}
|
|
2545
|
+
let should_start = app.try_state::<ScreenMemoryState>().is_some_and(|state| {
|
|
2546
|
+
state
|
|
2547
|
+
.inner
|
|
2548
|
+
.lock()
|
|
2549
|
+
.map(|mut runtime| {
|
|
2550
|
+
if runtime.transcript_cancelled_segments.contains(&segment.id) {
|
|
2551
|
+
return false;
|
|
2552
|
+
}
|
|
2553
|
+
runtime
|
|
2554
|
+
.transcript_queue
|
|
2555
|
+
.push_back(ScreenMemoryTranscriptJob { segment });
|
|
2556
|
+
if runtime.transcript_worker_running {
|
|
2557
|
+
false
|
|
2558
|
+
} else {
|
|
2559
|
+
runtime.transcript_worker_running = true;
|
|
2560
|
+
true
|
|
2561
|
+
}
|
|
2562
|
+
})
|
|
2563
|
+
.unwrap_or(false)
|
|
2564
|
+
});
|
|
2565
|
+
if should_start {
|
|
2566
|
+
let app = app.clone();
|
|
2567
|
+
std::thread::spawn(move || run_transcript_queue(app));
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
fn segment_derivatives_eligible(segment: &ScreenMemorySegmentMetadata) -> bool {
|
|
2572
|
+
!segment.exclusion_tainted
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
fn segment_transcript_eligible(segment: &ScreenMemorySegmentMetadata) -> bool {
|
|
2576
|
+
segment.capture_mode == RewindCaptureMode::VisualsAudio
|
|
2577
|
+
&& segment_derivatives_eligible(segment)
|
|
2578
|
+
&& !segment.corrupt
|
|
2579
|
+
&& segment.error.is_none()
|
|
2580
|
+
&& segment
|
|
2581
|
+
.system_audio_path
|
|
2582
|
+
.as_ref()
|
|
2583
|
+
.is_some_and(|path| path.exists())
|
|
2584
|
+
&& segment
|
|
2585
|
+
.microphone_path
|
|
2586
|
+
.as_ref()
|
|
2587
|
+
.is_some_and(|path| path.exists())
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
fn run_transcript_queue(app: AppHandle) {
|
|
2591
|
+
loop {
|
|
2592
|
+
let Some(state) = app.try_state::<ScreenMemoryState>() else {
|
|
2593
|
+
return;
|
|
2594
|
+
};
|
|
2595
|
+
let job = {
|
|
2596
|
+
let Ok(mut runtime) = state.inner.lock() else {
|
|
2597
|
+
return;
|
|
2598
|
+
};
|
|
2599
|
+
let Some(job) = take_next_transcript_job(&mut runtime) else {
|
|
2600
|
+
return;
|
|
2601
|
+
};
|
|
2602
|
+
job
|
|
2603
|
+
};
|
|
2604
|
+
let state = app.state::<ScreenMemoryState>();
|
|
2605
|
+
let Ok(_indexing) = state.indexing.lock() else {
|
|
2606
|
+
return;
|
|
2607
|
+
};
|
|
2608
|
+
let _ = run_transcript_job(&app, job);
|
|
2609
|
+
if let Ok(mut runtime) = state.inner.lock() {
|
|
2610
|
+
if let Some(segment_id) = runtime.transcript_active_segment.take() {
|
|
2611
|
+
runtime.transcript_cancelled_segments.remove(&segment_id);
|
|
2612
|
+
}
|
|
2613
|
+
};
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
fn take_next_transcript_job(
|
|
2618
|
+
runtime: &mut ScreenMemoryRuntime,
|
|
2619
|
+
) -> Option<ScreenMemoryTranscriptJob> {
|
|
2620
|
+
let job = runtime.transcript_queue.pop_front();
|
|
2621
|
+
if let Some(job) = job.as_ref() {
|
|
2622
|
+
runtime.transcript_active_segment = Some(job.segment.id.clone());
|
|
2623
|
+
} else {
|
|
2624
|
+
runtime.transcript_worker_running = false;
|
|
2625
|
+
}
|
|
2626
|
+
job
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
fn write_transcript_status(
|
|
2630
|
+
app: &AppHandle,
|
|
2631
|
+
segment_id: &str,
|
|
2632
|
+
status: &crate::screen_memory_transcript::ScreenMemoryTranscriptStatus,
|
|
2633
|
+
) -> Result<(), String> {
|
|
2634
|
+
let data = serde_json::to_vec_pretty(status)
|
|
2635
|
+
.map_err(|err| format!("screen memory transcript status encode failed: {err}"))?;
|
|
2636
|
+
let state = app.state::<ScreenMemoryState>();
|
|
2637
|
+
let runtime = state.inner.lock().map_err(|error| error.to_string())?;
|
|
2638
|
+
if runtime.transcript_cancelled_segments.contains(segment_id) {
|
|
2639
|
+
return Ok(());
|
|
2640
|
+
}
|
|
2641
|
+
std::fs::write(segment_transcript_status_path(app, segment_id)?, data)
|
|
2642
|
+
.map_err(|err| format!("screen memory transcript status write failed: {err}"))
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
fn write_transcript_rows(
|
|
2646
|
+
app: &AppHandle,
|
|
2647
|
+
segment_id: &str,
|
|
2648
|
+
rows: &[crate::screen_memory_transcript::ScreenMemoryTranscriptRow],
|
|
2649
|
+
) -> Result<(), String> {
|
|
2650
|
+
let state = app.state::<ScreenMemoryState>();
|
|
2651
|
+
let runtime = state.inner.lock().map_err(|error| error.to_string())?;
|
|
2652
|
+
if runtime.transcript_cancelled_segments.contains(segment_id) {
|
|
2653
|
+
return Ok(());
|
|
2654
|
+
}
|
|
2655
|
+
let rows_path = segment_transcript_rows_path(app, segment_id)?;
|
|
2656
|
+
let mut file = File::create(rows_path)
|
|
2657
|
+
.map_err(|err| format!("screen memory transcript rows open failed: {err}"))?;
|
|
2658
|
+
for row in rows {
|
|
2659
|
+
serde_json::to_writer(&mut file, row)
|
|
2660
|
+
.map_err(|err| format!("screen memory transcript row encode failed: {err}"))?;
|
|
2661
|
+
file.write_all(b"\n")
|
|
2662
|
+
.map_err(|err| format!("screen memory transcript row write failed: {err}"))?;
|
|
2663
|
+
}
|
|
2664
|
+
Ok(())
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
fn run_transcript_job(app: &AppHandle, job: ScreenMemoryTranscriptJob) -> Result<(), String> {
|
|
2668
|
+
use crate::screen_memory_transcript::ScreenMemoryTranscriptStatus;
|
|
2669
|
+
|
|
2670
|
+
// Re-check eligibility after dequeue: retention/deletion and legacy data
|
|
2671
|
+
// may have changed while the serialized worker was occupied with OCR.
|
|
2672
|
+
if !segment_transcript_eligible(&job.segment) || !job.segment.path.exists() {
|
|
2673
|
+
return write_transcript_status(
|
|
2674
|
+
app,
|
|
2675
|
+
&job.segment.id,
|
|
2676
|
+
&ScreenMemoryTranscriptStatus::skipped(now_iso()),
|
|
2677
|
+
);
|
|
2678
|
+
}
|
|
2679
|
+
let started_at = now_iso();
|
|
2680
|
+
write_transcript_status(
|
|
2681
|
+
app,
|
|
2682
|
+
&job.segment.id,
|
|
2683
|
+
&ScreenMemoryTranscriptStatus::transcribing(started_at.clone()),
|
|
2684
|
+
)?;
|
|
2685
|
+
let segment_started_at = match DateTime::parse_from_rfc3339(&job.segment.started_at) {
|
|
2686
|
+
Ok(value) => value.with_timezone(&Utc),
|
|
2687
|
+
Err(_) => {
|
|
2688
|
+
return write_transcript_status(
|
|
2689
|
+
app,
|
|
2690
|
+
&job.segment.id,
|
|
2691
|
+
&ScreenMemoryTranscriptStatus::failed(
|
|
2692
|
+
started_at,
|
|
2693
|
+
now_iso(),
|
|
2694
|
+
"segment start timestamp unavailable",
|
|
2695
|
+
),
|
|
2696
|
+
)
|
|
2697
|
+
}
|
|
2698
|
+
};
|
|
2699
|
+
let duration_ms = u64::try_from(job.segment.duration_ms)
|
|
2700
|
+
.unwrap_or(crate::screen_memory_transcript::MAX_DURATION_MS.saturating_add(1));
|
|
2701
|
+
let mut rows = Vec::new();
|
|
2702
|
+
for (path, source) in [
|
|
2703
|
+
(job.segment.system_audio_path.as_ref(), "system-audio"),
|
|
2704
|
+
(job.segment.microphone_path.as_ref(), "microphone"),
|
|
2705
|
+
] {
|
|
2706
|
+
let Some(path) = path else {
|
|
2707
|
+
return write_transcript_status(
|
|
2708
|
+
app,
|
|
2709
|
+
&job.segment.id,
|
|
2710
|
+
&ScreenMemoryTranscriptStatus::failed(
|
|
2711
|
+
started_at,
|
|
2712
|
+
now_iso(),
|
|
2713
|
+
format!("{source} sidecar is unavailable"),
|
|
2714
|
+
),
|
|
2715
|
+
);
|
|
2716
|
+
};
|
|
2717
|
+
match crate::screen_memory_transcript::transcribe_segment(
|
|
2718
|
+
app,
|
|
2719
|
+
path,
|
|
2720
|
+
&job.segment.id,
|
|
2721
|
+
source,
|
|
2722
|
+
0,
|
|
2723
|
+
segment_started_at,
|
|
2724
|
+
duration_ms,
|
|
2725
|
+
None,
|
|
2726
|
+
) {
|
|
2727
|
+
Ok(mut source_rows) => rows.append(&mut source_rows),
|
|
2728
|
+
Err(error) => {
|
|
2729
|
+
return write_transcript_status(
|
|
2730
|
+
app,
|
|
2731
|
+
&job.segment.id,
|
|
2732
|
+
&ScreenMemoryTranscriptStatus::failed(started_at, now_iso(), error),
|
|
2733
|
+
)
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
rows.sort_by_key(|row| (row.start_ms, row.end_ms, row.source.clone()));
|
|
2738
|
+
write_transcript_rows(app, &job.segment.id, &rows)?;
|
|
2739
|
+
let result = write_transcript_status(
|
|
2740
|
+
app,
|
|
2741
|
+
&job.segment.id,
|
|
2742
|
+
&ScreenMemoryTranscriptStatus::ready(rows.len(), started_at, now_iso()),
|
|
2743
|
+
);
|
|
2744
|
+
let _ = refresh_rewind_chapters(app);
|
|
2745
|
+
result
|
|
2746
|
+
}
|
|
2747
|
+
|
|
726
2748
|
fn write_segment_metadata(
|
|
727
2749
|
app: &AppHandle,
|
|
728
2750
|
segment: &ScreenMemorySegmentMetadata,
|
|
@@ -730,7 +2752,33 @@ fn write_segment_metadata(
|
|
|
730
2752
|
let path = segment_metadata_path(app, &segment.id)?;
|
|
731
2753
|
let data = serde_json::to_vec_pretty(segment)
|
|
732
2754
|
.map_err(|e| format!("screen memory metadata encode failed: {e}"))?;
|
|
733
|
-
std::fs::write(path, data).map_err(|e| format!("screen memory metadata write failed: {e}"))
|
|
2755
|
+
std::fs::write(path, data).map_err(|e| format!("screen memory metadata write failed: {e}"))?;
|
|
2756
|
+
crate::rewind_clip::pin_finalized_segment_if_active(app, segment)?;
|
|
2757
|
+
enqueue_segment_transcript(app, segment.clone());
|
|
2758
|
+
refresh_rewind_chapters(app)?;
|
|
2759
|
+
Ok(())
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
fn refresh_rewind_chapters(app: &AppHandle) -> Result<(), String> {
|
|
2763
|
+
let events = read_screen_memory_events(app)?;
|
|
2764
|
+
crate::rewind_chapters::rebuild(
|
|
2765
|
+
&screen_memory_dir(app)?,
|
|
2766
|
+
recent_segments(app, None)?,
|
|
2767
|
+
events,
|
|
2768
|
+
)
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
fn read_screen_memory_events(app: &AppHandle) -> Result<Vec<ScreenMemoryEvent>, String> {
|
|
2772
|
+
let path = screen_memory_events_path(app)?;
|
|
2773
|
+
if !path.exists() {
|
|
2774
|
+
return Ok(Vec::new());
|
|
2775
|
+
}
|
|
2776
|
+
let file = File::open(path).map_err(|e| format!("screen memory events open failed: {e}"))?;
|
|
2777
|
+
Ok(BufReader::new(file)
|
|
2778
|
+
.lines()
|
|
2779
|
+
.map_while(Result::ok)
|
|
2780
|
+
.filter_map(|line| serde_json::from_str(&line).ok())
|
|
2781
|
+
.collect())
|
|
734
2782
|
}
|
|
735
2783
|
|
|
736
2784
|
fn read_segment_metadata_path(path: &Path) -> Result<ScreenMemorySegmentMetadata, String> {
|
|
@@ -769,6 +2817,91 @@ fn recent_segments(
|
|
|
769
2817
|
Ok(segments)
|
|
770
2818
|
}
|
|
771
2819
|
|
|
2820
|
+
/// All finalized local segments, newest metadata included. This intentionally
|
|
2821
|
+
/// exposes no mutation or upload behavior; bounded local consumers apply their
|
|
2822
|
+
/// own coverage and privacy checks before reading adjacent indexes.
|
|
2823
|
+
pub(crate) fn finalized_segments(
|
|
2824
|
+
app: &AppHandle,
|
|
2825
|
+
) -> Result<Vec<ScreenMemorySegmentMetadata>, String> {
|
|
2826
|
+
recent_segments(app, None)
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
/// Finalized logical segments overlapping a shared capture-graph interval.
|
|
2830
|
+
/// This is local metadata only; callers must pin before assembling an artifact.
|
|
2831
|
+
pub(crate) fn finalized_segments_in_graph_interval(
|
|
2832
|
+
app: &AppHandle,
|
|
2833
|
+
started_elapsed_ms: u64,
|
|
2834
|
+
ended_elapsed_ms: u64,
|
|
2835
|
+
) -> Result<Vec<ScreenMemorySegmentMetadata>, String> {
|
|
2836
|
+
if ended_elapsed_ms < started_elapsed_ms {
|
|
2837
|
+
return Ok(Vec::new());
|
|
2838
|
+
}
|
|
2839
|
+
let current_epoch = graph_epoch_id(app);
|
|
2840
|
+
recent_segments(app, None).map(|segments| {
|
|
2841
|
+
segments
|
|
2842
|
+
.into_iter()
|
|
2843
|
+
.filter(|segment| {
|
|
2844
|
+
segment_overlaps_graph_interval(
|
|
2845
|
+
segment,
|
|
2846
|
+
¤t_epoch,
|
|
2847
|
+
started_elapsed_ms,
|
|
2848
|
+
ended_elapsed_ms,
|
|
2849
|
+
)
|
|
2850
|
+
})
|
|
2851
|
+
.collect()
|
|
2852
|
+
})
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
fn segment_overlaps_graph_interval(
|
|
2856
|
+
segment: &ScreenMemorySegmentMetadata,
|
|
2857
|
+
current_epoch: &str,
|
|
2858
|
+
started_elapsed_ms: u64,
|
|
2859
|
+
ended_elapsed_ms: u64,
|
|
2860
|
+
) -> bool {
|
|
2861
|
+
segment.graph_epoch_id.as_deref() == Some(current_epoch)
|
|
2862
|
+
&& segment.graph_started_elapsed_ms <= ended_elapsed_ms
|
|
2863
|
+
&& segment.graph_ended_elapsed_ms >= started_elapsed_ms
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
/// Keep a finalized local segment from retention pruning while an in-flight
|
|
2867
|
+
/// artifact copies it. Pin state is intentionally process-local.
|
|
2868
|
+
pub(crate) fn pin_segment(app: &AppHandle, segment_id: &str, pin_id: &str) -> Result<(), String> {
|
|
2869
|
+
let state = app.state::<ScreenMemoryState>();
|
|
2870
|
+
let mut runtime = state.inner.lock().map_err(|error| error.to_string())?;
|
|
2871
|
+
runtime
|
|
2872
|
+
.segment_pins
|
|
2873
|
+
.entry(sanitize_segment_id(segment_id))
|
|
2874
|
+
.or_default()
|
|
2875
|
+
.insert(pin_id.to_owned());
|
|
2876
|
+
Ok(())
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
pub(crate) fn unpin_segment(app: &AppHandle, segment_id: &str, pin_id: &str) -> Result<(), String> {
|
|
2880
|
+
let state = app.state::<ScreenMemoryState>();
|
|
2881
|
+
let mut runtime = state.inner.lock().map_err(|error| error.to_string())?;
|
|
2882
|
+
let segment_id = sanitize_segment_id(segment_id);
|
|
2883
|
+
if let Some(pins) = runtime.segment_pins.get_mut(&segment_id) {
|
|
2884
|
+
pins.remove(pin_id);
|
|
2885
|
+
if pins.is_empty() {
|
|
2886
|
+
runtime.segment_pins.remove(&segment_id);
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
Ok(())
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
fn segment_is_pinned(app: &AppHandle, segment_id: &str) -> bool {
|
|
2893
|
+
app.try_state::<ScreenMemoryState>()
|
|
2894
|
+
.and_then(|state| {
|
|
2895
|
+
state.inner.lock().ok().map(|runtime| {
|
|
2896
|
+
runtime
|
|
2897
|
+
.segment_pins
|
|
2898
|
+
.get(segment_id)
|
|
2899
|
+
.is_some_and(|pins| !pins.is_empty())
|
|
2900
|
+
})
|
|
2901
|
+
})
|
|
2902
|
+
.unwrap_or(false)
|
|
2903
|
+
}
|
|
2904
|
+
|
|
772
2905
|
fn query_screen_memory(
|
|
773
2906
|
app: &AppHandle,
|
|
774
2907
|
query: Option<String>,
|
|
@@ -855,19 +2988,7 @@ fn recent_events(
|
|
|
855
2988
|
}
|
|
856
2989
|
|
|
857
2990
|
fn export_recent(app: &AppHandle, minutes: u64) -> Result<ScreenMemoryExportResult, String> {
|
|
858
|
-
let minutes = minutes.clamp(1,
|
|
859
|
-
let cutoff = Utc::now() - ChronoDuration::minutes(minutes as i64);
|
|
860
|
-
let segments = recent_segments(app, None)?
|
|
861
|
-
.into_iter()
|
|
862
|
-
.filter(|segment| {
|
|
863
|
-
DateTime::parse_from_rfc3339(&segment.ended_at)
|
|
864
|
-
.map(|value| value.with_timezone(&Utc) >= cutoff)
|
|
865
|
-
.unwrap_or(true)
|
|
866
|
-
})
|
|
867
|
-
.collect::<Vec<_>>();
|
|
868
|
-
if segments.is_empty() {
|
|
869
|
-
return Err("No Screen Memory segments are available for that range.".to_string());
|
|
870
|
-
}
|
|
2991
|
+
let minutes = minutes.clamp(1, 5);
|
|
871
2992
|
|
|
872
2993
|
let folder = app
|
|
873
2994
|
.path()
|
|
@@ -878,29 +2999,28 @@ fn export_recent(app: &AppHandle, minutes: u64) -> Result<ScreenMemoryExportResu
|
|
|
878
2999
|
.join(Utc::now().format("%Y-%m-%d-%H%M%S").to_string());
|
|
879
3000
|
std::fs::create_dir_all(&folder)
|
|
880
3001
|
.map_err(|e| format!("screen memory export folder unavailable: {e}"))?;
|
|
881
|
-
|
|
882
|
-
let
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
}
|
|
3002
|
+
let file_name = format!("previous-{minutes}-minutes.mp4");
|
|
3003
|
+
let destination = folder.join(&file_name);
|
|
3004
|
+
let artifact = match crate::rewind_clip::materialize_recent_exact(
|
|
3005
|
+
app,
|
|
3006
|
+
Duration::from_secs(minutes * 60),
|
|
3007
|
+
destination.clone(),
|
|
3008
|
+
) {
|
|
3009
|
+
Ok(artifact) => artifact,
|
|
3010
|
+
Err(error) => {
|
|
3011
|
+
let _ = std::fs::remove_dir(&folder);
|
|
3012
|
+
return Err(error);
|
|
3013
|
+
}
|
|
3014
|
+
};
|
|
3015
|
+
let bytes = std::fs::metadata(&artifact.path)
|
|
3016
|
+
.map_err(|e| format!("screen memory export metadata failed: {e}"))?
|
|
3017
|
+
.len();
|
|
3018
|
+
let files = vec![ScreenMemoryExportFile {
|
|
3019
|
+
path: artifact.path.to_string_lossy().to_string(),
|
|
3020
|
+
file_name,
|
|
3021
|
+
bytes,
|
|
3022
|
+
mime_type: artifact.mime_type.to_string(),
|
|
3023
|
+
}];
|
|
904
3024
|
|
|
905
3025
|
Ok(ScreenMemoryExportResult {
|
|
906
3026
|
folder_path: folder.to_string_lossy().to_string(),
|
|
@@ -918,14 +3038,125 @@ fn prune_segments(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), St
|
|
|
918
3038
|
.ok();
|
|
919
3039
|
let expired = ended_at.map(|value| value < cutoff).unwrap_or(false);
|
|
920
3040
|
kept_bytes = kept_bytes.saturating_add(segment.bytes);
|
|
921
|
-
if expired || kept_bytes > config.max_bytes {
|
|
3041
|
+
if (expired || kept_bytes > config.max_bytes) && !segment_is_pinned(app, &segment.id) {
|
|
922
3042
|
let _ = delete_segment(app, &segment.id);
|
|
923
3043
|
}
|
|
924
3044
|
}
|
|
3045
|
+
prune_events(app, cutoff)?;
|
|
3046
|
+
refresh_rewind_chapters(app)?;
|
|
925
3047
|
Ok(())
|
|
926
3048
|
}
|
|
927
3049
|
|
|
3050
|
+
fn prune_events(app: &AppHandle, cutoff: DateTime<Utc>) -> Result<(), String> {
|
|
3051
|
+
let path = screen_memory_events_path(app)?;
|
|
3052
|
+
if !path.exists() {
|
|
3053
|
+
return Ok(());
|
|
3054
|
+
}
|
|
3055
|
+
let file = File::open(&path).map_err(|e| format!("screen memory events open failed: {e}"))?;
|
|
3056
|
+
let retained = BufReader::new(file)
|
|
3057
|
+
.lines()
|
|
3058
|
+
.map_while(Result::ok)
|
|
3059
|
+
.filter_map(|line| serde_json::from_str::<ScreenMemoryEvent>(&line).ok())
|
|
3060
|
+
.collect::<Vec<_>>();
|
|
3061
|
+
let mut coverage = recent_segments(app, None)?
|
|
3062
|
+
.into_iter()
|
|
3063
|
+
.filter_map(|segment| {
|
|
3064
|
+
Some((
|
|
3065
|
+
DateTime::parse_from_rfc3339(&segment.started_at)
|
|
3066
|
+
.ok()?
|
|
3067
|
+
.with_timezone(&Utc),
|
|
3068
|
+
DateTime::parse_from_rfc3339(&segment.ended_at)
|
|
3069
|
+
.ok()?
|
|
3070
|
+
.with_timezone(&Utc),
|
|
3071
|
+
))
|
|
3072
|
+
})
|
|
3073
|
+
.collect::<Vec<_>>();
|
|
3074
|
+
if let Some(active) = app
|
|
3075
|
+
.state::<ScreenMemoryState>()
|
|
3076
|
+
.inner
|
|
3077
|
+
.lock()
|
|
3078
|
+
.ok()
|
|
3079
|
+
.and_then(|runtime| {
|
|
3080
|
+
runtime
|
|
3081
|
+
.active
|
|
3082
|
+
.as_ref()
|
|
3083
|
+
.map(|active| active.started_at_iso.clone())
|
|
3084
|
+
})
|
|
3085
|
+
.and_then(|started_at| DateTime::parse_from_rfc3339(&started_at).ok())
|
|
3086
|
+
{
|
|
3087
|
+
coverage.push((active.with_timezone(&Utc), Utc::now()));
|
|
3088
|
+
}
|
|
3089
|
+
let retained = retain_events_with_media_coverage(retained, cutoff, &coverage);
|
|
3090
|
+
let temporary_path = path.with_extension("jsonl.tmp");
|
|
3091
|
+
let mut file = File::create(&temporary_path)
|
|
3092
|
+
.map_err(|e| format!("screen memory events rewrite failed: {e}"))?;
|
|
3093
|
+
for event in retained {
|
|
3094
|
+
serde_json::to_writer(&mut file, &event)
|
|
3095
|
+
.map_err(|e| format!("screen memory event encode failed: {e}"))?;
|
|
3096
|
+
file.write_all(b"\n")
|
|
3097
|
+
.map_err(|e| format!("screen memory event write failed: {e}"))?;
|
|
3098
|
+
}
|
|
3099
|
+
std::fs::rename(&temporary_path, &path)
|
|
3100
|
+
.map_err(|e| format!("screen memory events replace failed: {e}"))
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
fn retain_events_since(
|
|
3104
|
+
events: Vec<ScreenMemoryEvent>,
|
|
3105
|
+
cutoff: DateTime<Utc>,
|
|
3106
|
+
) -> Vec<ScreenMemoryEvent> {
|
|
3107
|
+
events
|
|
3108
|
+
.into_iter()
|
|
3109
|
+
.filter(|event| {
|
|
3110
|
+
DateTime::parse_from_rfc3339(&event.captured_at)
|
|
3111
|
+
.map(|captured_at| captured_at.with_timezone(&Utc) >= cutoff)
|
|
3112
|
+
.unwrap_or(false)
|
|
3113
|
+
})
|
|
3114
|
+
.collect()
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
fn retain_events_with_media_coverage(
|
|
3118
|
+
events: Vec<ScreenMemoryEvent>,
|
|
3119
|
+
cutoff: DateTime<Utc>,
|
|
3120
|
+
coverage: &[(DateTime<Utc>, DateTime<Utc>)],
|
|
3121
|
+
) -> Vec<ScreenMemoryEvent> {
|
|
3122
|
+
retain_events_since(events, cutoff)
|
|
3123
|
+
.into_iter()
|
|
3124
|
+
.filter(|event| {
|
|
3125
|
+
if event.source == "coverage-gap" {
|
|
3126
|
+
return true;
|
|
3127
|
+
}
|
|
3128
|
+
let Ok(captured_at) = DateTime::parse_from_rfc3339(&event.captured_at) else {
|
|
3129
|
+
return false;
|
|
3130
|
+
};
|
|
3131
|
+
let captured_at = captured_at.with_timezone(&Utc);
|
|
3132
|
+
coverage.iter().any(|(started_at, ended_at)| {
|
|
3133
|
+
captured_at >= *started_at && captured_at <= *ended_at
|
|
3134
|
+
})
|
|
3135
|
+
})
|
|
3136
|
+
.collect()
|
|
3137
|
+
}
|
|
3138
|
+
|
|
928
3139
|
fn delete_segment(app: &AppHandle, segment_id: &str) -> Result<ScreenMemoryDeleteResult, String> {
|
|
3140
|
+
let state = app.try_state::<ScreenMemoryState>();
|
|
3141
|
+
if let Some(state) = state.as_ref() {
|
|
3142
|
+
if let Ok(mut runtime) = state.inner.lock() {
|
|
3143
|
+
runtime.ocr_queue.retain(|job| job.segment.id != segment_id);
|
|
3144
|
+
runtime.ocr_cancelled_segments.insert(segment_id.to_owned());
|
|
3145
|
+
runtime
|
|
3146
|
+
.transcript_queue
|
|
3147
|
+
.retain(|job| job.segment.id != segment_id);
|
|
3148
|
+
runtime
|
|
3149
|
+
.transcript_cancelled_segments
|
|
3150
|
+
.insert(segment_id.to_owned());
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
// Wait for a currently-running derivative job to observe cancellation
|
|
3154
|
+
// before removing the source and its sidecars. This makes Clear and
|
|
3155
|
+
// retention deletion a real boundary instead of a hopeful suggestion.
|
|
3156
|
+
let _indexing = state
|
|
3157
|
+
.as_ref()
|
|
3158
|
+
.map(|state| state.indexing.lock().map_err(|error| error.to_string()))
|
|
3159
|
+
.transpose()?;
|
|
929
3160
|
let metadata_path = segment_metadata_path(app, segment_id)?;
|
|
930
3161
|
let segment = read_segment_metadata_path(&metadata_path).ok();
|
|
931
3162
|
let mut deleted_segments = 0_usize;
|
|
@@ -933,9 +3164,26 @@ fn delete_segment(app: &AppHandle, segment_id: &str) -> Result<ScreenMemoryDelet
|
|
|
933
3164
|
if let Some(segment) = segment {
|
|
934
3165
|
deleted_bytes = segment.bytes;
|
|
935
3166
|
remove_file_if_exists(&segment.path)?;
|
|
3167
|
+
if let Some(path) = segment.system_audio_path.as_ref() {
|
|
3168
|
+
remove_file_if_exists(path)?;
|
|
3169
|
+
}
|
|
3170
|
+
if let Some(path) = segment.microphone_path.as_ref() {
|
|
3171
|
+
remove_file_if_exists(path)?;
|
|
3172
|
+
}
|
|
936
3173
|
deleted_segments = 1;
|
|
937
3174
|
}
|
|
938
3175
|
remove_file_if_exists(&metadata_path)?;
|
|
3176
|
+
remove_file_if_exists(&segment_ocr_rows_path(app, segment_id)?)?;
|
|
3177
|
+
remove_file_if_exists(&segment_ocr_status_path(app, segment_id)?)?;
|
|
3178
|
+
remove_transcript_sidecars(&screen_memory_dir(app)?, segment_id)?;
|
|
3179
|
+
refresh_rewind_chapters(app)?;
|
|
3180
|
+
crate::rewind_clip::clear_preview_artifacts(app)?;
|
|
3181
|
+
if let Some(state) = state.as_ref() {
|
|
3182
|
+
if let Ok(mut runtime) = state.inner.lock() {
|
|
3183
|
+
runtime.ocr_cancelled_segments.remove(segment_id);
|
|
3184
|
+
runtime.transcript_cancelled_segments.remove(segment_id);
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
939
3187
|
Ok(ScreenMemoryDeleteResult {
|
|
940
3188
|
deleted_segments,
|
|
941
3189
|
deleted_bytes,
|
|
@@ -943,12 +3191,48 @@ fn delete_segment(app: &AppHandle, segment_id: &str) -> Result<ScreenMemoryDelet
|
|
|
943
3191
|
}
|
|
944
3192
|
|
|
945
3193
|
fn delete_all_segments(app: &AppHandle) -> Result<ScreenMemoryDeleteResult, String> {
|
|
3194
|
+
let state = app.try_state::<ScreenMemoryState>();
|
|
3195
|
+
if let Some(state) = state.as_ref() {
|
|
3196
|
+
let mut runtime = state.inner.lock().map_err(|error| error.to_string())?;
|
|
3197
|
+
let queued_ocr: Vec<_> = runtime
|
|
3198
|
+
.ocr_queue
|
|
3199
|
+
.iter()
|
|
3200
|
+
.map(|job| job.segment.id.clone())
|
|
3201
|
+
.collect();
|
|
3202
|
+
let queued_transcripts: Vec<_> = runtime
|
|
3203
|
+
.transcript_queue
|
|
3204
|
+
.iter()
|
|
3205
|
+
.map(|job| job.segment.id.clone())
|
|
3206
|
+
.collect();
|
|
3207
|
+
runtime.ocr_cancelled_segments.extend(queued_ocr);
|
|
3208
|
+
runtime
|
|
3209
|
+
.transcript_cancelled_segments
|
|
3210
|
+
.extend(queued_transcripts);
|
|
3211
|
+
if let Some(segment_id) = runtime.ocr_active_segment.clone() {
|
|
3212
|
+
runtime.ocr_cancelled_segments.insert(segment_id);
|
|
3213
|
+
}
|
|
3214
|
+
if let Some(segment_id) = runtime.transcript_active_segment.clone() {
|
|
3215
|
+
runtime.transcript_cancelled_segments.insert(segment_id);
|
|
3216
|
+
}
|
|
3217
|
+
runtime.ocr_queue.clear();
|
|
3218
|
+
runtime.transcript_queue.clear();
|
|
3219
|
+
}
|
|
3220
|
+
let _indexing = state
|
|
3221
|
+
.as_ref()
|
|
3222
|
+
.map(|state| state.indexing.lock().map_err(|error| error.to_string()))
|
|
3223
|
+
.transpose()?;
|
|
946
3224
|
let dir = screen_memory_dir(app)?;
|
|
947
3225
|
let mut deleted_segments = 0_usize;
|
|
948
3226
|
let mut deleted_bytes = 0_u64;
|
|
949
3227
|
for segment in recent_segments(app, None)? {
|
|
950
3228
|
deleted_bytes = deleted_bytes.saturating_add(segment.bytes);
|
|
951
3229
|
remove_file_if_exists(&segment.path)?;
|
|
3230
|
+
if let Some(path) = segment.system_audio_path.as_ref() {
|
|
3231
|
+
remove_file_if_exists(path)?;
|
|
3232
|
+
}
|
|
3233
|
+
if let Some(path) = segment.microphone_path.as_ref() {
|
|
3234
|
+
remove_file_if_exists(path)?;
|
|
3235
|
+
}
|
|
952
3236
|
remove_file_if_exists(&segment_metadata_path(app, &segment.id)?)?;
|
|
953
3237
|
deleted_segments += 1;
|
|
954
3238
|
}
|
|
@@ -961,6 +3245,14 @@ fn delete_all_segments(app: &AppHandle) -> Result<ScreenMemoryDeleteResult, Stri
|
|
|
961
3245
|
remove_file_if_exists(&path)?;
|
|
962
3246
|
}
|
|
963
3247
|
}
|
|
3248
|
+
crate::rewind_chapters::clear(&dir)?;
|
|
3249
|
+
crate::rewind_clip::clear_preview_artifacts(app)?;
|
|
3250
|
+
if let Some(state) = state.as_ref() {
|
|
3251
|
+
if let Ok(mut runtime) = state.inner.lock() {
|
|
3252
|
+
runtime.ocr_cancelled_segments.clear();
|
|
3253
|
+
runtime.transcript_cancelled_segments.clear();
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
964
3256
|
Ok(ScreenMemoryDeleteResult {
|
|
965
3257
|
deleted_segments,
|
|
966
3258
|
deleted_bytes,
|
|
@@ -988,3 +3280,520 @@ fn record_error(app: &AppHandle, error: String) {
|
|
|
988
3280
|
fn now_iso() -> String {
|
|
989
3281
|
Utc::now().to_rfc3339()
|
|
990
3282
|
}
|
|
3283
|
+
|
|
3284
|
+
#[cfg(test)]
|
|
3285
|
+
mod tests {
|
|
3286
|
+
use super::*;
|
|
3287
|
+
|
|
3288
|
+
#[test]
|
|
3289
|
+
fn codex_agent_connection_replaces_only_its_own_mcp_footprint() {
|
|
3290
|
+
let existing = r#"model = "gpt-5.6"
|
|
3291
|
+
|
|
3292
|
+
[mcp_servers."clips-screen-memory"]
|
|
3293
|
+
command = "old"
|
|
3294
|
+
args = ["old"]
|
|
3295
|
+
|
|
3296
|
+
[mcp_servers."clips-screen-memory".env]
|
|
3297
|
+
OLD = "value"
|
|
3298
|
+
|
|
3299
|
+
[mcp_servers.other]
|
|
3300
|
+
command = "keep"
|
|
3301
|
+
"#;
|
|
3302
|
+
let block = codex_screen_memory_block(Path::new("/tmp/Clips Alpha/screen-memory"));
|
|
3303
|
+
let next = replace_codex_screen_memory_block(existing, &block);
|
|
3304
|
+
assert_eq!(next.matches("clips-screen-memory").count(), 1);
|
|
3305
|
+
assert!(!next.contains("OLD"));
|
|
3306
|
+
assert!(next.contains("[mcp_servers.other]"));
|
|
3307
|
+
assert!(next.contains("/tmp/Clips Alpha/screen-memory"));
|
|
3308
|
+
assert!(next.contains("@agent-native/core@latest"));
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
#[test]
|
|
3312
|
+
fn codex_agent_connection_appends_to_an_existing_config() {
|
|
3313
|
+
let block = codex_screen_memory_block(Path::new("/tmp/screen-memory"));
|
|
3314
|
+
let next = replace_codex_screen_memory_block("model = \"gpt-5.6\"\n", &block);
|
|
3315
|
+
assert!(next.starts_with("model = \"gpt-5.6\""));
|
|
3316
|
+
assert!(next.contains("command = \"npx\""));
|
|
3317
|
+
assert!(next.contains("mcp\", \"screen-memory"));
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3320
|
+
fn event(captured_at: &str) -> ScreenMemoryEvent {
|
|
3321
|
+
ScreenMemoryEvent {
|
|
3322
|
+
captured_at: captured_at.to_string(),
|
|
3323
|
+
app_name: Some("Example".to_string()),
|
|
3324
|
+
window_title: Some("Example window".to_string()),
|
|
3325
|
+
bundle_id: Some("example.app".to_string()),
|
|
3326
|
+
source: "test".to_string(),
|
|
3327
|
+
coverage_gap_reason: None,
|
|
3328
|
+
accessibility: None,
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
fn segment(id: &str) -> ScreenMemorySegmentMetadata {
|
|
3333
|
+
ScreenMemorySegmentMetadata {
|
|
3334
|
+
id: id.into(),
|
|
3335
|
+
path: PathBuf::from(format!("{id}.mp4")),
|
|
3336
|
+
file_name: format!("{id}.mp4"),
|
|
3337
|
+
mime_type: "video/mp4".into(),
|
|
3338
|
+
started_at: "2026-07-19T10:00:00Z".into(),
|
|
3339
|
+
ended_at: "2026-07-19T10:00:01Z".into(),
|
|
3340
|
+
duration_ms: 1_000,
|
|
3341
|
+
width: Some(1280),
|
|
3342
|
+
height: Some(720),
|
|
3343
|
+
bytes: 1,
|
|
3344
|
+
system_audio_path: None,
|
|
3345
|
+
microphone_path: None,
|
|
3346
|
+
corrupt: false,
|
|
3347
|
+
error: None,
|
|
3348
|
+
capture_mode: RewindCaptureMode::VisualsAudio,
|
|
3349
|
+
exclusion_tainted: false,
|
|
3350
|
+
graph_epoch_id: Some("test-epoch".into()),
|
|
3351
|
+
graph_started_elapsed_ms: 0,
|
|
3352
|
+
graph_ended_elapsed_ms: 1_000,
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
#[test]
|
|
3357
|
+
fn derivative_dequeue_marks_the_job_active_in_the_same_runtime_mutation() {
|
|
3358
|
+
let mut runtime = ScreenMemoryRuntime {
|
|
3359
|
+
ocr_worker_running: true,
|
|
3360
|
+
transcript_worker_running: true,
|
|
3361
|
+
..ScreenMemoryRuntime::default()
|
|
3362
|
+
};
|
|
3363
|
+
runtime.ocr_queue.push_back(ScreenMemoryOcrJob {
|
|
3364
|
+
segment: segment("ocr"),
|
|
3365
|
+
sample_interval_seconds: 10,
|
|
3366
|
+
});
|
|
3367
|
+
runtime
|
|
3368
|
+
.transcript_queue
|
|
3369
|
+
.push_back(ScreenMemoryTranscriptJob {
|
|
3370
|
+
segment: segment("transcript"),
|
|
3371
|
+
});
|
|
3372
|
+
|
|
3373
|
+
assert_eq!(take_next_ocr_job(&mut runtime).unwrap().segment.id, "ocr");
|
|
3374
|
+
assert_eq!(runtime.ocr_active_segment.as_deref(), Some("ocr"));
|
|
3375
|
+
assert_eq!(
|
|
3376
|
+
take_next_transcript_job(&mut runtime).unwrap().segment.id,
|
|
3377
|
+
"transcript"
|
|
3378
|
+
);
|
|
3379
|
+
assert_eq!(
|
|
3380
|
+
runtime.transcript_active_segment.as_deref(),
|
|
3381
|
+
Some("transcript")
|
|
3382
|
+
);
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
#[test]
|
|
3386
|
+
fn normalizes_bounds_and_excluded_bundle_ids() {
|
|
3387
|
+
let config = normalize_screen_memory_config(ScreenMemoryConfig {
|
|
3388
|
+
retention_hours: 100,
|
|
3389
|
+
max_bytes: 1,
|
|
3390
|
+
segment_seconds: 1,
|
|
3391
|
+
sample_interval_seconds: 100,
|
|
3392
|
+
excluded_bundle_ids: vec![
|
|
3393
|
+
" COM.Example.Secret ".to_string(),
|
|
3394
|
+
"com.example.secret".to_string(),
|
|
3395
|
+
" ".to_string(),
|
|
3396
|
+
],
|
|
3397
|
+
..ScreenMemoryConfig::default()
|
|
3398
|
+
});
|
|
3399
|
+
|
|
3400
|
+
assert_eq!(config.retention_hours, MAX_RETENTION_HOURS);
|
|
3401
|
+
assert_eq!(config.max_bytes, MIN_MAX_BYTES);
|
|
3402
|
+
assert_eq!(config.segment_seconds, MIN_SEGMENT_SECONDS);
|
|
3403
|
+
assert_eq!(config.sample_interval_seconds, MIN_SEGMENT_SECONDS);
|
|
3404
|
+
assert_eq!(config.excluded_bundle_ids, vec!["com.example.secret"]);
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
#[test]
|
|
3408
|
+
fn excludes_bundle_ids_without_retaining_context() {
|
|
3409
|
+
let config = ScreenMemoryConfig::default();
|
|
3410
|
+
assert_eq!(
|
|
3411
|
+
event_exclusion_reason(&config, Some("COM.1PASSWORD.1PASSWORD"), Some("vault")),
|
|
3412
|
+
Some("excluded-bundle-id")
|
|
3413
|
+
);
|
|
3414
|
+
assert_eq!(
|
|
3415
|
+
event_exclusion_reason(
|
|
3416
|
+
&config,
|
|
3417
|
+
Some("com.example.browser"),
|
|
3418
|
+
Some("Search - Incognito")
|
|
3419
|
+
),
|
|
3420
|
+
None
|
|
3421
|
+
);
|
|
3422
|
+
let private_window_config = ScreenMemoryConfig {
|
|
3423
|
+
exclude_private_windows: true,
|
|
3424
|
+
..ScreenMemoryConfig::default()
|
|
3425
|
+
};
|
|
3426
|
+
assert_eq!(
|
|
3427
|
+
event_exclusion_reason(
|
|
3428
|
+
&private_window_config,
|
|
3429
|
+
Some("com.example.browser"),
|
|
3430
|
+
Some("Search - Incognito")
|
|
3431
|
+
),
|
|
3432
|
+
Some("excluded-private-window")
|
|
3433
|
+
);
|
|
3434
|
+
let gap = coverage_gap_event("privacy-exclusion");
|
|
3435
|
+
assert_eq!(gap.source, "coverage-gap");
|
|
3436
|
+
assert_eq!(
|
|
3437
|
+
gap.coverage_gap_reason.as_deref(),
|
|
3438
|
+
Some("privacy-exclusion")
|
|
3439
|
+
);
|
|
3440
|
+
assert!(gap.app_name.is_none() && gap.window_title.is_none() && gap.bundle_id.is_none());
|
|
3441
|
+
assert!(gap.accessibility.is_none());
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
#[test]
|
|
3445
|
+
fn old_event_jsonl_lines_parse_without_accessibility() {
|
|
3446
|
+
let event: ScreenMemoryEvent = serde_json::from_str(
|
|
3447
|
+
r#"{"capturedAt":"2026-07-18T12:00:00Z","appName":"Mail","windowTitle":"Inbox","bundleId":"com.apple.mail","source":"core-graphics"}"#,
|
|
3448
|
+
)
|
|
3449
|
+
.expect("old event line remains readable");
|
|
3450
|
+
assert_eq!(event.app_name.as_deref(), Some("Mail"));
|
|
3451
|
+
assert!(event.coverage_gap_reason.is_none());
|
|
3452
|
+
assert!(event.accessibility.is_none());
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
#[test]
|
|
3456
|
+
fn recognized_exclusion_transitions_suspend_stay_suspended_and_resume() {
|
|
3457
|
+
assert_eq!(
|
|
3458
|
+
exclusion_transition(false, false),
|
|
3459
|
+
ExclusionTransition::KeepCapturing
|
|
3460
|
+
);
|
|
3461
|
+
assert_eq!(
|
|
3462
|
+
exclusion_transition(false, true),
|
|
3463
|
+
ExclusionTransition::Suspend
|
|
3464
|
+
);
|
|
3465
|
+
assert_eq!(
|
|
3466
|
+
exclusion_transition(true, true),
|
|
3467
|
+
ExclusionTransition::StaySuspended
|
|
3468
|
+
);
|
|
3469
|
+
assert_eq!(
|
|
3470
|
+
exclusion_transition(true, false),
|
|
3471
|
+
ExclusionTransition::Resume
|
|
3472
|
+
);
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
#[test]
|
|
3476
|
+
fn exclusion_resume_requires_one_stopped_producer_and_safe_live_config() {
|
|
3477
|
+
assert!(exclusion_resume_allowed(
|
|
3478
|
+
true, false, false, false, true, false
|
|
3479
|
+
));
|
|
3480
|
+
assert!(!exclusion_resume_allowed(
|
|
3481
|
+
true, false, false, false, true, true
|
|
3482
|
+
));
|
|
3483
|
+
assert!(!exclusion_resume_allowed(
|
|
3484
|
+
true, false, false, true, true, false
|
|
3485
|
+
));
|
|
3486
|
+
assert!(!exclusion_resume_allowed(
|
|
3487
|
+
true, true, false, false, true, false
|
|
3488
|
+
));
|
|
3489
|
+
assert!(!exclusion_resume_allowed(
|
|
3490
|
+
true, false, true, false, true, false
|
|
3491
|
+
));
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
#[test]
|
|
3495
|
+
fn exclusion_gap_covers_discarded_segment_start_and_every_effective_source() {
|
|
3496
|
+
let started_at = Instant::now() - Duration::from_secs(7);
|
|
3497
|
+
let gap = active_exclusion_gap(started_at, RewindCaptureMode::VisualsAudio);
|
|
3498
|
+
assert_eq!(gap.started_at, started_at);
|
|
3499
|
+
assert_eq!(
|
|
3500
|
+
gap.sources,
|
|
3501
|
+
vec![
|
|
3502
|
+
CaptureSource::Screen,
|
|
3503
|
+
CaptureSource::SystemAudio,
|
|
3504
|
+
CaptureSource::Microphone
|
|
3505
|
+
]
|
|
3506
|
+
);
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
#[test]
|
|
3510
|
+
fn discarded_segment_sidecars_are_removed_as_one_retention_unit() {
|
|
3511
|
+
let dir = std::env::temp_dir().join(format!(
|
|
3512
|
+
"clips-exclusion-discard-{}-{}",
|
|
3513
|
+
std::process::id(),
|
|
3514
|
+
SEGMENT_COUNTER.fetch_add(1, Ordering::Relaxed)
|
|
3515
|
+
));
|
|
3516
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
3517
|
+
let paths: Vec<_> = [
|
|
3518
|
+
"segment.json",
|
|
3519
|
+
"segment.ocr.jsonl",
|
|
3520
|
+
"segment.ocr-status.json",
|
|
3521
|
+
"segment.transcript.jsonl",
|
|
3522
|
+
"segment.transcript-status.json",
|
|
3523
|
+
]
|
|
3524
|
+
.into_iter()
|
|
3525
|
+
.map(|name| dir.join(name))
|
|
3526
|
+
.collect();
|
|
3527
|
+
for path in &paths {
|
|
3528
|
+
std::fs::write(path, b"private-derived-data").unwrap();
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
remove_discarded_sidecars(&paths).unwrap();
|
|
3532
|
+
|
|
3533
|
+
assert!(paths.iter().all(|path| !path.exists()));
|
|
3534
|
+
std::fs::remove_dir_all(dir).unwrap();
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
#[test]
|
|
3538
|
+
fn segment_metadata_preserves_exclusion_taint_and_defaults_legacy_rows() {
|
|
3539
|
+
let tainted: ScreenMemorySegmentMetadata = serde_json::from_value(serde_json::json!({
|
|
3540
|
+
"id": "segment-1", "path": "/tmp/segment.mp4", "fileName": "segment.mp4",
|
|
3541
|
+
"mimeType": "video/mp4", "startedAt": "2026-07-14T12:00:00Z",
|
|
3542
|
+
"endedAt": "2026-07-14T12:01:00Z", "durationMs": 60000, "width": 1,
|
|
3543
|
+
"height": 1, "bytes": 1, "corrupt": false, "error": null,
|
|
3544
|
+
"exclusionTainted": true
|
|
3545
|
+
}))
|
|
3546
|
+
.unwrap();
|
|
3547
|
+
assert!(tainted.exclusion_tainted);
|
|
3548
|
+
assert_eq!(tainted.graph_started_elapsed_ms, 0);
|
|
3549
|
+
assert_eq!(tainted.graph_ended_elapsed_ms, 0);
|
|
3550
|
+
assert_eq!(tainted.capture_mode, RewindCaptureMode::Visuals);
|
|
3551
|
+
|
|
3552
|
+
let legacy: ScreenMemorySegmentMetadata = serde_json::from_value(serde_json::json!({
|
|
3553
|
+
"id": "segment-2", "path": "/tmp/segment.mp4", "fileName": "segment.mp4",
|
|
3554
|
+
"mimeType": "video/mp4", "startedAt": "2026-07-14T12:00:00Z",
|
|
3555
|
+
"endedAt": "2026-07-14T12:01:00Z", "durationMs": 60000, "width": null,
|
|
3556
|
+
"height": null, "bytes": 1, "corrupt": false, "error": null
|
|
3557
|
+
}))
|
|
3558
|
+
.unwrap();
|
|
3559
|
+
assert!(!legacy.exclusion_tainted);
|
|
3560
|
+
assert_eq!(legacy.capture_mode, RewindCaptureMode::Visuals);
|
|
3561
|
+
assert_eq!(legacy.graph_epoch_id, None);
|
|
3562
|
+
assert_eq!(legacy.graph_started_elapsed_ms, 0);
|
|
3563
|
+
assert_eq!(legacy.graph_ended_elapsed_ms, 0);
|
|
3564
|
+
|
|
3565
|
+
let mut current = legacy.clone();
|
|
3566
|
+
current.graph_epoch_id = Some("current-epoch".into());
|
|
3567
|
+
current.graph_started_elapsed_ms = 100;
|
|
3568
|
+
current.graph_ended_elapsed_ms = 200;
|
|
3569
|
+
assert!(segment_overlaps_graph_interval(
|
|
3570
|
+
¤t,
|
|
3571
|
+
"current-epoch",
|
|
3572
|
+
150,
|
|
3573
|
+
250
|
|
3574
|
+
));
|
|
3575
|
+
assert!(!segment_overlaps_graph_interval(
|
|
3576
|
+
¤t,
|
|
3577
|
+
"stale-epoch",
|
|
3578
|
+
150,
|
|
3579
|
+
250
|
|
3580
|
+
));
|
|
3581
|
+
assert!(!segment_overlaps_graph_interval(
|
|
3582
|
+
&legacy,
|
|
3583
|
+
"current-epoch",
|
|
3584
|
+
0,
|
|
3585
|
+
1
|
|
3586
|
+
));
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
#[test]
|
|
3590
|
+
fn transcript_lifecycle_only_indexes_clean_visuals_audio_segments() {
|
|
3591
|
+
let system = std::env::temp_dir().join(format!(
|
|
3592
|
+
"clips-transcript-system-{}.wav",
|
|
3593
|
+
std::process::id()
|
|
3594
|
+
));
|
|
3595
|
+
let microphone = std::env::temp_dir().join(format!(
|
|
3596
|
+
"clips-transcript-microphone-{}.wav",
|
|
3597
|
+
std::process::id()
|
|
3598
|
+
));
|
|
3599
|
+
std::fs::write(&system, b"audio").unwrap();
|
|
3600
|
+
std::fs::write(µphone, b"audio").unwrap();
|
|
3601
|
+
let mut segment: ScreenMemorySegmentMetadata = serde_json::from_value(serde_json::json!({
|
|
3602
|
+
"id": "segment-1", "path": "/tmp/segment.mp4", "fileName": "segment.mp4",
|
|
3603
|
+
"mimeType": "video/mp4", "startedAt": "2026-07-14T12:00:00Z",
|
|
3604
|
+
"endedAt": "2026-07-14T12:01:00Z", "durationMs": 60000, "width": 1,
|
|
3605
|
+
"height": 1, "bytes": 1, "corrupt": false, "error": null,
|
|
3606
|
+
"captureMode": "visuals-audio",
|
|
3607
|
+
"systemAudioPath": system.clone(),
|
|
3608
|
+
"microphonePath": microphone.clone()
|
|
3609
|
+
}))
|
|
3610
|
+
.unwrap();
|
|
3611
|
+
assert!(segment_transcript_eligible(&segment));
|
|
3612
|
+
segment.exclusion_tainted = true;
|
|
3613
|
+
assert!(!segment_derivatives_eligible(&segment));
|
|
3614
|
+
assert!(!segment_transcript_eligible(&segment));
|
|
3615
|
+
segment.exclusion_tainted = false;
|
|
3616
|
+
segment.corrupt = true;
|
|
3617
|
+
assert!(!segment_transcript_eligible(&segment));
|
|
3618
|
+
segment.corrupt = false;
|
|
3619
|
+
segment.error = Some("capture failed".into());
|
|
3620
|
+
assert!(!segment_transcript_eligible(&segment));
|
|
3621
|
+
segment.error = None;
|
|
3622
|
+
segment.capture_mode = RewindCaptureMode::Visuals;
|
|
3623
|
+
assert!(!segment_transcript_eligible(&segment));
|
|
3624
|
+
let _ = std::fs::remove_file(system);
|
|
3625
|
+
let _ = std::fs::remove_file(microphone);
|
|
3626
|
+
}
|
|
3627
|
+
|
|
3628
|
+
#[test]
|
|
3629
|
+
fn status_explains_recognized_media_exclusion_and_coverage_gap() {
|
|
3630
|
+
let config = ScreenMemoryConfig {
|
|
3631
|
+
enabled: true,
|
|
3632
|
+
paused: false,
|
|
3633
|
+
..ScreenMemoryConfig::default()
|
|
3634
|
+
};
|
|
3635
|
+
let coverage = coverage_language(&config, true, false);
|
|
3636
|
+
assert!(coverage.contains("entire in-flight media segment was discarded"));
|
|
3637
|
+
assert!(coverage.contains("source coverage gap"));
|
|
3638
|
+
}
|
|
3639
|
+
|
|
3640
|
+
#[test]
|
|
3641
|
+
fn transcript_sidecars_are_deleted_together() {
|
|
3642
|
+
let dir = std::env::temp_dir().join(format!(
|
|
3643
|
+
"clips-transcript-sidecars-{}-{}",
|
|
3644
|
+
std::process::id(),
|
|
3645
|
+
SEGMENT_COUNTER.fetch_add(1, Ordering::Relaxed)
|
|
3646
|
+
));
|
|
3647
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
3648
|
+
let (rows, status) = transcript_sidecar_paths(&dir, "segment/one");
|
|
3649
|
+
std::fs::write(&rows, b"{}\n").unwrap();
|
|
3650
|
+
std::fs::write(&status, b"{}").unwrap();
|
|
3651
|
+
remove_transcript_sidecars(&dir, "segment/one").unwrap();
|
|
3652
|
+
assert!(!rows.exists());
|
|
3653
|
+
assert!(!status.exists());
|
|
3654
|
+
std::fs::remove_dir_all(dir).unwrap();
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
#[test]
|
|
3658
|
+
fn retains_only_parseable_events_within_the_retention_window() {
|
|
3659
|
+
let cutoff = DateTime::parse_from_rfc3339("2026-07-14T12:00:00Z")
|
|
3660
|
+
.unwrap()
|
|
3661
|
+
.with_timezone(&Utc);
|
|
3662
|
+
let events = retain_events_since(
|
|
3663
|
+
vec![
|
|
3664
|
+
event("2026-07-14T11:59:59Z"),
|
|
3665
|
+
event("2026-07-14T12:00:00Z"),
|
|
3666
|
+
event("not-a-date"),
|
|
3667
|
+
],
|
|
3668
|
+
cutoff,
|
|
3669
|
+
);
|
|
3670
|
+
|
|
3671
|
+
assert_eq!(events.len(), 1);
|
|
3672
|
+
assert_eq!(events[0].captured_at, "2026-07-14T12:00:00Z");
|
|
3673
|
+
}
|
|
3674
|
+
|
|
3675
|
+
#[test]
|
|
3676
|
+
fn app_context_is_pruned_with_media_while_coverage_gaps_remain_visible() {
|
|
3677
|
+
let cutoff = DateTime::parse_from_rfc3339("2026-07-14T12:00:00Z")
|
|
3678
|
+
.unwrap()
|
|
3679
|
+
.with_timezone(&Utc);
|
|
3680
|
+
let coverage = [(
|
|
3681
|
+
DateTime::parse_from_rfc3339("2026-07-14T12:05:00Z")
|
|
3682
|
+
.unwrap()
|
|
3683
|
+
.with_timezone(&Utc),
|
|
3684
|
+
DateTime::parse_from_rfc3339("2026-07-14T12:10:00Z")
|
|
3685
|
+
.unwrap()
|
|
3686
|
+
.with_timezone(&Utc),
|
|
3687
|
+
)];
|
|
3688
|
+
let mut gap = event("2026-07-14T12:03:00Z");
|
|
3689
|
+
gap.source = "coverage-gap".into();
|
|
3690
|
+
let events = retain_events_with_media_coverage(
|
|
3691
|
+
vec![
|
|
3692
|
+
event("2026-07-14T12:02:00Z"),
|
|
3693
|
+
gap,
|
|
3694
|
+
event("2026-07-14T12:06:00Z"),
|
|
3695
|
+
],
|
|
3696
|
+
cutoff,
|
|
3697
|
+
&coverage,
|
|
3698
|
+
);
|
|
3699
|
+
|
|
3700
|
+
assert_eq!(events.len(), 2);
|
|
3701
|
+
assert_eq!(events[0].source, "coverage-gap");
|
|
3702
|
+
assert_eq!(events[1].captured_at, "2026-07-14T12:06:00Z");
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
#[test]
|
|
3706
|
+
fn temporary_audio_refcounts_restore_the_persisted_base_mode() {
|
|
3707
|
+
assert_eq!(
|
|
3708
|
+
effective_mode(RewindCaptureMode::Visuals, 0),
|
|
3709
|
+
RewindCaptureMode::Visuals
|
|
3710
|
+
);
|
|
3711
|
+
assert_eq!(
|
|
3712
|
+
effective_mode(RewindCaptureMode::Visuals, 1),
|
|
3713
|
+
RewindCaptureMode::VisualsAudio
|
|
3714
|
+
);
|
|
3715
|
+
assert_eq!(
|
|
3716
|
+
effective_mode(RewindCaptureMode::Visuals, 2),
|
|
3717
|
+
RewindCaptureMode::VisualsAudio
|
|
3718
|
+
);
|
|
3719
|
+
assert_eq!(
|
|
3720
|
+
effective_mode(RewindCaptureMode::VisualsAudio, 0),
|
|
3721
|
+
RewindCaptureMode::VisualsAudio
|
|
3722
|
+
);
|
|
3723
|
+
|
|
3724
|
+
let mut runtime = ScreenMemoryRuntime::default();
|
|
3725
|
+
runtime.temporary_audio_consumers.insert(
|
|
3726
|
+
"meeting-whisper".into(),
|
|
3727
|
+
TemporaryAudioConsumer {
|
|
3728
|
+
graph_lease_id: "meeting-lease".into(),
|
|
3729
|
+
},
|
|
3730
|
+
);
|
|
3731
|
+
runtime.temporary_audio_consumers.insert(
|
|
3732
|
+
"rewind-clip".into(),
|
|
3733
|
+
TemporaryAudioConsumer {
|
|
3734
|
+
graph_lease_id: "clip-lease".into(),
|
|
3735
|
+
},
|
|
3736
|
+
);
|
|
3737
|
+
assert_eq!(
|
|
3738
|
+
requested_sources(effective_mode(
|
|
3739
|
+
RewindCaptureMode::Visuals,
|
|
3740
|
+
runtime.temporary_audio_consumers.len()
|
|
3741
|
+
)),
|
|
3742
|
+
vec![
|
|
3743
|
+
CaptureSource::Screen,
|
|
3744
|
+
CaptureSource::SystemAudio,
|
|
3745
|
+
CaptureSource::Microphone
|
|
3746
|
+
]
|
|
3747
|
+
);
|
|
3748
|
+
runtime.temporary_audio_consumers.remove("rewind-clip");
|
|
3749
|
+
assert_eq!(
|
|
3750
|
+
effective_mode(
|
|
3751
|
+
RewindCaptureMode::Visuals,
|
|
3752
|
+
runtime.temporary_audio_consumers.len()
|
|
3753
|
+
),
|
|
3754
|
+
RewindCaptureMode::VisualsAudio
|
|
3755
|
+
);
|
|
3756
|
+
runtime.temporary_audio_consumers.remove("meeting-whisper");
|
|
3757
|
+
assert_eq!(
|
|
3758
|
+
effective_mode(
|
|
3759
|
+
RewindCaptureMode::Visuals,
|
|
3760
|
+
runtime.temporary_audio_consumers.len()
|
|
3761
|
+
),
|
|
3762
|
+
RewindCaptureMode::Visuals
|
|
3763
|
+
);
|
|
3764
|
+
}
|
|
3765
|
+
|
|
3766
|
+
#[test]
|
|
3767
|
+
fn temporary_audio_requires_a_live_unpaused_rewind_producer() {
|
|
3768
|
+
assert!(temporary_audio_lease_available(
|
|
3769
|
+
true, false, true, true, false
|
|
3770
|
+
));
|
|
3771
|
+
assert!(!temporary_audio_lease_available(
|
|
3772
|
+
false, false, true, true, true
|
|
3773
|
+
));
|
|
3774
|
+
assert!(!temporary_audio_lease_available(
|
|
3775
|
+
true, true, true, true, true
|
|
3776
|
+
));
|
|
3777
|
+
assert!(!temporary_audio_lease_available(
|
|
3778
|
+
true, false, false, true, true
|
|
3779
|
+
));
|
|
3780
|
+
assert!(!temporary_audio_lease_available(
|
|
3781
|
+
true, false, true, false, false
|
|
3782
|
+
));
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3785
|
+
#[test]
|
|
3786
|
+
fn terminal_handoffs_do_not_retain_agent_reason_text() {
|
|
3787
|
+
let mut object = serde_json::json!({
|
|
3788
|
+
"status": "pending",
|
|
3789
|
+
"reason": "private words from the requested interval"
|
|
3790
|
+
})
|
|
3791
|
+
.as_object()
|
|
3792
|
+
.unwrap()
|
|
3793
|
+
.clone();
|
|
3794
|
+
scrub_terminal_handoff_fields(&mut object, "processing");
|
|
3795
|
+
assert!(object.contains_key("reason"));
|
|
3796
|
+
scrub_terminal_handoff_fields(&mut object, "declined");
|
|
3797
|
+
assert!(!object.contains_key("reason"));
|
|
3798
|
+
}
|
|
3799
|
+
}
|