@agent-native/core 0.114.5 → 0.114.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +14 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/mcp-config-writers.ts +6 -4
  5. package/corpus/core/src/cli/mcp.ts +124 -0
  6. package/corpus/core/src/cli/skills-content/help.ts +4 -3
  7. package/corpus/core/src/cli/skills-content/index.ts +1 -0
  8. package/corpus/core/src/cli/skills-content/rewind-skill.ts +52 -0
  9. package/corpus/core/src/cli/skills.ts +115 -13
  10. package/corpus/core/src/client/resources/ResourceTree.tsx +11 -5
  11. package/corpus/core/src/client/resources/ResourcesPanel.tsx +141 -18
  12. package/corpus/core/src/mcp/screen-memory-stdio.ts +1360 -104
  13. package/corpus/core/src/mcp-client/index.ts +9 -0
  14. package/corpus/core/src/mcp-client/screen-memory-local.ts +847 -13
  15. package/corpus/templates/clips/actions/apply-rewind-extension.ts +195 -0
  16. package/corpus/templates/clips/actions/delete-agent-recording-if-unpromoted.ts +64 -0
  17. package/corpus/templates/clips/actions/get-rewind-extension-request.ts +22 -0
  18. package/corpus/templates/clips/actions/get-screen-memory-status.ts +18 -2
  19. package/corpus/templates/clips/actions/list-rewind-extension-requests.ts +52 -0
  20. package/corpus/templates/clips/actions/make-recording-private-for-rewind.ts +55 -0
  21. package/corpus/templates/clips/actions/query-screen-memory-context.ts +4 -4
  22. package/corpus/templates/clips/actions/request-rewind-extension.ts +81 -0
  23. package/corpus/templates/clips/actions/update-rewind-extension-request.ts +47 -0
  24. package/corpus/templates/clips/app/components/editor/editor-layout.tsx +25 -0
  25. package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +36 -0
  26. package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +338 -0
  27. package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +1 -16
  28. package/corpus/templates/clips/app/components/editor/timeline.tsx +26 -0
  29. package/corpus/templates/clips/app/components/meetings/meeting-card.tsx +1 -2
  30. package/corpus/templates/clips/app/i18n/en-US.ts +18 -0
  31. package/corpus/templates/clips/app/lib/ffmpeg-export.ts +19 -12
  32. package/corpus/templates/clips/app/lib/rewind-visibility.ts +31 -0
  33. package/corpus/templates/clips/app/lib/timestamp-mapping.ts +7 -0
  34. package/corpus/templates/clips/app/lib/upload-file-client.ts +17 -0
  35. package/corpus/templates/clips/changelog/2026-07-14-rewind-keeps-recent-screen-context-local-and-turns-it-into-clips.md +6 -0
  36. package/corpus/templates/clips/changelog/2026-07-17-active-clips-keep-rewind-stable-and-settings-clear.md +12 -0
  37. package/corpus/templates/clips/changelog/2026-07-17-clicking-the-clips-icon-during-a-recording-now-opens-the-app.md +6 -0
  38. package/corpus/templates/clips/changelog/2026-07-17-default-mic-now-uses-the-mac-default-and-transcription-shares-one-capture.md +6 -0
  39. package/corpus/templates/clips/changelog/2026-07-17-full-screen-clips-finalize-even-when-screencapturekit-stop-hangs.md +6 -0
  40. package/corpus/templates/clips/changelog/2026-07-17-normal-full-screen-clips-no-longer-stop-immediately-after-th.md +6 -0
  41. package/corpus/templates/clips/changelog/2026-07-17-rewind-can-hand-one-bounded-private-clip-to-your-agent.md +12 -0
  42. package/corpus/templates/clips/changelog/2026-07-17-rewind-home-can-copy-a-local-agent-prompt.md +6 -0
  43. package/corpus/templates/clips/changelog/2026-07-17-rewind-now-has-a-calm-home-status-focused-first-run-consent-.md +6 -0
  44. package/corpus/templates/clips/changelog/2026-07-17-rewind-settings-are-smaller-clearer-and-agent-first.md +6 -0
  45. package/corpus/templates/clips/changelog/2026-07-17-the-full-clips-interface-stays-available-while-recording.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-07-18-rewind-organizes-recent-work-into-local-searchable-chapters.md +8 -0
  47. package/corpus/templates/clips/changelog/2026-07-18-searchable-rewind-chapters.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-19-rewind-backed-recordings-now-start-immediately-after-the-cou.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-20-opening-zoom-meeting-launches-native-app.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-07-20-rewind-can-set-up-your-local-agent-once-so-future-requests-w.md +6 -0
  51. package/corpus/templates/clips/desktop/package.json +1 -0
  52. package/corpus/templates/clips/desktop/scripts/sign-macos-local.ts +12 -1
  53. package/corpus/templates/clips/desktop/src/app.tsx +2478 -223
  54. package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +21 -1
  55. package/corpus/templates/clips/desktop/src/components/Switch.tsx +3 -0
  56. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +156 -24
  57. package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +6 -7
  58. package/corpus/templates/clips/desktop/src/lib/open-meeting-join-url.ts +22 -0
  59. package/corpus/templates/clips/desktop/src/lib/recorder.ts +1763 -1160
  60. package/corpus/templates/clips/desktop/src/lib/rewind-agent-prompt.ts +9 -0
  61. package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +20 -0
  62. package/corpus/templates/clips/desktop/src/main.tsx +3 -0
  63. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +14 -14
  64. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +2 -3
  65. package/corpus/templates/clips/desktop/src/overlays/onboarding.tsx +4 -4
  66. package/corpus/templates/clips/desktop/src/overlays/preparing.tsx +13 -0
  67. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +27 -2
  68. package/corpus/templates/clips/desktop/src/shared/config.ts +16 -0
  69. package/corpus/templates/clips/desktop/src/styles.css +956 -134
  70. package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +5 -0
  71. package/corpus/templates/clips/desktop/src-tauri/build.rs +69 -1
  72. package/corpus/templates/clips/desktop/src-tauri/src/accessibility.rs +386 -12
  73. package/corpus/templates/clips/desktop/src-tauri/src/capture_audio_bus.rs +382 -0
  74. package/corpus/templates/clips/desktop/src-tauri/src/capture_graph.rs +777 -0
  75. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +249 -22
  76. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +148 -1
  77. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +58 -4
  78. package/corpus/templates/clips/desktop/src-tauri/src/logfile.rs +13 -1
  79. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +1505 -51
  80. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +124 -0
  81. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +1006 -95
  82. package/corpus/templates/clips/desktop/src-tauri/src/rewind_capture_suspension.rs +165 -0
  83. package/corpus/templates/clips/desktop/src-tauri/src/rewind_chapters.rs +2434 -0
  84. package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +1650 -0
  85. package/corpus/templates/clips/desktop/src-tauri/src/rewind_egress.rs +643 -0
  86. package/corpus/templates/clips/desktop/src-tauri/src/rewind_local_ask.rs +803 -0
  87. package/corpus/templates/clips/desktop/src-tauri/src/rewind_meeting_history.rs +595 -0
  88. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +2900 -91
  89. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_ocr.rs +360 -0
  90. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_transcript.rs +408 -0
  91. package/corpus/templates/clips/desktop/src-tauri/src/shortcuts.rs +114 -33
  92. package/corpus/templates/clips/desktop/src-tauri/src/state.rs +2 -2
  93. package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +13 -3
  94. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +4 -1
  95. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +7 -13
  96. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +198 -11
  97. package/corpus/templates/clips/desktop/src-tauri/tauri.alpha.conf.json +17 -0
  98. package/corpus/templates/clips/shared/meeting-join-url.ts +0 -5
  99. package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +9 -1
  100. package/corpus/templates/content/AGENTS.md +4 -1
  101. package/corpus/templates/content/actions/_content-spaces.ts +6 -1
  102. package/corpus/templates/content/actions/_database-row-batch.ts +15 -2
  103. package/corpus/templates/content/actions/_database-utils.ts +83 -15
  104. package/corpus/templates/content/actions/add-database-item.ts +3 -0
  105. package/corpus/templates/content/actions/connect-local-folder-source.ts +2 -1
  106. package/corpus/templates/content/actions/delete-content-database.ts +12 -9
  107. package/corpus/templates/content/actions/delete-database-items.ts +3 -2
  108. package/corpus/templates/content/actions/delete-document.ts +233 -6
  109. package/corpus/templates/content/actions/duplicate-database-item.ts +1 -0
  110. package/corpus/templates/content/actions/get-document.ts +4 -1
  111. package/corpus/templates/content/actions/list-content-databases.ts +1 -0
  112. package/corpus/templates/content/actions/list-documents.ts +1 -0
  113. package/corpus/templates/content/actions/list-trashed-content-databases.ts +8 -2
  114. package/corpus/templates/content/actions/list-trashed-documents.ts +50 -0
  115. package/corpus/templates/content/actions/permanently-delete-document.ts +28 -0
  116. package/corpus/templates/content/actions/pull-document.ts +15 -2
  117. package/corpus/templates/content/actions/restore-content-database.ts +48 -11
  118. package/corpus/templates/content/actions/restore-document.ts +28 -0
  119. package/corpus/templates/content/actions/search-documents.ts +2 -1
  120. package/corpus/templates/content/actions/view-screen.ts +2 -1
  121. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +149 -55
  122. package/corpus/templates/content/app/components/editor/database/GalleryView.tsx +7 -2
  123. package/corpus/templates/content/app/components/editor/database/ListView.tsx +7 -2
  124. package/corpus/templates/content/app/components/editor/database/TimelineView.tsx +3 -0
  125. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +26 -5
  126. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +243 -163
  127. package/corpus/templates/content/app/components/sidebar/WorkspaceSourceMenu.tsx +154 -0
  128. package/corpus/templates/content/app/components/sidebar/select-content-space.ts +11 -0
  129. package/corpus/templates/content/app/hooks/use-content-database.ts +6 -0
  130. package/corpus/templates/content/app/hooks/use-documents.ts +57 -0
  131. package/corpus/templates/content/app/i18n-data.ts +83 -2
  132. package/corpus/templates/content/app/routes/_app.local-files.tsx +8 -1
  133. package/corpus/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md +6 -0
  134. package/corpus/templates/content/changelog/2026-07-20-trash-actions-now-preserve-independent-archived-pages-enforc.md +6 -0
  135. package/corpus/templates/content/changelog/2026-07-20-workspace-toggles-now-stay-independently-open-or-closed-and-.md +6 -0
  136. package/corpus/templates/content/changelog/2026-07-20-workspaces-can-be-added-from-a-blank-workspace-or-a-connecte.md +6 -0
  137. package/corpus/templates/content/parity/matrix.md +1 -1
  138. package/corpus/templates/content/parity/matrix.ts +3 -0
  139. package/corpus/templates/content/server/db/schema.ts +2 -0
  140. package/corpus/templates/content/server/lib/public-documents.ts +8 -2
  141. package/corpus/templates/content/server/plugins/db.ts +52 -0
  142. package/corpus/templates/content/server/routes/api/document-agent-context.json.get.ts +2 -2
  143. package/corpus/templates/content/shared/api.ts +10 -0
  144. package/corpus/toolkit/CHANGELOG.md +6 -0
  145. package/corpus/toolkit/package.json +1 -1
  146. package/corpus/toolkit/src/ui/progress.tsx +3 -2
  147. package/dist/cli/mcp-config-writers.d.ts +2 -2
  148. package/dist/cli/mcp-config-writers.d.ts.map +1 -1
  149. package/dist/cli/mcp-config-writers.js +6 -6
  150. package/dist/cli/mcp-config-writers.js.map +1 -1
  151. package/dist/cli/mcp.d.ts +14 -0
  152. package/dist/cli/mcp.d.ts.map +1 -1
  153. package/dist/cli/mcp.js +79 -0
  154. package/dist/cli/mcp.js.map +1 -1
  155. package/dist/cli/skills-content/help.d.ts +1 -1
  156. package/dist/cli/skills-content/help.d.ts.map +1 -1
  157. package/dist/cli/skills-content/help.js +4 -3
  158. package/dist/cli/skills-content/help.js.map +1 -1
  159. package/dist/cli/skills-content/index.d.ts +1 -0
  160. package/dist/cli/skills-content/index.d.ts.map +1 -1
  161. package/dist/cli/skills-content/index.js +1 -0
  162. package/dist/cli/skills-content/index.js.map +1 -1
  163. package/dist/cli/skills-content/rewind-skill.d.ts +2 -0
  164. package/dist/cli/skills-content/rewind-skill.d.ts.map +1 -0
  165. package/dist/cli/skills-content/rewind-skill.js +53 -0
  166. package/dist/cli/skills-content/rewind-skill.js.map +1 -0
  167. package/dist/cli/skills.d.ts +6 -0
  168. package/dist/cli/skills.d.ts.map +1 -1
  169. package/dist/cli/skills.js +90 -14
  170. package/dist/cli/skills.js.map +1 -1
  171. package/dist/client/resources/ResourceTree.d.ts +3 -1
  172. package/dist/client/resources/ResourceTree.d.ts.map +1 -1
  173. package/dist/client/resources/ResourceTree.js +4 -4
  174. package/dist/client/resources/ResourceTree.js.map +1 -1
  175. package/dist/client/resources/ResourcesPanel.d.ts.map +1 -1
  176. package/dist/client/resources/ResourcesPanel.js +55 -6
  177. package/dist/client/resources/ResourcesPanel.js.map +1 -1
  178. package/dist/collab/routes.d.ts +1 -1
  179. package/dist/collab/struct-routes.d.ts +1 -1
  180. package/dist/mcp/screen-memory-stdio.d.ts +365 -0
  181. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
  182. package/dist/mcp/screen-memory-stdio.js +1014 -91
  183. package/dist/mcp/screen-memory-stdio.js.map +1 -1
  184. package/dist/mcp-client/index.d.ts +1 -1
  185. package/dist/mcp-client/index.d.ts.map +1 -1
  186. package/dist/mcp-client/index.js +1 -1
  187. package/dist/mcp-client/index.js.map +1 -1
  188. package/dist/mcp-client/screen-memory-local.d.ts +90 -0
  189. package/dist/mcp-client/screen-memory-local.d.ts.map +1 -1
  190. package/dist/mcp-client/screen-memory-local.js +590 -12
  191. package/dist/mcp-client/screen-memory-local.js.map +1 -1
  192. package/dist/notifications/routes.d.ts +2 -2
  193. package/dist/observability/routes.d.ts +5 -5
  194. package/dist/progress/routes.d.ts +1 -1
  195. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  196. package/dist/resources/handlers.d.ts +1 -1
  197. package/dist/secrets/routes.d.ts +9 -9
  198. package/dist/server/transcribe-voice.d.ts +1 -1
  199. package/package.json +2 -2
  200. package/src/cli/mcp-config-writers.ts +6 -4
  201. package/src/cli/mcp.ts +124 -0
  202. package/src/cli/skills-content/help.ts +4 -3
  203. package/src/cli/skills-content/index.ts +1 -0
  204. package/src/cli/skills-content/rewind-skill.ts +52 -0
  205. package/src/cli/skills.ts +115 -13
  206. package/src/client/resources/ResourceTree.tsx +11 -5
  207. package/src/client/resources/ResourcesPanel.tsx +141 -18
  208. package/src/mcp/screen-memory-stdio.ts +1360 -104
  209. package/src/mcp-client/index.ts +9 -0
  210. package/src/mcp-client/screen-memory-local.ts +847 -13
@@ -0,0 +1,1650 @@
1
+ use crate::capture_graph::{CaptureConsumer, CaptureGraphState, CaptureInterval, CaptureSource};
2
+ use crate::native_screen::{
3
+ self, FinalizedNativeArtifact, NativeAudioSelection, NativeFullscreenSaveResult,
4
+ NativeFullscreenUploadResult, NativeMediaSlice, NativeUploadMode,
5
+ };
6
+ use crate::screen_memory::{self, ScreenMemorySegmentMetadata};
7
+ use chrono::{DateTime, Utc};
8
+ use serde::Serialize;
9
+ use std::collections::BTreeSet;
10
+ use std::path::PathBuf;
11
+ #[cfg(target_os = "macos")]
12
+ use std::process::{Command, Stdio};
13
+ use std::sync::Mutex;
14
+ use tauri::{AppHandle, Manager, State};
15
+
16
+ const RETROSPECTIVE_30_SECONDS: u64 = 30;
17
+ const RETROSPECTIVE_5_MINUTES: u64 = 5 * 60;
18
+ const REWIND_CLIP_AUDIO_OWNER: &str = "rewind-clip";
19
+
20
+ #[derive(Default)]
21
+ pub(crate) struct RewindClipState(Mutex<Option<ActiveRewindClip>>);
22
+
23
+ pub(crate) fn is_active(app: &AppHandle) -> bool {
24
+ app.try_state::<RewindClipState>()
25
+ .and_then(|state| {
26
+ state
27
+ .0
28
+ .lock()
29
+ .ok()
30
+ .map(|active| active.as_ref().is_some_and(|clip| clip.activated))
31
+ })
32
+ .unwrap_or(false)
33
+ }
34
+
35
+ struct ActiveRewindClip {
36
+ activated: bool,
37
+ lease_id: Option<String>,
38
+ pin_id: String,
39
+ started_elapsed_ms: u64,
40
+ pinned_segments: BTreeSet<String>,
41
+ sources: Vec<CaptureSource>,
42
+ retrospective_seconds: u64,
43
+ intervals: Vec<CaptureInterval>,
44
+ paused: bool,
45
+ temporary_audio: Option<screen_memory::TemporaryAudioLease>,
46
+ #[cfg(target_os = "macos")]
47
+ shared_sink: Option<native_screen::SharedClipSink>,
48
+ }
49
+
50
+ #[derive(Clone, Debug, Serialize)]
51
+ #[serde(rename_all = "kebab-case")]
52
+ pub(crate) enum RewindClipCompatibility {
53
+ Compatible,
54
+ NotCompatible,
55
+ }
56
+
57
+ #[derive(Clone, Debug, Serialize)]
58
+ #[serde(rename_all = "camelCase")]
59
+ pub(crate) struct RewindClipStatus {
60
+ compatibility: RewindClipCompatibility,
61
+ active: bool,
62
+ retrospective_seconds: u64,
63
+ paused: bool,
64
+ sources: Vec<CaptureSource>,
65
+ }
66
+
67
+ fn not_compatible(detail: impl AsRef<str>) -> String {
68
+ format!("rewind-not-compatible: {}", detail.as_ref())
69
+ }
70
+
71
+ #[tauri::command]
72
+ pub(crate) fn rewind_clip_status(
73
+ app: AppHandle,
74
+ state: State<'_, RewindClipState>,
75
+ ) -> Result<RewindClipStatus, String> {
76
+ let active_guard = state.0.lock().map_err(|error| error.to_string())?;
77
+ let active = active_guard.as_ref().is_some_and(|clip| clip.activated);
78
+ let compatible = screen_memory::rewind_clip_compatible(&app)?;
79
+ Ok(RewindClipStatus {
80
+ compatibility: if compatible {
81
+ RewindClipCompatibility::Compatible
82
+ } else {
83
+ RewindClipCompatibility::NotCompatible
84
+ },
85
+ active,
86
+ retrospective_seconds: active_guard
87
+ .as_ref()
88
+ .map(|active| active.retrospective_seconds)
89
+ .unwrap_or(0),
90
+ paused: active_guard.as_ref().is_some_and(|active| active.paused),
91
+ sources: active_guard
92
+ .as_ref()
93
+ .map(|active| active.sources.clone())
94
+ .unwrap_or_else(|| screen_memory::rewind_clip_sources(&app)),
95
+ })
96
+ }
97
+
98
+ /// Perform every potentially slow operation before the numeric countdown.
99
+ /// The resulting secondary writer shares Rewind's physical producer but is
100
+ /// still closed to samples until `rewind_clip_start` activates it at zero.
101
+ #[tauri::command]
102
+ pub(crate) fn rewind_clip_prepare(
103
+ app: AppHandle,
104
+ state: State<'_, RewindClipState>,
105
+ artifact_label: String,
106
+ server_url: Option<String>,
107
+ recording_id: Option<String>,
108
+ auth_token: Option<String>,
109
+ cookie: Option<String>,
110
+ include_mic: bool,
111
+ include_system_audio: bool,
112
+ has_camera: bool,
113
+ ) -> Result<RewindClipStatus, String> {
114
+ if !screen_memory::rewind_clip_compatible(&app)? {
115
+ return Err(not_compatible(
116
+ "Screen Memory is unavailable or not recording",
117
+ ));
118
+ }
119
+ if state.0.lock().map_err(|error| error.to_string())?.is_some() {
120
+ return Err("a Rewind-derived clip is already prepared or active".into());
121
+ }
122
+ let temporary_audio = if include_mic || include_system_audio {
123
+ screen_memory::acquire_temporary_audio_consumer(
124
+ &app,
125
+ REWIND_CLIP_AUDIO_OWNER,
126
+ CaptureConsumer::Clip,
127
+ include_mic,
128
+ include_system_audio,
129
+ )?
130
+ } else {
131
+ None
132
+ };
133
+ if (include_mic || include_system_audio) && temporary_audio.is_none() {
134
+ return Err(not_compatible("Screen Memory has no active audio producer"));
135
+ }
136
+ let sources = screen_memory::rewind_clip_sources(&app);
137
+ let output = artifact_path(&app, &artifact_label)?;
138
+ #[cfg(target_os = "macos")]
139
+ let shared_sink = match screen_memory::prepare_shared_clip_sink(
140
+ &app,
141
+ output,
142
+ include_mic,
143
+ include_system_audio,
144
+ has_camera,
145
+ native_screen::ClipLiveUploadConfig {
146
+ server_url,
147
+ recording_id,
148
+ auth_token,
149
+ cookie,
150
+ },
151
+ ) {
152
+ Ok(sink) => sink,
153
+ Err(error) => {
154
+ release_temporary_audio(&app, temporary_audio);
155
+ return Err(error);
156
+ }
157
+ };
158
+ #[cfg(not(target_os = "macos"))]
159
+ {
160
+ let _ = (
161
+ output,
162
+ server_url,
163
+ recording_id,
164
+ auth_token,
165
+ cookie,
166
+ has_camera,
167
+ );
168
+ release_temporary_audio(&app, temporary_audio);
169
+ return Err(not_compatible("shared Rewind Clip sinks require macOS"));
170
+ }
171
+ let response_sources = sources.clone();
172
+ let mut active = state.0.lock().map_err(|error| error.to_string())?;
173
+ if active.is_some() {
174
+ drop(active);
175
+ #[cfg(target_os = "macos")]
176
+ shared_sink.cancel();
177
+ release_temporary_audio(&app, temporary_audio);
178
+ return Err("a Rewind-derived clip is already prepared or active".into());
179
+ }
180
+ *active = Some(ActiveRewindClip {
181
+ activated: false,
182
+ pin_id: format!("rewind-clip-{}", Utc::now().timestamp_micros()),
183
+ lease_id: None,
184
+ started_elapsed_ms: 0,
185
+ pinned_segments: BTreeSet::new(),
186
+ sources,
187
+ retrospective_seconds: 0,
188
+ intervals: Vec::new(),
189
+ paused: false,
190
+ temporary_audio,
191
+ #[cfg(target_os = "macos")]
192
+ shared_sink: Some(shared_sink),
193
+ });
194
+ Ok(RewindClipStatus {
195
+ compatibility: RewindClipCompatibility::Compatible,
196
+ active: false,
197
+ retrospective_seconds: 0,
198
+ paused: false,
199
+ sources: response_sources,
200
+ })
201
+ }
202
+
203
+ /// Countdown zero/Enter boundary. Preparation has already installed the Clip
204
+ /// writer, so this path performs only in-memory graph and callback admission.
205
+ #[tauri::command]
206
+ pub(crate) fn rewind_clip_start(
207
+ app: AppHandle,
208
+ state: State<'_, RewindClipState>,
209
+ ) -> Result<RewindClipStatus, String> {
210
+ let started = std::time::Instant::now();
211
+ let mut active_guard = state.0.lock().map_err(|error| error.to_string())?;
212
+ let active = active_guard
213
+ .as_mut()
214
+ .ok_or_else(|| "no prepared Rewind-derived clip is available".to_string())?;
215
+ if active.activated {
216
+ return Err("a Rewind-derived clip is already active".into());
217
+ }
218
+ let boundary = std::time::Instant::now();
219
+ let lease = app
220
+ .state::<CaptureGraphState>()
221
+ .0
222
+ .lock()
223
+ .map_err(|error| error.to_string())?
224
+ .start_consumer_at(
225
+ CaptureConsumer::Clip,
226
+ active.sources.iter().copied(),
227
+ boundary,
228
+ )
229
+ .map_err(|error| error.to_string())?;
230
+ #[cfg(target_os = "macos")]
231
+ if let Err(error) = active
232
+ .shared_sink
233
+ .as_mut()
234
+ .ok_or_else(|| "prepared Rewind Clip sink is missing".to_string())?
235
+ .activate()
236
+ {
237
+ end_clip_graph_lease(&app, &lease.id);
238
+ return Err(error);
239
+ }
240
+ active.lease_id = Some(lease.id);
241
+ active.started_elapsed_ms = lease.interval.started_elapsed_ms;
242
+ active.activated = true;
243
+ crate::logfile::diagnostic(&format!(
244
+ "[rewind-latency] start boundary accepted in {}ms",
245
+ started.elapsed().as_millis()
246
+ ));
247
+ Ok(RewindClipStatus {
248
+ compatibility: RewindClipCompatibility::Compatible,
249
+ active: true,
250
+ retrospective_seconds: 0,
251
+ paused: false,
252
+ sources: active.sources.clone(),
253
+ })
254
+ }
255
+
256
+ fn release_temporary_audio(app: &AppHandle, lease: Option<screen_memory::TemporaryAudioLease>) {
257
+ if let Some(lease) = lease {
258
+ if let Err(error) = screen_memory::release_temporary_audio_consumer(app, lease) {
259
+ eprintln!("[clips-tray] Rewind Clip audio lease release failed: {error}");
260
+ }
261
+ }
262
+ }
263
+
264
+ fn end_clip_graph_lease(app: &AppHandle, lease_id: &str) {
265
+ if let Ok(mut graph) = app.state::<CaptureGraphState>().0.lock() {
266
+ let _ = graph.end_consumer(lease_id);
267
+ }
268
+ }
269
+
270
+ #[tauri::command]
271
+ pub(crate) fn rewind_clip_extend(
272
+ app: AppHandle,
273
+ state: State<'_, RewindClipState>,
274
+ seconds: u64,
275
+ ) -> Result<RewindClipStatus, String> {
276
+ if !matches!(seconds, RETROSPECTIVE_30_SECONDS | RETROSPECTIVE_5_MINUTES) {
277
+ return Err("retrospective extension must be exactly 30 or 300 seconds".into());
278
+ }
279
+ let lease_id = state
280
+ .0
281
+ .lock()
282
+ .map_err(|error| error.to_string())?
283
+ .as_ref()
284
+ .and_then(|active| active.lease_id.clone())
285
+ .ok_or_else(|| "no Rewind-derived clip is active".to_string())?;
286
+ let lease = app
287
+ .state::<CaptureGraphState>()
288
+ .0
289
+ .lock()
290
+ .map_err(|error| error.to_string())?
291
+ .extend_retrospectively(&lease_id, std::time::Duration::from_secs(seconds))
292
+ .map_err(|error| error.to_string())?;
293
+ {
294
+ let mut active = state.0.lock().map_err(|error| error.to_string())?;
295
+ let active = active
296
+ .as_mut()
297
+ .ok_or_else(|| "no Rewind-derived clip is active".to_string())?;
298
+ active.started_elapsed_ms = lease.interval.started_elapsed_ms;
299
+ active.retrospective_seconds = seconds;
300
+ for segment in screen_memory::finalized_segments_in_graph_interval(
301
+ &app,
302
+ lease.interval.started_elapsed_ms,
303
+ lease.interval.ended_elapsed_ms,
304
+ )? {
305
+ screen_memory::pin_segment(&app, &segment.id, &active.pin_id)?;
306
+ active.pinned_segments.insert(segment.id);
307
+ }
308
+ }
309
+ Ok(RewindClipStatus {
310
+ compatibility: RewindClipCompatibility::Compatible,
311
+ active: true,
312
+ retrospective_seconds: lease.retrospective_extension_ms / 1000,
313
+ paused: false,
314
+ sources: state
315
+ .0
316
+ .lock()
317
+ .map_err(|error| error.to_string())?
318
+ .as_ref()
319
+ .map(|active| active.sources.clone())
320
+ .unwrap_or_default(),
321
+ })
322
+ }
323
+
324
+ #[tauri::command]
325
+ pub(crate) fn rewind_clip_pause(
326
+ app: AppHandle,
327
+ state: State<'_, RewindClipState>,
328
+ ) -> Result<RewindClipStatus, String> {
329
+ let mut active_guard = state.0.lock().map_err(|error| error.to_string())?;
330
+ let active = active_guard
331
+ .as_mut()
332
+ .ok_or_else(|| "no Rewind-derived clip is active".to_string())?;
333
+ if !active.activated || active.paused {
334
+ return Err("Rewind-derived clip is already paused".into());
335
+ }
336
+ let lease_id = active
337
+ .lease_id
338
+ .clone()
339
+ .ok_or_else(|| "active Clip lease is missing".to_string())?;
340
+ let sources = active.sources.clone();
341
+ let retrospective_seconds = active.retrospective_seconds;
342
+ #[cfg(target_os = "macos")]
343
+ let sink = active
344
+ .shared_sink
345
+ .as_ref()
346
+ .ok_or_else(|| "active Rewind Clip sink is missing".to_string())?;
347
+ #[cfg(target_os = "macos")]
348
+ sink.pause()?;
349
+
350
+ let close_result = app
351
+ .state::<CaptureGraphState>()
352
+ .0
353
+ .lock()
354
+ .map_err(|error| error.to_string())
355
+ .and_then(|mut graph| {
356
+ graph
357
+ .end_consumer(&lease_id)
358
+ .map_err(|error| error.to_string())
359
+ });
360
+ let closed = match close_result {
361
+ Ok(closed) => closed,
362
+ Err(error) => {
363
+ #[cfg(target_os = "macos")]
364
+ if let Err(rollback_error) = sink.resume() {
365
+ return Err(format!(
366
+ "{error}. Clip pause rollback also failed: {rollback_error}"
367
+ ));
368
+ }
369
+ return Err(error);
370
+ }
371
+ };
372
+ let interval = closed.lease.interval;
373
+ active.intervals.push(interval);
374
+ active.lease_id = None;
375
+ active.paused = true;
376
+ Ok(RewindClipStatus {
377
+ compatibility: RewindClipCompatibility::Compatible,
378
+ active: true,
379
+ retrospective_seconds,
380
+ paused: true,
381
+ sources,
382
+ })
383
+ }
384
+
385
+ #[tauri::command]
386
+ pub(crate) fn rewind_clip_resume(
387
+ app: AppHandle,
388
+ state: State<'_, RewindClipState>,
389
+ ) -> Result<RewindClipStatus, String> {
390
+ let sources = {
391
+ let active = state.0.lock().map_err(|e| e.to_string())?;
392
+ let active = active
393
+ .as_ref()
394
+ .ok_or_else(|| "no Rewind-derived clip is active".to_string())?;
395
+ if !active.paused {
396
+ return Err("Rewind-derived clip is not paused".into());
397
+ }
398
+ active.sources.clone()
399
+ };
400
+ let lease = match app
401
+ .state::<CaptureGraphState>()
402
+ .0
403
+ .lock()
404
+ .map_err(|e| e.to_string())?
405
+ .start_consumer(CaptureConsumer::Clip, sources.iter().copied())
406
+ {
407
+ Ok(lease) => lease,
408
+ Err(error) => return Err(error.to_string()),
409
+ };
410
+ let mut active = state.0.lock().map_err(|e| e.to_string())?;
411
+ let active = active
412
+ .as_mut()
413
+ .ok_or_else(|| "no Rewind-derived clip is active".to_string())?;
414
+ #[cfg(target_os = "macos")]
415
+ if let Err(error) = active
416
+ .shared_sink
417
+ .as_ref()
418
+ .ok_or_else(|| "active Rewind Clip sink is missing".to_string())?
419
+ .resume()
420
+ {
421
+ end_clip_graph_lease(&app, &lease.id);
422
+ return Err(error);
423
+ }
424
+ active.lease_id = Some(lease.id);
425
+ active.started_elapsed_ms = lease.interval.started_elapsed_ms;
426
+ active.sources = sources;
427
+ active.paused = false;
428
+ Ok(RewindClipStatus {
429
+ compatibility: RewindClipCompatibility::Compatible,
430
+ active: true,
431
+ retrospective_seconds: active.retrospective_seconds,
432
+ paused: false,
433
+ sources: active.sources.clone(),
434
+ })
435
+ }
436
+
437
+ fn take_active(state: &State<'_, RewindClipState>) -> Result<ActiveRewindClip, String> {
438
+ state
439
+ .0
440
+ .lock()
441
+ .map_err(|error| error.to_string())?
442
+ .take()
443
+ .ok_or_else(|| "no Rewind-derived clip is active".to_string())
444
+ }
445
+
446
+ fn close_direct_clip_interval(
447
+ app: &AppHandle,
448
+ active: &mut ActiveRewindClip,
449
+ ) -> Result<(), String> {
450
+ if !active.activated {
451
+ return Err("prepared Rewind-derived clip never reached countdown zero".into());
452
+ }
453
+ #[cfg(target_os = "macos")]
454
+ active
455
+ .shared_sink
456
+ .as_ref()
457
+ .ok_or_else(|| "active Rewind Clip sink is missing".to_string())?
458
+ .deactivate();
459
+ if let Some(lease_id) = active.lease_id.take() {
460
+ let closed = app
461
+ .state::<CaptureGraphState>()
462
+ .0
463
+ .lock()
464
+ .map_err(|error| error.to_string())?
465
+ .end_consumer(&lease_id)
466
+ .map_err(|error| error.to_string())?;
467
+ active.intervals.push(closed.lease.interval);
468
+ }
469
+ active.paused = false;
470
+ Ok(())
471
+ }
472
+
473
+ fn release_active_resources(app: &AppHandle, active: &mut ActiveRewindClip) {
474
+ for segment_id in std::mem::take(&mut active.pinned_segments) {
475
+ let _ = screen_memory::unpin_segment(app, &segment_id, &active.pin_id);
476
+ }
477
+ release_temporary_audio(app, active.temporary_audio.take());
478
+ }
479
+
480
+ fn artifact_path(app: &AppHandle, label: &str) -> Result<PathBuf, String> {
481
+ let dir = app
482
+ .path()
483
+ .app_local_data_dir()
484
+ .map_err(|error| format!("local data directory unavailable: {error}"))?
485
+ .join("pending-recordings");
486
+ std::fs::create_dir_all(&dir)
487
+ .map_err(|error| format!("pending recording directory unavailable: {error}"))?;
488
+ Ok(dir.join(format!(
489
+ "rewind-{label}-{}.mp4",
490
+ chrono::Utc::now().timestamp_millis()
491
+ )))
492
+ }
493
+
494
+ fn validate_and_plan(
495
+ segments: &[ScreenMemorySegmentMetadata],
496
+ start_ms: u64,
497
+ end_ms: u64,
498
+ ) -> Result<Vec<NativeMediaSlice>, String> {
499
+ const MAX_SEGMENT_BOUNDARY_QUANTIZATION_MS: u64 = 2;
500
+ let mut segments = segments.to_vec();
501
+ segments.sort_by_key(|segment| segment.graph_started_elapsed_ms);
502
+ let mut cursor = start_ms;
503
+ let mut slices = Vec::new();
504
+ for segment in segments {
505
+ if segment.corrupt || segment.error.is_some() || segment.exclusion_tainted {
506
+ return Err("selected Rewind media is corrupt, tainted, or incomplete".into());
507
+ }
508
+ let overlap_start = start_ms.max(segment.graph_started_elapsed_ms);
509
+ let overlap_end = end_ms.min(segment.graph_ended_elapsed_ms);
510
+ if overlap_start >= overlap_end {
511
+ continue;
512
+ }
513
+ if overlap_start.saturating_sub(cursor) > MAX_SEGMENT_BOUNDARY_QUANTIZATION_MS {
514
+ return Err("selected Rewind interval contains a coverage gap".into());
515
+ }
516
+ slices.push(NativeMediaSlice {
517
+ path: segment.path,
518
+ system_audio_path: segment.system_audio_path,
519
+ microphone_path: segment.microphone_path,
520
+ start_ms: overlap_start - segment.graph_started_elapsed_ms,
521
+ end_ms: overlap_end - segment.graph_started_elapsed_ms,
522
+ });
523
+ cursor = cursor.max(overlap_end);
524
+ }
525
+ if end_ms.saturating_sub(cursor) > MAX_SEGMENT_BOUNDARY_QUANTIZATION_MS || slices.is_empty() {
526
+ return Err("selected Rewind interval is not fully covered".into());
527
+ }
528
+ Ok(slices)
529
+ }
530
+
531
+ fn validate_and_plan_wall_clock(
532
+ segments: &[ScreenMemorySegmentMetadata],
533
+ started: DateTime<Utc>,
534
+ ended: DateTime<Utc>,
535
+ ) -> Result<(Vec<NativeMediaSlice>, Vec<ScreenMemorySegmentMetadata>), String> {
536
+ const MAX_SEGMENT_BOUNDARY_QUANTIZATION_MS: i64 = 2;
537
+ let mut timed = segments
538
+ .iter()
539
+ .filter_map(|segment| {
540
+ let segment_started = DateTime::parse_from_rfc3339(&segment.started_at)
541
+ .ok()?
542
+ .with_timezone(&Utc);
543
+ let segment_ended = DateTime::parse_from_rfc3339(&segment.ended_at)
544
+ .ok()?
545
+ .with_timezone(&Utc);
546
+ Some((segment_started, segment_ended, segment.clone()))
547
+ })
548
+ .collect::<Vec<_>>();
549
+ timed.sort_by_key(|(segment_started, _, _)| *segment_started);
550
+
551
+ let mut cursor = started;
552
+ let mut slices = Vec::new();
553
+ let mut selected = Vec::new();
554
+ let mut previous: Option<(Option<String>, u64, DateTime<Utc>)> = None;
555
+ for (wall_started, wall_ended, segment) in timed {
556
+ let graph_duration_ms = segment
557
+ .graph_ended_elapsed_ms
558
+ .checked_sub(segment.graph_started_elapsed_ms)
559
+ .filter(|duration| *duration > 0);
560
+ let duration_ms = graph_duration_ms.unwrap_or_else(|| {
561
+ wall_ended
562
+ .signed_duration_since(wall_started)
563
+ .num_milliseconds()
564
+ .max(0) as u64
565
+ });
566
+ let normalized_started = previous
567
+ .as_ref()
568
+ .and_then(
569
+ |(previous_epoch, previous_graph_end, previous_normalized_end)| {
570
+ let same_epoch = segment.graph_epoch_id.is_some()
571
+ && segment.graph_epoch_id == *previous_epoch;
572
+ let graph_skew = segment
573
+ .graph_started_elapsed_ms
574
+ .abs_diff(*previous_graph_end);
575
+ (same_epoch && graph_skew <= 2).then_some(*previous_normalized_end)
576
+ },
577
+ )
578
+ .unwrap_or(wall_started);
579
+ let normalized_ended = normalized_started
580
+ + chrono::Duration::milliseconds(i64::try_from(duration_ms).unwrap_or(i64::MAX));
581
+ previous = Some((
582
+ segment.graph_epoch_id.clone(),
583
+ segment.graph_ended_elapsed_ms,
584
+ normalized_ended,
585
+ ));
586
+
587
+ let overlap_start = started.max(normalized_started).max(cursor);
588
+ let overlap_end = ended.min(normalized_ended);
589
+ if overlap_start >= overlap_end {
590
+ continue;
591
+ }
592
+ if segment.corrupt || segment.error.is_some() || segment.exclusion_tainted {
593
+ return Err("selected Rewind media is corrupt, tainted, or incomplete".into());
594
+ }
595
+ if overlap_start
596
+ .signed_duration_since(cursor)
597
+ .num_milliseconds()
598
+ > MAX_SEGMENT_BOUNDARY_QUANTIZATION_MS
599
+ {
600
+ return Err("selected Rewind interval contains a coverage gap".into());
601
+ }
602
+ let start_ms = overlap_start
603
+ .signed_duration_since(normalized_started)
604
+ .num_milliseconds()
605
+ .max(0) as u64;
606
+ let end_ms = overlap_end
607
+ .signed_duration_since(normalized_started)
608
+ .num_milliseconds()
609
+ .max(0) as u64;
610
+ slices.push(NativeMediaSlice {
611
+ path: segment.path.clone(),
612
+ system_audio_path: segment.system_audio_path.clone(),
613
+ microphone_path: segment.microphone_path.clone(),
614
+ start_ms,
615
+ end_ms,
616
+ });
617
+ selected.push(segment);
618
+ cursor = cursor.max(overlap_end);
619
+ }
620
+ if ended.signed_duration_since(cursor).num_milliseconds() > MAX_SEGMENT_BOUNDARY_QUANTIZATION_MS
621
+ || slices.is_empty()
622
+ {
623
+ return Err("selected Rewind interval is not fully covered".into());
624
+ }
625
+ Ok((slices, selected))
626
+ }
627
+
628
+ fn select_audio(
629
+ sources: &[CaptureSource],
630
+ include_mic: bool,
631
+ include_system_audio: bool,
632
+ ) -> Result<NativeAudioSelection, String> {
633
+ if (include_mic && !sources.contains(&CaptureSource::Microphone))
634
+ || (include_system_audio && !sources.contains(&CaptureSource::SystemAudio))
635
+ {
636
+ return Err(not_compatible("requested audio was not captured by Rewind"));
637
+ }
638
+ Ok(match (include_mic, include_system_audio) {
639
+ (false, false) => NativeAudioSelection::None,
640
+ (false, true) => NativeAudioSelection::System,
641
+ (true, false) => NativeAudioSelection::Microphone,
642
+ (true, true) => NativeAudioSelection::MixBoth,
643
+ })
644
+ }
645
+
646
+ /// Materialize an explicit recent range as one exact local MP4. This powers
647
+ /// “Save what just happened” without copying whole five-minute container
648
+ /// segments (which could otherwise retain media from before the chosen range).
649
+ pub(crate) fn materialize_recent_exact(
650
+ app: &AppHandle,
651
+ duration: std::time::Duration,
652
+ output: PathBuf,
653
+ ) -> Result<FinalizedNativeArtifact, String> {
654
+ let duration_ms = u64::try_from(duration.as_millis())
655
+ .map_err(|_| "requested Rewind export is too long".to_string())?;
656
+ if duration_ms == 0 || duration > crate::capture_graph::MAX_RETROSPECTIVE_EXTENSION {
657
+ return Err("Rewind export must be between one millisecond and five minutes".into());
658
+ }
659
+ let fenced = screen_memory::fence_active_for_clip(app)?;
660
+ let end_ms = fenced.graph_ended_elapsed_ms;
661
+ let start_ms = end_ms
662
+ .checked_sub(duration_ms)
663
+ .ok_or_else(|| "Rewind does not yet contain the full requested range".to_string())?;
664
+ materialize_graph_interval_exact(
665
+ app,
666
+ start_ms,
667
+ end_ms,
668
+ fenced.started_at,
669
+ fenced.ended_at,
670
+ output,
671
+ "explicit recent Rewind export",
672
+ None,
673
+ )
674
+ }
675
+
676
+ fn materialize_wall_clock_exact(
677
+ app: &AppHandle,
678
+ started_at: &str,
679
+ ended_at: &str,
680
+ output: PathBuf,
681
+ include_mic: bool,
682
+ include_system_audio: bool,
683
+ ) -> Result<FinalizedNativeArtifact, String> {
684
+ let started = DateTime::parse_from_rfc3339(started_at)
685
+ .map_err(|_| "Rewind handoff start must be an RFC3339 timestamp.".to_string())?
686
+ .with_timezone(&Utc);
687
+ let ended = DateTime::parse_from_rfc3339(ended_at)
688
+ .map_err(|_| "Rewind handoff end must be an RFC3339 timestamp.".to_string())?
689
+ .with_timezone(&Utc);
690
+ let duration = ended.signed_duration_since(started);
691
+ if duration.num_milliseconds() < 1
692
+ || duration.num_milliseconds()
693
+ > i64::try_from(crate::capture_graph::MAX_RETROSPECTIVE_EXTENSION.as_millis())
694
+ .unwrap_or(i64::MAX)
695
+ {
696
+ return Err("Rewind handoff must be between one millisecond and five minutes.".into());
697
+ }
698
+ let materialize = || {
699
+ materialize_retained_wall_clock_exact(
700
+ app,
701
+ started,
702
+ ended,
703
+ output.clone(),
704
+ include_mic,
705
+ include_system_audio,
706
+ )
707
+ };
708
+ match materialize() {
709
+ Ok(artifact) => Ok(artifact),
710
+ Err(initial_error) => {
711
+ // A request ending near “now” may overlap the still-open segment.
712
+ // Fence once and retry. Older retained ranges never depend on the
713
+ // current capture graph, so they survive app restarts and pauses.
714
+ if Utc::now().signed_duration_since(ended).num_minutes() <= 6
715
+ && screen_memory::fence_active_for_clip(app).is_ok()
716
+ {
717
+ materialize()
718
+ } else {
719
+ Err(initial_error)
720
+ }
721
+ }
722
+ }
723
+ }
724
+
725
+ fn materialize_retained_wall_clock_exact(
726
+ app: &AppHandle,
727
+ started: DateTime<Utc>,
728
+ ended: DateTime<Utc>,
729
+ output: PathBuf,
730
+ include_mic: bool,
731
+ include_system_audio: bool,
732
+ ) -> Result<FinalizedNativeArtifact, String> {
733
+ let segments = screen_memory::finalized_segments(app)?;
734
+ let (slices, selected) = validate_and_plan_wall_clock(&segments, started, ended)?;
735
+ let pin_id = format!("rewind-wall-clock-{}", Utc::now().timestamp_micros());
736
+ let mut pinned_segments = Vec::new();
737
+ let outcome = (|| {
738
+ for segment in &selected {
739
+ screen_memory::pin_segment(app, &segment.id, &pin_id)?;
740
+ pinned_segments.push(segment.id.clone());
741
+ }
742
+ let audio_available = selected
743
+ .iter()
744
+ .all(|segment| segment.capture_mode == crate::config::RewindCaptureMode::VisualsAudio);
745
+ if (include_mic || include_system_audio) && !audio_available {
746
+ return Err("The selected Rewind range does not contain the requested audio.".into());
747
+ }
748
+ let audio_selection = match (include_mic, include_system_audio) {
749
+ (false, false) => NativeAudioSelection::None,
750
+ (true, false) => NativeAudioSelection::Microphone,
751
+ (false, true) => NativeAudioSelection::System,
752
+ (true, true) => NativeAudioSelection::MixBoth,
753
+ };
754
+ native_screen::materialize_mp4_slices_exact(&slices, &output, audio_selection)?;
755
+ let first = selected
756
+ .first()
757
+ .ok_or_else(|| "no Rewind media selected".to_string())?;
758
+ Ok(FinalizedNativeArtifact::rewind_mp4(
759
+ output,
760
+ ended
761
+ .signed_duration_since(started)
762
+ .num_milliseconds()
763
+ .max(0) as u128,
764
+ first.width,
765
+ first.height,
766
+ include_mic,
767
+ include_system_audio,
768
+ ))
769
+ })();
770
+ for segment_id in pinned_segments {
771
+ let _ = screen_memory::unpin_segment(app, &segment_id, &pin_id);
772
+ }
773
+ outcome
774
+ }
775
+
776
+ fn materialize_graph_interval_exact(
777
+ app: &AppHandle,
778
+ start_ms: u64,
779
+ end_ms: u64,
780
+ started_at: String,
781
+ ended_at: String,
782
+ output: PathBuf,
783
+ label: &str,
784
+ requested_audio: Option<(bool, bool)>,
785
+ ) -> Result<FinalizedNativeArtifact, String> {
786
+ if end_ms <= start_ms {
787
+ return Err("The selected Rewind handoff range is empty.".into());
788
+ }
789
+ let interval = CaptureInterval {
790
+ started_at,
791
+ ended_at,
792
+ started_elapsed_ms: start_ms,
793
+ ended_elapsed_ms: end_ms,
794
+ };
795
+ let graph_state = app.state::<CaptureGraphState>();
796
+ let (pin_id, has_screen_gap) = {
797
+ let mut graph = graph_state.0.lock().map_err(|error| error.to_string())?;
798
+ let status = graph
799
+ .status_at(std::time::Instant::now())
800
+ .map_err(|error| error.to_string())?;
801
+ let has_gap = status.coverage_gaps.iter().any(|gap| {
802
+ gap.source == CaptureSource::Screen
803
+ && gap.interval.started_elapsed_ms < end_ms
804
+ && gap.interval.ended_elapsed_ms > start_ms
805
+ });
806
+ let pin = graph
807
+ .pin_interval(&interval, Some(label.into()))
808
+ .map_err(|error| error.to_string())?;
809
+ (pin.id, has_gap)
810
+ };
811
+ let mut pinned_segments = Vec::new();
812
+ let outcome = (|| {
813
+ if has_screen_gap {
814
+ return Err("selected Rewind interval contains a recorded coverage gap".into());
815
+ }
816
+ let segments = screen_memory::finalized_segments_in_graph_interval(app, start_ms, end_ms)?;
817
+ for segment in &segments {
818
+ screen_memory::pin_segment(app, &segment.id, &pin_id)?;
819
+ pinned_segments.push(segment.id.clone());
820
+ }
821
+ let slices = validate_and_plan(&segments, start_ms, end_ms)?;
822
+ let audio_available = segments
823
+ .iter()
824
+ .all(|segment| segment.capture_mode == crate::config::RewindCaptureMode::VisualsAudio);
825
+ let (include_mic, include_system_audio) =
826
+ requested_audio.unwrap_or((audio_available, audio_available));
827
+ if (include_mic || include_system_audio) && !audio_available {
828
+ return Err("The selected Rewind range does not contain the requested audio.".into());
829
+ }
830
+ let audio_selection = match (include_mic, include_system_audio) {
831
+ (false, false) => NativeAudioSelection::None,
832
+ (true, false) => NativeAudioSelection::Microphone,
833
+ (false, true) => NativeAudioSelection::System,
834
+ (true, true) => NativeAudioSelection::MixBoth,
835
+ };
836
+ native_screen::materialize_mp4_slices_exact(&slices, &output, audio_selection)?;
837
+ let first = segments
838
+ .first()
839
+ .ok_or_else(|| "no Rewind media selected".to_string())?;
840
+ Ok(FinalizedNativeArtifact::rewind_mp4(
841
+ output,
842
+ u128::from(end_ms.saturating_sub(start_ms)),
843
+ first.width,
844
+ first.height,
845
+ include_mic,
846
+ include_system_audio,
847
+ ))
848
+ })();
849
+ for segment_id in pinned_segments {
850
+ let _ = screen_memory::unpin_segment(app, &segment_id, &pin_id);
851
+ }
852
+ if let Ok(mut graph) = graph_state.0.lock() {
853
+ graph.release_pin(&pin_id);
854
+ }
855
+ outcome
856
+ }
857
+
858
+ #[tauri::command]
859
+ pub(crate) async fn rewind_agent_handoff_upload(
860
+ app: AppHandle,
861
+ request_id: String,
862
+ started_at: String,
863
+ ended_at: String,
864
+ server_url: String,
865
+ recording_id: String,
866
+ auth_token: Option<String>,
867
+ cookie: Option<String>,
868
+ upload_mode: Option<String>,
869
+ include_mic: bool,
870
+ include_system_audio: bool,
871
+ ) -> Result<NativeFullscreenUploadResult, String> {
872
+ let safe_request_id: String = request_id
873
+ .chars()
874
+ .filter(|character| {
875
+ character.is_ascii_alphanumeric() || *character == '-' || *character == '_'
876
+ })
877
+ .collect();
878
+ if safe_request_id != request_id || !safe_request_id.starts_with("handoff-") {
879
+ return Err("Invalid Rewind handoff request ID.".into());
880
+ }
881
+ let output_dir = app
882
+ .path()
883
+ .app_data_dir()
884
+ .map_err(|error| format!("app data directory unavailable: {error}"))?
885
+ .join("screen-memory")
886
+ .join("agent-handoffs");
887
+ std::fs::create_dir_all(&output_dir)
888
+ .map_err(|error| format!("agent handoff directory unavailable: {error}"))?;
889
+ let output = output_dir.join(format!("{safe_request_id}.mp4"));
890
+ let artifact = materialize_wall_clock_exact(
891
+ &app,
892
+ &started_at,
893
+ &ended_at,
894
+ output.clone(),
895
+ include_mic,
896
+ include_system_audio,
897
+ )?;
898
+ let result = native_screen::upload_finalized_native_artifact(
899
+ &app,
900
+ &artifact,
901
+ server_url,
902
+ recording_id,
903
+ auth_token.unwrap_or_default(),
904
+ cookie.unwrap_or_default(),
905
+ NativeUploadMode::from_option(upload_mode),
906
+ artifact.mic_captured || artifact.system_audio_captured,
907
+ false,
908
+ )
909
+ .await;
910
+ let _ = std::fs::remove_file(output);
911
+ result
912
+ }
913
+
914
+ #[tauri::command]
915
+ pub(crate) async fn rewind_agent_handoff_preview(
916
+ app: AppHandle,
917
+ request_id: String,
918
+ started_at: String,
919
+ ended_at: String,
920
+ include_mic: bool,
921
+ include_system_audio: bool,
922
+ ) -> Result<String, String> {
923
+ let safe_request_id: String = request_id
924
+ .chars()
925
+ .filter(|character| {
926
+ character.is_ascii_alphanumeric() || *character == '-' || *character == '_'
927
+ })
928
+ .collect();
929
+ if safe_request_id != request_id || !safe_request_id.starts_with("handoff-") {
930
+ return Err("Invalid Rewind handoff request ID.".into());
931
+ }
932
+ let output_dir = app
933
+ .path()
934
+ .app_local_data_dir()
935
+ .map_err(|error| format!("local preview directory unavailable: {error}"))?
936
+ .join("rewind-previews");
937
+ cleanup_expired_preview_artifacts(&output_dir, std::time::Duration::from_secs(15 * 60))?;
938
+ std::fs::create_dir_all(&output_dir)
939
+ .map_err(|error| format!("local preview directory unavailable: {error}"))?;
940
+ let output = output_dir.join(format!("{safe_request_id}-preview.mp4"));
941
+ let preview = output.clone();
942
+ tauri::async_runtime::spawn_blocking(move || {
943
+ materialize_wall_clock_exact(
944
+ &app,
945
+ &started_at,
946
+ &ended_at,
947
+ preview,
948
+ include_mic,
949
+ include_system_audio,
950
+ )
951
+ })
952
+ .await
953
+ .map_err(|error| format!("local handoff preview worker failed: {error}"))??;
954
+
955
+ #[cfg(target_os = "macos")]
956
+ Command::new("/usr/bin/open")
957
+ .arg(&output)
958
+ .stdin(Stdio::null())
959
+ .stdout(Stdio::null())
960
+ .stderr(Stdio::null())
961
+ .spawn()
962
+ .map_err(|error| format!("could not open Rewind handoff preview: {error}"))?;
963
+
964
+ let expiring_output = output.clone();
965
+ std::thread::spawn(move || {
966
+ std::thread::sleep(std::time::Duration::from_secs(15 * 60));
967
+ let _ = std::fs::remove_file(expiring_output);
968
+ });
969
+
970
+ Ok(output.to_string_lossy().to_string())
971
+ }
972
+
973
+ fn cleanup_expired_preview_artifacts(
974
+ directory: &std::path::Path,
975
+ max_age: std::time::Duration,
976
+ ) -> Result<(), String> {
977
+ let now = std::time::SystemTime::now();
978
+ let Ok(entries) = std::fs::read_dir(directory) else {
979
+ return Ok(());
980
+ };
981
+ for entry in entries.flatten() {
982
+ let path = entry.path();
983
+ let expired = entry
984
+ .metadata()
985
+ .ok()
986
+ .and_then(|metadata| metadata.modified().ok())
987
+ .and_then(|modified| now.duration_since(modified).ok())
988
+ .is_some_and(|age| age >= max_age);
989
+ if path.is_file() && expired {
990
+ std::fs::remove_file(&path)
991
+ .map_err(|error| format!("could not remove expired preview: {error}"))?;
992
+ }
993
+ }
994
+ Ok(())
995
+ }
996
+
997
+ pub(crate) fn clear_preview_artifacts(app: &AppHandle) -> Result<(), String> {
998
+ let directory = app
999
+ .path()
1000
+ .app_local_data_dir()
1001
+ .map_err(|error| format!("local preview directory unavailable: {error}"))?
1002
+ .join("rewind-previews");
1003
+ match std::fs::remove_dir_all(&directory) {
1004
+ Ok(()) => Ok(()),
1005
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1006
+ Err(error) => Err(format!("could not clear local Rewind previews: {error}")),
1007
+ }
1008
+ }
1009
+
1010
+ fn materialize(
1011
+ app: &AppHandle,
1012
+ state: &State<'_, RewindClipState>,
1013
+ label: &str,
1014
+ include_mic: bool,
1015
+ include_system_audio: bool,
1016
+ ) -> Result<FinalizedNativeArtifact, String> {
1017
+ let active = take_active(state)?;
1018
+ let mut intervals = active.intervals.clone();
1019
+ let mut lease_ended = active.lease_id.is_none();
1020
+ let mut graph_pin_ids = Vec::new();
1021
+ let mut pinned = BTreeSet::new();
1022
+ let outcome = (|| -> Result<FinalizedNativeArtifact, String> {
1023
+ if let Some(lease_id) = active.lease_id.as_deref() {
1024
+ let fenced = screen_memory::fence_active_for_clip(app)?;
1025
+ let closed = app
1026
+ .state::<CaptureGraphState>()
1027
+ .0
1028
+ .lock()
1029
+ .map_err(|e| e.to_string())?
1030
+ .end_consumer(lease_id)
1031
+ .map_err(|e| e.to_string())?;
1032
+ lease_ended = true;
1033
+ let mut interval = closed.lease.interval;
1034
+ interval.ended_elapsed_ms =
1035
+ interval.ended_elapsed_ms.min(fenced.graph_ended_elapsed_ms);
1036
+ intervals.push(interval);
1037
+ }
1038
+ if intervals.is_empty() {
1039
+ return Err("Rewind-derived clip has no recorded intervals".into());
1040
+ }
1041
+ let graph_status = app
1042
+ .state::<CaptureGraphState>()
1043
+ .0
1044
+ .lock()
1045
+ .map_err(|error| error.to_string())?
1046
+ .status_at(std::time::Instant::now())
1047
+ .map_err(|error| error.to_string())?;
1048
+ if graph_status.coverage_gaps.iter().any(|gap| {
1049
+ intervals.iter().any(|interval| {
1050
+ gap.interval.started_elapsed_ms < interval.ended_elapsed_ms
1051
+ && gap.interval.ended_elapsed_ms > interval.started_elapsed_ms
1052
+ && active.sources.contains(&gap.source)
1053
+ })
1054
+ }) {
1055
+ return Err("selected Rewind interval contains a recorded coverage gap".into());
1056
+ }
1057
+ let mut slices = Vec::new();
1058
+ let mut first_segment = None;
1059
+ let mut duration_ms = 0u128;
1060
+ for interval in &intervals {
1061
+ let pin = app
1062
+ .state::<CaptureGraphState>()
1063
+ .0
1064
+ .lock()
1065
+ .map_err(|e| e.to_string())?
1066
+ .pin_interval(interval, Some("rewind-derived clip".into()))
1067
+ .map_err(|e| e.to_string())?;
1068
+ graph_pin_ids.push(pin.id.clone());
1069
+ let segments = screen_memory::finalized_segments_in_graph_interval(
1070
+ app,
1071
+ interval.started_elapsed_ms,
1072
+ interval.ended_elapsed_ms,
1073
+ )?;
1074
+ for segment in &segments {
1075
+ screen_memory::pin_segment(app, &segment.id, &pin.id)?;
1076
+ pinned.insert((segment.id.clone(), pin.id.clone()));
1077
+ }
1078
+ if first_segment.is_none() {
1079
+ first_segment = segments.first().cloned();
1080
+ }
1081
+ slices.extend(validate_and_plan(
1082
+ &segments,
1083
+ interval.started_elapsed_ms,
1084
+ interval.ended_elapsed_ms,
1085
+ )?);
1086
+ duration_ms += (interval.ended_elapsed_ms - interval.started_elapsed_ms) as u128;
1087
+ }
1088
+ let output = artifact_path(app, label)?;
1089
+ let audio = select_audio(&active.sources, include_mic, include_system_audio)?;
1090
+ native_screen::materialize_mp4_slices_exact(&slices, &output, audio)?;
1091
+ let first = first_segment
1092
+ .as_ref()
1093
+ .ok_or_else(|| "no Rewind media selected".to_string())?;
1094
+ Ok(FinalizedNativeArtifact::rewind_mp4(
1095
+ output,
1096
+ duration_ms,
1097
+ first.width,
1098
+ first.height,
1099
+ include_mic,
1100
+ include_system_audio,
1101
+ ))
1102
+ })();
1103
+ if !lease_ended {
1104
+ if let (Some(lease_id), Ok(mut graph)) = (
1105
+ active.lease_id.as_deref(),
1106
+ app.state::<CaptureGraphState>().0.lock(),
1107
+ ) {
1108
+ let _ = graph.end_consumer(lease_id);
1109
+ }
1110
+ }
1111
+ for (segment_id, pin_id) in pinned {
1112
+ let _ = screen_memory::unpin_segment(app, &segment_id, &pin_id);
1113
+ }
1114
+ for pin_id in graph_pin_ids {
1115
+ if let Ok(mut graph) = app.state::<CaptureGraphState>().0.lock() {
1116
+ graph.release_pin(&pin_id);
1117
+ }
1118
+ }
1119
+ for segment_id in &active.pinned_segments {
1120
+ let _ = screen_memory::unpin_segment(app, segment_id, &active.pin_id);
1121
+ }
1122
+ release_temporary_audio(app, active.temporary_audio);
1123
+ outcome
1124
+ }
1125
+
1126
+ #[tauri::command]
1127
+ pub(crate) async fn rewind_clip_stop_and_upload(
1128
+ app: AppHandle,
1129
+ state: State<'_, RewindClipState>,
1130
+ server_url: String,
1131
+ recording_id: String,
1132
+ auth_token: Option<String>,
1133
+ cookie: Option<String>,
1134
+ upload_mode: Option<String>,
1135
+ include_mic: bool,
1136
+ include_system_audio: bool,
1137
+ has_camera: bool,
1138
+ ) -> Result<NativeFullscreenUploadResult, String> {
1139
+ #[cfg(target_os = "macos")]
1140
+ {
1141
+ let stop_started = std::time::Instant::now();
1142
+ let mut active = take_active(&state)?;
1143
+ if let Err(error) = close_direct_clip_interval(&app, &mut active) {
1144
+ if let Some(sink) = active.shared_sink.take() {
1145
+ sink.cancel();
1146
+ }
1147
+ release_active_resources(&app, &mut active);
1148
+ return Err(error);
1149
+ }
1150
+ let uploaded_before_stop = active
1151
+ .shared_sink
1152
+ .as_ref()
1153
+ .and_then(|sink| sink.uploaded_bytes_now());
1154
+ crate::logfile::diagnostic(&format!(
1155
+ "[rewind-latency] stop boundary accepted in {}ms",
1156
+ stop_started.elapsed().as_millis()
1157
+ ));
1158
+ let mut sink = active
1159
+ .shared_sink
1160
+ .take()
1161
+ .ok_or_else(|| "active Rewind Clip sink is missing".to_string())?;
1162
+ if active.retrospective_seconds == 0 {
1163
+ let sink_path = sink.path().to_path_buf();
1164
+ let (sink_width, sink_height) = sink.dimensions();
1165
+ let logical_duration_ms = sink.duration_ms();
1166
+ let result = sink.finalize_writer();
1167
+ release_active_resources(&app, &mut active);
1168
+ let mut result = match result {
1169
+ Ok(result) => result,
1170
+ Err(error) => {
1171
+ let recovery_error = native_screen::persist_shared_clip_recording(
1172
+ &app,
1173
+ &sink_path,
1174
+ &server_url,
1175
+ &recording_id,
1176
+ logical_duration_ms as u128,
1177
+ sink_width,
1178
+ sink_height,
1179
+ include_mic,
1180
+ include_system_audio,
1181
+ has_camera,
1182
+ Some(&error),
1183
+ )
1184
+ .err();
1185
+ let error = match recovery_error {
1186
+ Some(recovery_error) => {
1187
+ format!("{error}. Recovery metadata also failed: {recovery_error}")
1188
+ }
1189
+ None => error,
1190
+ };
1191
+ native_screen::emit_native_upload_finished(
1192
+ &app,
1193
+ &server_url,
1194
+ &recording_id,
1195
+ false,
1196
+ Some(error.clone()),
1197
+ Some(&sink_path),
1198
+ );
1199
+ return Err(error);
1200
+ }
1201
+ };
1202
+ if let Err(error) = native_screen::persist_shared_clip_recording(
1203
+ &app,
1204
+ &result.path,
1205
+ &server_url,
1206
+ &recording_id,
1207
+ result.duration_ms as u128,
1208
+ result.width,
1209
+ result.height,
1210
+ include_mic,
1211
+ include_system_audio,
1212
+ has_camera,
1213
+ None,
1214
+ ) {
1215
+ sink.cancel_upload();
1216
+ native_screen::emit_native_upload_finished(
1217
+ &app,
1218
+ &server_url,
1219
+ &recording_id,
1220
+ false,
1221
+ Some(error.clone()),
1222
+ Some(&result.path),
1223
+ );
1224
+ return Err(error);
1225
+ }
1226
+ result.verification_pending = match sink.finalize_upload(result.duration_ms).await {
1227
+ Ok(pending) => pending,
1228
+ Err(error) => {
1229
+ let _ = native_screen::persist_shared_clip_recording(
1230
+ &app,
1231
+ &result.path,
1232
+ &server_url,
1233
+ &recording_id,
1234
+ result.duration_ms as u128,
1235
+ result.width,
1236
+ result.height,
1237
+ include_mic,
1238
+ include_system_audio,
1239
+ has_camera,
1240
+ Some(&error),
1241
+ );
1242
+ native_screen::emit_native_upload_finished(
1243
+ &app,
1244
+ &server_url,
1245
+ &recording_id,
1246
+ false,
1247
+ Some(error.clone()),
1248
+ Some(&result.path),
1249
+ );
1250
+ return Err(format!(
1251
+ "{error}. The clip was saved locally and can be retried from the Clips menu."
1252
+ ));
1253
+ }
1254
+ };
1255
+ if let Some(uploaded) = uploaded_before_stop {
1256
+ let percent = if result.bytes == 0 {
1257
+ 0
1258
+ } else {
1259
+ uploaded.saturating_mul(100) / result.bytes
1260
+ };
1261
+ crate::logfile::diagnostic(&format!(
1262
+ "[rewind-latency] uploaded before Stop: {uploaded}/{} bytes ({percent}%)",
1263
+ result.bytes
1264
+ ));
1265
+ }
1266
+ native_screen::emit_native_upload_finished(
1267
+ &app,
1268
+ &server_url,
1269
+ &recording_id,
1270
+ true,
1271
+ None,
1272
+ Some(&result.path),
1273
+ );
1274
+ if !result.verification_pending {
1275
+ native_screen::clear_shared_clip_recording(&app, &recording_id, &result.path);
1276
+ }
1277
+ return Ok(result.into_native_upload_result(recording_id));
1278
+ }
1279
+
1280
+ if let Err(error) = sink.abandon_for_rewind_materialization().await {
1281
+ release_active_resources(&app, &mut active);
1282
+ return Err(error);
1283
+ }
1284
+ *state.0.lock().map_err(|error| error.to_string())? = Some(active);
1285
+ }
1286
+ let artifact = materialize(
1287
+ &app,
1288
+ &state,
1289
+ &recording_id,
1290
+ include_mic,
1291
+ include_system_audio,
1292
+ )?;
1293
+ native_screen::upload_finalized_native_artifact(
1294
+ &app,
1295
+ &artifact,
1296
+ server_url,
1297
+ recording_id,
1298
+ auth_token.unwrap_or_default(),
1299
+ cookie.unwrap_or_default(),
1300
+ NativeUploadMode::from_option(upload_mode),
1301
+ include_mic || include_system_audio,
1302
+ has_camera,
1303
+ )
1304
+ .await
1305
+ }
1306
+
1307
+ #[tauri::command]
1308
+ pub(crate) async fn rewind_clip_stop_and_save(
1309
+ app: AppHandle,
1310
+ state: State<'_, RewindClipState>,
1311
+ folder_name: String,
1312
+ file_role: String,
1313
+ include_mic: bool,
1314
+ include_system_audio: bool,
1315
+ ) -> Result<NativeFullscreenSaveResult, String> {
1316
+ #[cfg(target_os = "macos")]
1317
+ {
1318
+ let stop_started = std::time::Instant::now();
1319
+ let mut active = take_active(&state)?;
1320
+ if let Err(error) = close_direct_clip_interval(&app, &mut active) {
1321
+ if let Some(sink) = active.shared_sink.take() {
1322
+ sink.cancel();
1323
+ }
1324
+ release_active_resources(&app, &mut active);
1325
+ return Err(error);
1326
+ }
1327
+ eprintln!(
1328
+ "[rewind-latency] local stop boundary accepted in {}ms",
1329
+ stop_started.elapsed().as_millis()
1330
+ );
1331
+ let mut sink = active
1332
+ .shared_sink
1333
+ .take()
1334
+ .ok_or_else(|| "active Rewind Clip sink is missing".to_string())?;
1335
+ if active.retrospective_seconds == 0 {
1336
+ let result = sink.finalize_writer();
1337
+ release_active_resources(&app, &mut active);
1338
+ let result = result?;
1339
+ let artifact = FinalizedNativeArtifact::rewind_mp4(
1340
+ result.path,
1341
+ result.duration_ms as u128,
1342
+ Some(result.width),
1343
+ Some(result.height),
1344
+ include_mic,
1345
+ include_system_audio,
1346
+ );
1347
+ let saved = native_screen::save_finalized_native_artifact_to_local_export(
1348
+ &app,
1349
+ &artifact,
1350
+ &folder_name,
1351
+ &file_role,
1352
+ )?;
1353
+ native_screen::clear_shared_clip_recording(&app, &folder_name, &artifact.path);
1354
+ return Ok(saved);
1355
+ }
1356
+ if let Err(error) = sink.abandon_for_rewind_materialization().await {
1357
+ release_active_resources(&app, &mut active);
1358
+ return Err(error);
1359
+ }
1360
+ *state.0.lock().map_err(|error| error.to_string())? = Some(active);
1361
+ }
1362
+ let artifact = materialize(
1363
+ &app,
1364
+ &state,
1365
+ &folder_name,
1366
+ include_mic,
1367
+ include_system_audio,
1368
+ )?;
1369
+ native_screen::save_finalized_native_artifact_to_local_export(
1370
+ &app,
1371
+ &artifact,
1372
+ &folder_name,
1373
+ &file_role,
1374
+ )
1375
+ }
1376
+
1377
+ #[tauri::command]
1378
+ pub(crate) fn rewind_clip_cancel(
1379
+ app: AppHandle,
1380
+ state: State<'_, RewindClipState>,
1381
+ ) -> Result<(), String> {
1382
+ let active = state.0.lock().map_err(|error| error.to_string())?.take();
1383
+ if let Some(mut active) = active {
1384
+ if let Some(lease_id) = active.lease_id.as_deref() {
1385
+ let _ = app
1386
+ .state::<CaptureGraphState>()
1387
+ .0
1388
+ .lock()
1389
+ .map_err(|error| error.to_string())?
1390
+ .end_consumer(lease_id);
1391
+ }
1392
+ #[cfg(target_os = "macos")]
1393
+ if let Some(sink) = active.shared_sink.take() {
1394
+ sink.cancel();
1395
+ }
1396
+ release_active_resources(&app, &mut active);
1397
+ }
1398
+ Ok(())
1399
+ }
1400
+
1401
+ /// Called by Screen Memory whenever a segment becomes finalized. Keeping this
1402
+ /// hook in the consumer module prevents rotation/pruning from racing a clip
1403
+ /// whose materialization has not happened yet.
1404
+ pub(crate) fn pin_finalized_segment_if_active(
1405
+ app: &AppHandle,
1406
+ segment: &ScreenMemorySegmentMetadata,
1407
+ ) -> Result<(), String> {
1408
+ let Some(state) = app.try_state::<RewindClipState>() else {
1409
+ return Ok(());
1410
+ };
1411
+ let Ok(mut active) = state.0.try_lock() else {
1412
+ return Ok(());
1413
+ };
1414
+ let Some(active) = active.as_mut() else {
1415
+ return Ok(());
1416
+ };
1417
+ if !active.activated || active.paused {
1418
+ return Ok(());
1419
+ }
1420
+ if segment.graph_ended_elapsed_ms >= active.started_elapsed_ms {
1421
+ screen_memory::pin_segment(app, &segment.id, &active.pin_id)?;
1422
+ active.pinned_segments.insert(segment.id.clone());
1423
+ }
1424
+ Ok(())
1425
+ }
1426
+
1427
+ #[cfg(test)]
1428
+ mod tests {
1429
+ use super::*;
1430
+ use crate::config::RewindCaptureMode;
1431
+
1432
+ fn segment(id: &str, start: u64, end: u64) -> ScreenMemorySegmentMetadata {
1433
+ ScreenMemorySegmentMetadata {
1434
+ id: id.into(),
1435
+ path: PathBuf::from(format!("{id}.mp4")),
1436
+ file_name: format!("{id}.mp4"),
1437
+ mime_type: "video/mp4".into(),
1438
+ started_at: String::new(),
1439
+ ended_at: String::new(),
1440
+ duration_ms: (end - start) as u128,
1441
+ width: Some(1280),
1442
+ height: Some(720),
1443
+ bytes: 1,
1444
+ system_audio_path: None,
1445
+ microphone_path: None,
1446
+ corrupt: false,
1447
+ error: None,
1448
+ capture_mode: RewindCaptureMode::VisualsAudio,
1449
+ exclusion_tainted: false,
1450
+ graph_epoch_id: Some("test-epoch".into()),
1451
+ graph_started_elapsed_ms: start,
1452
+ graph_ended_elapsed_ms: end,
1453
+ }
1454
+ }
1455
+
1456
+ fn wall_segment(
1457
+ id: &str,
1458
+ started_at: &str,
1459
+ ended_at: &str,
1460
+ epoch: &str,
1461
+ ) -> ScreenMemorySegmentMetadata {
1462
+ let mut value = segment(id, 0, 100);
1463
+ value.started_at = started_at.into();
1464
+ value.ended_at = ended_at.into();
1465
+ value.graph_epoch_id = Some(epoch.into());
1466
+ value
1467
+ }
1468
+
1469
+ #[test]
1470
+ fn slice_math_has_no_pre_roll_and_exact_overlap() {
1471
+ let slices =
1472
+ validate_and_plan(&[segment("a", 0, 100), segment("b", 100, 200)], 50, 150).unwrap();
1473
+ assert_eq!(
1474
+ slices,
1475
+ vec![
1476
+ NativeMediaSlice {
1477
+ path: PathBuf::from("a.mp4"),
1478
+ system_audio_path: None,
1479
+ microphone_path: None,
1480
+ start_ms: 50,
1481
+ end_ms: 100
1482
+ },
1483
+ NativeMediaSlice {
1484
+ path: PathBuf::from("b.mp4"),
1485
+ system_audio_path: None,
1486
+ microphone_path: None,
1487
+ start_ms: 0,
1488
+ end_ms: 50
1489
+ }
1490
+ ]
1491
+ );
1492
+ }
1493
+
1494
+ #[test]
1495
+ fn gaps_and_taint_fail_closed() {
1496
+ assert!(validate_and_plan(&[segment("a", 0, 100), segment("b", 101, 200)], 0, 200).is_ok());
1497
+ assert!(
1498
+ validate_and_plan(&[segment("a", 0, 100), segment("b", 103, 200)], 0, 200).is_err()
1499
+ );
1500
+ assert!(validate_and_plan(&[segment("a", 0, 90), segment("b", 100, 200)], 0, 200).is_err());
1501
+ let mut tainted = segment("a", 0, 100);
1502
+ tainted.exclusion_tainted = true;
1503
+ assert!(validate_and_plan(&[tainted], 0, 100).is_err());
1504
+ }
1505
+
1506
+ #[test]
1507
+ fn retrospective_choices_are_strictly_bounded() {
1508
+ assert!(matches!(30, RETROSPECTIVE_30_SECONDS));
1509
+ assert!(matches!(300, RETROSPECTIVE_5_MINUTES));
1510
+ }
1511
+
1512
+ #[test]
1513
+ fn visuals_reject_audio_and_audio_mode_preserves_each_choice() {
1514
+ assert!(select_audio(&[CaptureSource::Screen], true, false).is_err());
1515
+ let sources = [
1516
+ CaptureSource::Screen,
1517
+ CaptureSource::SystemAudio,
1518
+ CaptureSource::Microphone,
1519
+ ];
1520
+ assert_eq!(
1521
+ select_audio(&sources, false, false).unwrap(),
1522
+ NativeAudioSelection::None
1523
+ );
1524
+ assert_eq!(
1525
+ select_audio(&sources, false, true).unwrap(),
1526
+ NativeAudioSelection::System
1527
+ );
1528
+ assert_eq!(
1529
+ select_audio(&sources, true, false).unwrap(),
1530
+ NativeAudioSelection::Microphone
1531
+ );
1532
+ assert_eq!(
1533
+ select_audio(&sources, true, true).unwrap(),
1534
+ NativeAudioSelection::MixBoth
1535
+ );
1536
+ }
1537
+
1538
+ #[test]
1539
+ fn ordered_intervals_exclude_paused_media() {
1540
+ let segments = [
1541
+ segment("before", 0, 100),
1542
+ segment("paused", 100, 200),
1543
+ segment("after", 200, 300),
1544
+ ];
1545
+ let mut slices = validate_and_plan(&segments, 0, 100).unwrap();
1546
+ slices.extend(validate_and_plan(&segments, 200, 300).unwrap());
1547
+ assert_eq!(slices.len(), 2);
1548
+ assert_eq!(slices[0].path, PathBuf::from("before.mp4"));
1549
+ assert_eq!(slices[1].path, PathBuf::from("after.mp4"));
1550
+ }
1551
+
1552
+ #[test]
1553
+ fn wall_clock_planning_crosses_capture_graph_epochs() {
1554
+ let started = DateTime::parse_from_rfc3339("2026-07-19T10:00:00Z")
1555
+ .unwrap()
1556
+ .with_timezone(&Utc);
1557
+ let ended = DateTime::parse_from_rfc3339("2026-07-19T10:00:00.200Z")
1558
+ .unwrap()
1559
+ .with_timezone(&Utc);
1560
+ let segments = [
1561
+ wall_segment(
1562
+ "before-restart",
1563
+ "2026-07-19T10:00:00Z",
1564
+ "2026-07-19T10:00:00.100Z",
1565
+ "epoch-before-restart",
1566
+ ),
1567
+ wall_segment(
1568
+ "after-restart",
1569
+ "2026-07-19T10:00:00.100Z",
1570
+ "2026-07-19T10:00:00.200Z",
1571
+ "epoch-after-restart",
1572
+ ),
1573
+ ];
1574
+ let (slices, selected) = validate_and_plan_wall_clock(&segments, started, ended).unwrap();
1575
+ assert_eq!(slices.len(), 2);
1576
+ assert_eq!(selected[0].id, "before-restart");
1577
+ assert_eq!(selected[1].id, "after-restart");
1578
+ }
1579
+
1580
+ #[test]
1581
+ fn wall_clock_planning_uses_graph_continuity_across_rotation_skew() {
1582
+ let started = DateTime::parse_from_rfc3339("2026-07-19T10:00:00Z")
1583
+ .unwrap()
1584
+ .with_timezone(&Utc);
1585
+ let ended = DateTime::parse_from_rfc3339("2026-07-19T10:00:00.200Z")
1586
+ .unwrap()
1587
+ .with_timezone(&Utc);
1588
+ let first = wall_segment(
1589
+ "before-rotation",
1590
+ "2026-07-19T10:00:00Z",
1591
+ "2026-07-19T10:00:00.100Z",
1592
+ "same-epoch",
1593
+ );
1594
+ let mut second = wall_segment(
1595
+ "after-rotation",
1596
+ "2026-07-19T10:00:00.150Z",
1597
+ "2026-07-19T10:00:00.250Z",
1598
+ "same-epoch",
1599
+ );
1600
+ second.graph_started_elapsed_ms = 100;
1601
+ second.graph_ended_elapsed_ms = 200;
1602
+ let (slices, selected) =
1603
+ validate_and_plan_wall_clock(&[first, second], started, ended).unwrap();
1604
+ assert_eq!(slices.len(), 2);
1605
+ assert_eq!(selected[1].id, "after-rotation");
1606
+ assert_eq!(slices[1].start_ms, 0);
1607
+ assert_eq!(slices[1].end_ms, 100);
1608
+ }
1609
+
1610
+ #[test]
1611
+ fn wall_clock_planning_rejects_real_gaps() {
1612
+ let started = DateTime::parse_from_rfc3339("2026-07-19T10:00:00Z")
1613
+ .unwrap()
1614
+ .with_timezone(&Utc);
1615
+ let ended = DateTime::parse_from_rfc3339("2026-07-19T10:00:00.200Z")
1616
+ .unwrap()
1617
+ .with_timezone(&Utc);
1618
+ let mut first = wall_segment(
1619
+ "a",
1620
+ "2026-07-19T10:00:00Z",
1621
+ "2026-07-19T10:00:00.090Z",
1622
+ "one",
1623
+ );
1624
+ first.graph_ended_elapsed_ms = 90;
1625
+ let segments = [
1626
+ first,
1627
+ wall_segment(
1628
+ "b",
1629
+ "2026-07-19T10:00:00.100Z",
1630
+ "2026-07-19T10:00:00.200Z",
1631
+ "two",
1632
+ ),
1633
+ ];
1634
+ assert!(validate_and_plan_wall_clock(&segments, started, ended).is_err());
1635
+ }
1636
+
1637
+ #[test]
1638
+ fn expired_preview_cleanup_removes_files() {
1639
+ let directory = std::env::temp_dir().join(format!(
1640
+ "clips-rewind-preview-test-{}",
1641
+ Utc::now().timestamp_micros()
1642
+ ));
1643
+ std::fs::create_dir_all(&directory).unwrap();
1644
+ let preview = directory.join("preview.mp4");
1645
+ std::fs::write(&preview, b"preview").unwrap();
1646
+ cleanup_expired_preview_artifacts(&directory, std::time::Duration::ZERO).unwrap();
1647
+ assert!(!preview.exists());
1648
+ let _ = std::fs::remove_dir_all(directory);
1649
+ }
1650
+ }