@agent-native/core 0.153.1 → 0.153.4

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 (41) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/adhoc-analysis/SKILL.md +21 -19
  3. package/corpus/templates/analytics/AGENTS.md +4 -0
  4. package/corpus/templates/analytics/server/plugins/agent-chat.ts +1 -0
  5. package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +0 -11
  6. package/corpus/templates/clips/app/components/player/share-dialog.tsx +32 -33
  7. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +29 -39
  8. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +0 -1
  9. package/corpus/templates/clips/desktop/src/app.tsx +817 -441
  10. package/corpus/templates/clips/desktop/src/styles.css +234 -0
  11. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +1 -0
  12. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +593 -7
  13. package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +95 -10
  14. package/corpus/templates/forms/AGENTS.md +6 -0
  15. package/corpus/templates/forms/server/plugins/agent-chat.ts +1 -0
  16. package/corpus/templates/mail/AGENTS.md +5 -4
  17. package/corpus/templates/mail/actions/get-automation-settings.ts +17 -6
  18. package/corpus/templates/mail/actions/send-email.ts +17 -9
  19. package/corpus/templates/mail/actions/send-queued-drafts.ts +21 -11
  20. package/corpus/templates/mail/actions/update-automation-settings.ts +20 -6
  21. package/corpus/templates/mail/app/i18n/en-US.ts +5 -0
  22. package/corpus/templates/mail/app/pages/SettingsPage.tsx +68 -7
  23. package/corpus/templates/mail/server/lib/automation-settings.ts +27 -0
  24. package/corpus/templates/mail/server/plugins/agent-chat.ts +2 -0
  25. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +3 -3
  26. package/corpus/templates/slides/app/components/presentation/PresentationView.tsx +17 -4
  27. package/corpus/templates/slides/app/context/DeckContext.tsx +3 -2
  28. package/corpus/templates/slides/app/pages/DeckEditor.tsx +44 -0
  29. package/corpus/templates/slides/changelog/2026-08-12-arrow-key-navigation-in-the-slide-film-strip-now-scrolls-sli.md +6 -0
  30. package/corpus/templates/slides/changelog/2026-08-12-duplicate-a-slide-from-the-film-strip-with-cmd-ctrl-c-then-c.md +6 -0
  31. package/corpus/templates/slides/changelog/2026-08-12-presenting-a-deck-now-returns-you-to-the-slide-you-were-view.md +6 -0
  32. package/dist/client/resources/mcp-integration-catalog.d.ts +7 -0
  33. package/dist/client/resources/mcp-integration-catalog.js +14 -1
  34. package/dist/client/settings/AgentsSection.js +46 -30
  35. package/dist/client/sharing/ShareDialog.js +4 -2
  36. package/dist/collab/awareness.d.ts +2 -2
  37. package/dist/observability/routes.d.ts +5 -5
  38. package/dist/secrets/routes.d.ts +9 -9
  39. package/dist/server/agent-chat-plugin.js +1 -0
  40. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  41. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  use chrono::Utc;
2
2
  use serde::{Deserialize, Serialize};
3
+ use std::collections::BTreeSet;
3
4
  use std::fs::File;
4
5
  use std::io::{ErrorKind, Read, Seek, SeekFrom};
5
6
  use std::path::{Path, PathBuf};
@@ -567,6 +568,7 @@ impl SharedClipSink {
567
568
  cancel_clip_live_upload(&live);
568
569
  }
569
570
  self.sink.cancel();
571
+ remove_recording_intent(&self.path);
570
572
  }
571
573
 
572
574
  /// Use when Add previous 30s/5m switches to Rewind's exact local
@@ -578,6 +580,7 @@ impl SharedClipSink {
578
580
  cancel_clip_live_upload(&live);
579
581
  }
580
582
  self.sink.cancel();
583
+ remove_recording_intent(&self.path);
581
584
  if let Some(config) = self.upload_reset.take() {
582
585
  let Some((server_url, recording_id, auth_token, cookie)) = config.validated()? else {
583
586
  return Ok(());
@@ -1035,6 +1038,26 @@ struct SavedNativeRecording {
1035
1038
  corrupt: bool,
1036
1039
  }
1037
1040
 
1041
+ /// Written as soon as a remote recording starts. If the process dies before
1042
+ /// the normal stop path can write SavedNativeRecording, this small sidecar is
1043
+ /// enough for the next startup to promote the media file into the same retry
1044
+ /// queue instead of leaving an invisible orphan on disk.
1045
+ #[derive(Debug, Clone, Serialize, Deserialize)]
1046
+ #[serde(rename_all = "camelCase")]
1047
+ struct NativeRecordingIntent {
1048
+ recording_id: String,
1049
+ server_url: String,
1050
+ mime_type: String,
1051
+ width: Option<u32>,
1052
+ height: Option<u32>,
1053
+ has_audio: bool,
1054
+ mic_captured: bool,
1055
+ system_audio_captured: bool,
1056
+ has_camera: bool,
1057
+ custom_pipeline: bool,
1058
+ saved_at: String,
1059
+ }
1060
+
1038
1061
  #[derive(Debug, Clone, Serialize)]
1039
1062
  #[serde(rename_all = "camelCase")]
1040
1063
  pub struct PendingNativeRecording {
@@ -1054,6 +1077,13 @@ pub struct PendingNativeRecording {
1054
1077
  corrupt: bool,
1055
1078
  }
1056
1079
 
1080
+ #[derive(Debug, Clone, Serialize)]
1081
+ #[serde(rename_all = "camelCase")]
1082
+ pub struct NativeOrphanedRecordingRecoveryResult {
1083
+ recovered: u32,
1084
+ skipped: u32,
1085
+ }
1086
+
1057
1087
  #[derive(Serialize)]
1058
1088
  #[serde(rename_all = "camelCase")]
1059
1089
  pub struct NativeFullscreenStartInfo {
@@ -1360,6 +1390,33 @@ fn start_native_session_locked(
1360
1390
  })
1361
1391
  }
1362
1392
 
1393
+ #[cfg(target_os = "macos")]
1394
+ fn persist_active_recording_intent(
1395
+ session: &NativeFullscreenSession,
1396
+ recording_id: &str,
1397
+ server_url: Option<&str>,
1398
+ has_camera: bool,
1399
+ ) {
1400
+ let Some(server_url) = server_url.filter(|value| !value.trim().is_empty()) else {
1401
+ return;
1402
+ };
1403
+ if let Err(error) = persist_recording_intent(
1404
+ &session.path,
1405
+ recording_id,
1406
+ server_url,
1407
+ session.mime_type,
1408
+ session.width,
1409
+ session.height,
1410
+ session.restart.include_audio || session.restart.capture_system_audio,
1411
+ session.restart.mic_captured_in_file,
1412
+ session.restart.capture_system_audio,
1413
+ has_camera,
1414
+ session.custom_pipeline,
1415
+ ) {
1416
+ eprintln!("[clips-tray] native recording recovery intent unavailable: {error}");
1417
+ }
1418
+ }
1419
+
1363
1420
  /// Phase 1 of the warm/begin start. Starts ScreenCaptureKit capture with the
1364
1421
  /// recording output DEFERRED so the microphone pipeline warms up (its first
1365
1422
  /// sample lands ~1s after capture begins) without that silent second being
@@ -1554,6 +1611,12 @@ pub async fn native_fullscreen_recording_begin(
1554
1611
  include_audio,
1555
1612
  has_camera.unwrap_or(false),
1556
1613
  );
1614
+ persist_active_recording_intent(
1615
+ session,
1616
+ &recording_id,
1617
+ server_url.as_deref(),
1618
+ has_camera.unwrap_or(false),
1619
+ );
1557
1620
  }
1558
1621
  }
1559
1622
  return Ok(info);
@@ -1645,6 +1708,12 @@ pub async fn native_fullscreen_recording_begin(
1645
1708
  include_audio,
1646
1709
  has_camera.unwrap_or(false),
1647
1710
  );
1711
+ persist_active_recording_intent(
1712
+ session,
1713
+ &recording_id,
1714
+ server_url.as_deref(),
1715
+ has_camera.unwrap_or(false),
1716
+ );
1648
1717
 
1649
1718
  Ok(NativeFullscreenStartInfo {
1650
1719
  recording_id,
@@ -2659,8 +2728,10 @@ fn discard_session(session: &mut NativeFullscreenSession) {
2659
2728
  }
2660
2729
  let _ = finalize_active_backend(session, false);
2661
2730
  for segment in &session.segments {
2731
+ remove_recording_intent(segment);
2662
2732
  let _ = std::fs::remove_file(segment);
2663
2733
  }
2734
+ remove_recording_intent(&session.path);
2664
2735
  let _ = std::fs::remove_file(&session.path);
2665
2736
  }
2666
2737
 
@@ -3076,6 +3147,399 @@ pub async fn native_fullscreen_pending_uploads(
3076
3147
  Ok(pending)
3077
3148
  }
3078
3149
 
3150
+ fn is_recording_media_path(path: &Path) -> bool {
3151
+ matches!(
3152
+ path.extension().and_then(|value| value.to_str()),
3153
+ Some("mp4" | "mov")
3154
+ )
3155
+ }
3156
+
3157
+ fn is_recording_segment_path(path: &Path) -> bool {
3158
+ let Some(stem) = path.file_stem().and_then(|value| value.to_str()) else {
3159
+ return false;
3160
+ };
3161
+ let Some((_, suffix)) = stem.rsplit_once("-seg") else {
3162
+ return false;
3163
+ };
3164
+ !suffix.is_empty() && suffix.chars().all(|value| value.is_ascii_digit())
3165
+ }
3166
+
3167
+ fn orphan_recording_id(path: &Path) -> Option<String> {
3168
+ if is_recording_segment_path(path) {
3169
+ return None;
3170
+ }
3171
+ let stem = path.file_stem().and_then(|value| value.to_str())?;
3172
+ let value = if let Some(value) = stem.strip_prefix("rewind-") {
3173
+ value
3174
+ } else if let Some(value) = stem.strip_prefix("clips-fullscreen-") {
3175
+ value
3176
+ } else {
3177
+ return None;
3178
+ };
3179
+ let (recording_id, suffix) = value.rsplit_once('-')?;
3180
+ if recording_id.is_empty() || suffix.is_empty() || !suffix.chars().all(|v| v.is_ascii_digit()) {
3181
+ return None;
3182
+ }
3183
+ Some(recording_id.to_string())
3184
+ }
3185
+
3186
+ fn orphan_recording_mime_type(path: &Path) -> &'static str {
3187
+ match path.extension().and_then(|value| value.to_str()) {
3188
+ Some("mov") => QUICKTIME_RECORDING_MIME_TYPE,
3189
+ _ => MP4_RECORDING_MIME_TYPE,
3190
+ }
3191
+ }
3192
+
3193
+ fn orphan_recording_candidates(app: &AppHandle) -> Result<Vec<PathBuf>, String> {
3194
+ let local_dir = app
3195
+ .path()
3196
+ .app_local_data_dir()
3197
+ .map_err(|error| format!("local data directory unavailable: {error}"))?
3198
+ .join("pending-recordings");
3199
+ let native_dir = pending_uploads_dir(app)?;
3200
+ let mut candidates = BTreeSet::new();
3201
+
3202
+ for directory in [local_dir, native_dir] {
3203
+ let entries = match std::fs::read_dir(&directory) {
3204
+ Ok(entries) => entries,
3205
+ Err(error) if error.kind() == ErrorKind::NotFound => continue,
3206
+ Err(error) => {
3207
+ return Err(format!(
3208
+ "orphaned recording lookup failed for {}: {error}",
3209
+ directory.display()
3210
+ ));
3211
+ }
3212
+ };
3213
+ for entry in entries.flatten() {
3214
+ let path = entry.path();
3215
+ if is_recording_media_path(&path) {
3216
+ if !is_recording_segment_path(&path) {
3217
+ candidates.insert(path);
3218
+ }
3219
+ } else if path
3220
+ .file_name()
3221
+ .and_then(|value| value.to_str())
3222
+ .is_some_and(|value| value.ends_with(".intent.json"))
3223
+ {
3224
+ if let Some(recording_path) = recording_path_from_intent(&path) {
3225
+ candidates.insert(recording_path);
3226
+ }
3227
+ }
3228
+ }
3229
+ }
3230
+ Ok(candidates.into_iter().collect())
3231
+ }
3232
+
3233
+ fn orphan_recording_paths(path: &Path) -> Vec<PathBuf> {
3234
+ let mut paths = if path.is_file() {
3235
+ vec![path.to_path_buf()]
3236
+ } else {
3237
+ Vec::new()
3238
+ };
3239
+ let Some(parent) = path.parent() else {
3240
+ return paths;
3241
+ };
3242
+ let Some(stem) = path.file_stem().and_then(|value| value.to_str()) else {
3243
+ return paths;
3244
+ };
3245
+ let Some(extension) = path.extension().and_then(|value| value.to_str()) else {
3246
+ return paths;
3247
+ };
3248
+ let prefix = format!("{stem}-seg");
3249
+ let mut segments = Vec::new();
3250
+ let Ok(entries) = std::fs::read_dir(parent) else {
3251
+ return paths;
3252
+ };
3253
+ for entry in entries.flatten() {
3254
+ let candidate = entry.path();
3255
+ if candidate.extension().and_then(|value| value.to_str()) != Some(extension) {
3256
+ continue;
3257
+ }
3258
+ let Some(candidate_stem) = candidate.file_stem().and_then(|value| value.to_str()) else {
3259
+ continue;
3260
+ };
3261
+ let Some(suffix) = candidate_stem.strip_prefix(&prefix) else {
3262
+ continue;
3263
+ };
3264
+ let Ok(index) = suffix.parse::<u32>() else {
3265
+ continue;
3266
+ };
3267
+ if candidate.is_file() {
3268
+ segments.push((index, candidate));
3269
+ }
3270
+ }
3271
+ segments.sort_by_key(|(index, _)| *index);
3272
+ paths.extend(segments.into_iter().map(|(_, path)| path));
3273
+ paths
3274
+ }
3275
+
3276
+ fn orphan_recording_intent(
3277
+ path: &Path,
3278
+ fallback_server_url: &str,
3279
+ ) -> Result<Option<NativeRecordingIntent>, String> {
3280
+ let mut intent = match read_recording_intent(path)? {
3281
+ Some(intent) => intent,
3282
+ None => {
3283
+ let Some(recording_id) = orphan_recording_id(path) else {
3284
+ return Ok(None);
3285
+ };
3286
+ if fallback_server_url.trim().is_empty() {
3287
+ return Ok(None);
3288
+ }
3289
+ let is_rewind = path
3290
+ .file_stem()
3291
+ .and_then(|value| value.to_str())
3292
+ .is_some_and(|value| value.starts_with("rewind-"));
3293
+ NativeRecordingIntent {
3294
+ recording_id,
3295
+ server_url: fallback_server_url.trim_end_matches('/').to_string(),
3296
+ mime_type: orphan_recording_mime_type(path).to_string(),
3297
+ width: None,
3298
+ height: None,
3299
+ has_audio: false,
3300
+ mic_captured: false,
3301
+ system_audio_captured: false,
3302
+ has_camera: false,
3303
+ custom_pipeline: is_rewind,
3304
+ saved_at: now_iso(),
3305
+ }
3306
+ }
3307
+ };
3308
+ if intent.server_url.trim().is_empty() {
3309
+ intent.server_url = fallback_server_url.trim_end_matches('/').to_string();
3310
+ }
3311
+ if intent.recording_id.trim().is_empty() || intent.server_url.trim().is_empty() {
3312
+ return Ok(None);
3313
+ }
3314
+ Ok(Some(intent))
3315
+ }
3316
+
3317
+ fn recover_orphaned_recording(
3318
+ app: &AppHandle,
3319
+ path: &Path,
3320
+ fallback_server_url: &str,
3321
+ ) -> Result<bool, String> {
3322
+ let Some(intent) = orphan_recording_intent(path, fallback_server_url)? else {
3323
+ return Ok(false);
3324
+ };
3325
+ let metadata_path = saved_recording_metadata_path(app, &intent.recording_id)?;
3326
+ if metadata_path.exists() {
3327
+ if let Ok(saved) = read_saved_recording_metadata_path(&metadata_path) {
3328
+ if saved_recording_has_local_artifact(&saved) {
3329
+ remove_recording_intent(path);
3330
+ return Ok(false);
3331
+ }
3332
+ }
3333
+ return Err(format!(
3334
+ "pending metadata already exists for {}",
3335
+ intent.recording_id
3336
+ ));
3337
+ }
3338
+
3339
+ let paths = orphan_recording_paths(path);
3340
+ if paths.is_empty() {
3341
+ return Ok(false);
3342
+ }
3343
+ for recording_path in &paths {
3344
+ let bytes = std::fs::metadata(recording_path)
3345
+ .map_err(|error| format!("orphaned recording metadata unavailable: {error}"))?
3346
+ .len();
3347
+ if bytes == 0 {
3348
+ return Err(format!(
3349
+ "orphaned recording is empty: {}",
3350
+ recording_path.display()
3351
+ ));
3352
+ }
3353
+ if !playable_recording_file(recording_path, &intent.mime_type) {
3354
+ return Err(format!(
3355
+ "orphaned recording is not playable yet: {}",
3356
+ recording_path.display()
3357
+ ));
3358
+ }
3359
+ }
3360
+ let duration_ms = paths.iter().try_fold(0u128, |total, recording_path| {
3361
+ let duration = probe_local_media_duration_ms(recording_path)?;
3362
+ total
3363
+ .checked_add(duration)
3364
+ .ok_or_else(|| "orphaned recording duration overflowed".to_string())
3365
+ })?;
3366
+ if duration_ms == 0 {
3367
+ return Err("orphaned recording has no measurable duration".into());
3368
+ }
3369
+ let bytes = paths.iter().try_fold(0u64, |total, recording_path| {
3370
+ let size = std::fs::metadata(recording_path)
3371
+ .map_err(|error| format!("orphaned recording metadata unavailable: {error}"))?
3372
+ .len();
3373
+ total
3374
+ .checked_add(size)
3375
+ .ok_or_else(|| "orphaned recording size overflowed".to_string())
3376
+ })?;
3377
+ let file_path = paths
3378
+ .first()
3379
+ .cloned()
3380
+ .ok_or_else(|| "orphaned recording has no file path".to_string())?;
3381
+ let segment_paths = if paths.len() > 1 {
3382
+ paths.clone()
3383
+ } else {
3384
+ Vec::new()
3385
+ };
3386
+ let saved = SavedNativeRecording {
3387
+ recording_id: intent.recording_id,
3388
+ server_url: intent.server_url.trim_end_matches('/').to_string(),
3389
+ file_path,
3390
+ segment_paths,
3391
+ mime_type: intent.mime_type,
3392
+ duration_ms,
3393
+ width: intent.width,
3394
+ height: intent.height,
3395
+ bytes,
3396
+ has_audio: intent.has_audio,
3397
+ mic_captured: intent.mic_captured,
3398
+ system_audio_captured: intent.system_audio_captured,
3399
+ has_camera: intent.has_camera,
3400
+ saved_at: intent.saved_at,
3401
+ last_attempt_at: None,
3402
+ last_error: None,
3403
+ retry_count: 0,
3404
+ retry_attempt_id: None,
3405
+ custom_pipeline: intent.custom_pipeline,
3406
+ corrupt: false,
3407
+ };
3408
+ write_saved_recording_metadata(app, &saved)?;
3409
+ Ok(true)
3410
+ }
3411
+
3412
+ fn recover_orphaned_recordings(
3413
+ app: &AppHandle,
3414
+ fallback_server_url: &str,
3415
+ ) -> Result<NativeOrphanedRecordingRecoveryResult, String> {
3416
+ let candidates = orphan_recording_candidates(app)?;
3417
+ let mut result = NativeOrphanedRecordingRecoveryResult {
3418
+ recovered: 0,
3419
+ skipped: 0,
3420
+ };
3421
+ for candidate in candidates {
3422
+ match recover_orphaned_recording(app, &candidate, fallback_server_url) {
3423
+ Ok(true) => {
3424
+ result.recovered = result.recovered.saturating_add(1);
3425
+ eprintln!(
3426
+ "[clips-tray] recovered orphaned recording for retry: {}",
3427
+ candidate.display()
3428
+ );
3429
+ }
3430
+ Ok(false) => {}
3431
+ Err(error) => {
3432
+ result.skipped = result.skipped.saturating_add(1);
3433
+ eprintln!(
3434
+ "[clips-tray] skipped orphaned recording {}: {error}",
3435
+ candidate.display()
3436
+ );
3437
+ }
3438
+ }
3439
+ }
3440
+ Ok(result)
3441
+ }
3442
+
3443
+ /// Run after the renderer starts so media inspection cannot delay tray startup.
3444
+ /// The command is idempotent: once metadata exists, the intent sidecar is
3445
+ /// removed and later startup checks leave the ordinary retry record alone.
3446
+ #[tauri::command]
3447
+ pub async fn native_fullscreen_recover_orphaned_uploads(
3448
+ app: AppHandle,
3449
+ server_url: String,
3450
+ ) -> Result<NativeOrphanedRecordingRecoveryResult, String> {
3451
+ let native_recording_active = app
3452
+ .try_state::<NativeFullscreenRecordingState>()
3453
+ .is_some_and(|state| {
3454
+ state
3455
+ .inner
3456
+ .lock()
3457
+ .map(|recording| recording.is_some())
3458
+ .unwrap_or(false)
3459
+ });
3460
+ if native_recording_active || crate::rewind_clip::is_active(&app) {
3461
+ return Ok(NativeOrphanedRecordingRecoveryResult {
3462
+ recovered: 0,
3463
+ skipped: 0,
3464
+ });
3465
+ }
3466
+ let fallback_server_url = server_url.trim_end_matches('/').to_string();
3467
+ let worker_app = app.clone();
3468
+ let result = tauri::async_runtime::spawn_blocking(move || {
3469
+ recover_orphaned_recordings(&worker_app, &fallback_server_url)
3470
+ })
3471
+ .await
3472
+ .map_err(|error| format!("orphaned recording recovery task failed: {error}"))??;
3473
+ if result.recovered > 0 {
3474
+ let _ = app.emit("clips:pending-uploads-changed", ());
3475
+ }
3476
+ Ok(result)
3477
+ }
3478
+
3479
+ #[cfg(test)]
3480
+ mod orphan_recovery_tests {
3481
+ use super::*;
3482
+
3483
+ #[test]
3484
+ fn recognizes_recording_ids_without_treating_segments_as_new_clips() {
3485
+ assert_eq!(
3486
+ orphan_recording_id(Path::new("rewind-dvDBh5T8t4FU-1723456789012.mp4")),
3487
+ Some("dvDBh5T8t4FU".into())
3488
+ );
3489
+ assert_eq!(
3490
+ orphan_recording_id(Path::new("clips-fullscreen-e4esSx9NZCZa-1234.mov")),
3491
+ Some("e4esSx9NZCZa".into())
3492
+ );
3493
+ assert!(is_recording_segment_path(Path::new(
3494
+ "clips-fullscreen-e4esSx9NZCZa-1234-seg2.mp4"
3495
+ )));
3496
+ assert!(
3497
+ orphan_recording_id(Path::new("clips-fullscreen-e4esSx9NZCZa-1234-seg2.mp4")).is_none()
3498
+ );
3499
+ assert!(orphan_recording_id(Path::new("unrelated.mp4")).is_none());
3500
+ }
3501
+
3502
+ #[test]
3503
+ fn intent_sidecars_round_trip_and_map_back_to_media() {
3504
+ let root = std::env::temp_dir().join(format!(
3505
+ "clips-recovery-intent-test-{}-{}",
3506
+ std::process::id(),
3507
+ std::time::SystemTime::now()
3508
+ .duration_since(std::time::UNIX_EPOCH)
3509
+ .unwrap()
3510
+ .as_nanos()
3511
+ ));
3512
+ let media = root.join("rewind-recording-123.mp4");
3513
+ persist_recording_intent(
3514
+ &media,
3515
+ "recording-123",
3516
+ "https://clips.example.test",
3517
+ MP4_RECORDING_MIME_TYPE,
3518
+ Some(1280),
3519
+ Some(720),
3520
+ true,
3521
+ true,
3522
+ false,
3523
+ false,
3524
+ true,
3525
+ )
3526
+ .unwrap();
3527
+ let intent_path = recording_intent_path(&media);
3528
+ assert!(intent_path.exists());
3529
+ assert_eq!(
3530
+ recording_path_from_intent(&intent_path),
3531
+ Some(media.clone())
3532
+ );
3533
+ let intent = read_recording_intent(&media).unwrap().unwrap();
3534
+ assert_eq!(intent.recording_id, "recording-123");
3535
+ assert_eq!(intent.server_url, "https://clips.example.test");
3536
+ assert!(intent.custom_pipeline);
3537
+ remove_recording_intent(&media);
3538
+ assert!(!intent_path.exists());
3539
+ let _ = std::fs::remove_dir_all(root);
3540
+ }
3541
+ }
3542
+
3079
3543
  #[tauri::command]
3080
3544
  pub async fn native_fullscreen_recording_retry_upload(
3081
3545
  app: AppHandle,
@@ -3424,6 +3888,110 @@ fn now_iso() -> String {
3424
3888
  Utc::now().to_rfc3339()
3425
3889
  }
3426
3890
 
3891
+ fn recording_intent_path(path: &Path) -> PathBuf {
3892
+ let file_name = path
3893
+ .file_name()
3894
+ .and_then(|value| value.to_str())
3895
+ .unwrap_or("recording");
3896
+ path.with_file_name(format!("{file_name}.intent.json"))
3897
+ }
3898
+
3899
+ fn recording_path_from_intent(path: &Path) -> Option<PathBuf> {
3900
+ let file_name = path.file_name()?.to_str()?;
3901
+ let recording_name = file_name.strip_suffix(".intent.json")?;
3902
+ Some(path.with_file_name(recording_name))
3903
+ }
3904
+
3905
+ /// Persist only non-secret recording facts. The auth token and cookie remain
3906
+ /// in the normal session state, so a crash cannot turn this recovery marker
3907
+ /// into a credentials file.
3908
+ pub(crate) fn persist_recording_intent(
3909
+ path: &Path,
3910
+ recording_id: &str,
3911
+ server_url: &str,
3912
+ mime_type: &str,
3913
+ width: Option<u32>,
3914
+ height: Option<u32>,
3915
+ has_audio: bool,
3916
+ mic_captured: bool,
3917
+ system_audio_captured: bool,
3918
+ has_camera: bool,
3919
+ custom_pipeline: bool,
3920
+ ) -> Result<(), String> {
3921
+ if recording_id.trim().is_empty() || server_url.trim().is_empty() {
3922
+ return Err("recording recovery intent requires a recording ID and server URL".into());
3923
+ }
3924
+ let parent = path
3925
+ .parent()
3926
+ .ok_or_else(|| format!("recording path has no parent: {}", path.display()))?;
3927
+ std::fs::create_dir_all(parent)
3928
+ .map_err(|error| format!("recording recovery directory unavailable: {error}"))?;
3929
+ let intent = NativeRecordingIntent {
3930
+ recording_id: recording_id.to_string(),
3931
+ server_url: server_url.trim_end_matches('/').to_string(),
3932
+ mime_type: mime_type.to_string(),
3933
+ width,
3934
+ height,
3935
+ has_audio,
3936
+ mic_captured,
3937
+ system_audio_captured,
3938
+ has_camera,
3939
+ custom_pipeline,
3940
+ saved_at: now_iso(),
3941
+ };
3942
+ let data = serde_json::to_vec_pretty(&intent)
3943
+ .map_err(|error| format!("recording recovery intent encode failed: {error}"))?;
3944
+ let destination = recording_intent_path(path);
3945
+ let temporary = destination.with_file_name(format!(
3946
+ ".{}.tmp",
3947
+ destination
3948
+ .file_name()
3949
+ .and_then(|value| value.to_str())
3950
+ .unwrap_or("recording.intent.json")
3951
+ ));
3952
+ std::fs::write(&temporary, data)
3953
+ .map_err(|error| format!("recording recovery intent write failed: {error}"))?;
3954
+ if let Err(error) = std::fs::rename(&temporary, &destination) {
3955
+ let _ = std::fs::remove_file(&temporary);
3956
+ return Err(format!(
3957
+ "recording recovery intent finalize failed: {error}"
3958
+ ));
3959
+ }
3960
+ Ok(())
3961
+ }
3962
+
3963
+ pub(crate) fn remove_recording_intent(path: &Path) {
3964
+ let intent_path = recording_intent_path(path);
3965
+ if let Err(error) = std::fs::remove_file(&intent_path) {
3966
+ if error.kind() != ErrorKind::NotFound {
3967
+ eprintln!(
3968
+ "[clips-tray] recording recovery intent cleanup failed for {}: {error}",
3969
+ intent_path.display()
3970
+ );
3971
+ }
3972
+ }
3973
+ }
3974
+
3975
+ fn read_recording_intent(path: &Path) -> Result<Option<NativeRecordingIntent>, String> {
3976
+ let intent_path = recording_intent_path(path);
3977
+ let data = match std::fs::read(&intent_path) {
3978
+ Ok(data) => data,
3979
+ Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
3980
+ Err(error) => {
3981
+ return Err(format!(
3982
+ "recording recovery intent read failed for {}: {error}",
3983
+ intent_path.display()
3984
+ ));
3985
+ }
3986
+ };
3987
+ serde_json::from_slice(&data).map(Some).map_err(|error| {
3988
+ format!(
3989
+ "recording recovery intent decode failed for {}: {error}",
3990
+ intent_path.display()
3991
+ )
3992
+ })
3993
+ }
3994
+
3427
3995
  fn describe_recording_path(path: &Path) -> String {
3428
3996
  let exists = path.exists();
3429
3997
  let size = std::fs::metadata(path).map(|m| m.len()).ok();
@@ -3840,7 +4408,13 @@ fn write_saved_recording_metadata(
3840
4408
  let path = saved_recording_metadata_path(app, &saved.recording_id)?;
3841
4409
  let data = serde_json::to_vec_pretty(saved)
3842
4410
  .map_err(|e| format!("pending recording metadata encode failed: {e}"))?;
3843
- std::fs::write(path, data).map_err(|e| format!("pending recording metadata write failed: {e}"))
4411
+ std::fs::write(path, data)
4412
+ .map_err(|e| format!("pending recording metadata write failed: {e}"))?;
4413
+ remove_recording_intent(&saved.file_path);
4414
+ for segment_path in &saved.segment_paths {
4415
+ remove_recording_intent(segment_path);
4416
+ }
4417
+ Ok(())
3844
4418
  }
3845
4419
 
3846
4420
  fn read_saved_recording_metadata_path(path: &Path) -> Result<SavedNativeRecording, String> {
@@ -3873,6 +4447,10 @@ fn clear_saved_recording(app: &AppHandle, saved: &SavedNativeRecording) -> Resul
3873
4447
  remove_saved_file(segment_path, "pending recording segment")?;
3874
4448
  }
3875
4449
  }
4450
+ remove_recording_intent(&saved.file_path);
4451
+ for segment_path in &saved.segment_paths {
4452
+ remove_recording_intent(segment_path);
4453
+ }
3876
4454
  let path = saved_recording_metadata_path(app, &saved.recording_id)?;
3877
4455
  remove_saved_file(&path, "pending recording metadata")
3878
4456
  }
@@ -3908,8 +4486,8 @@ pub(crate) fn persist_shared_clip_recording(
3908
4486
  server_url: &str,
3909
4487
  recording_id: &str,
3910
4488
  duration_ms: u128,
3911
- width: u32,
3912
- height: u32,
4489
+ width: Option<u32>,
4490
+ height: Option<u32>,
3913
4491
  include_mic: bool,
3914
4492
  include_system_audio: bool,
3915
4493
  has_camera: bool,
@@ -3928,8 +4506,8 @@ pub(crate) fn persist_shared_clip_recording(
3928
4506
  segment_paths: Vec::new(),
3929
4507
  mime_type: MP4_RECORDING_MIME_TYPE.to_string(),
3930
4508
  duration_ms,
3931
- width: Some(width),
3932
- height: Some(height),
4509
+ width,
4510
+ height,
3933
4511
  bytes,
3934
4512
  has_audio: include_mic || include_system_audio,
3935
4513
  mic_captured: include_mic,
@@ -3954,8 +4532,11 @@ pub(crate) fn clear_shared_clip_recording(app: &AppHandle, recording_id: &str, p
3954
4532
  let saved = read_saved_recording_metadata(app, recording_id).ok();
3955
4533
  if let Some(saved) = saved {
3956
4534
  clear_saved_recording_after_success(app, &saved);
3957
- } else if let Err(error) = remove_saved_file(path, "shared Clip temporary artifact") {
3958
- eprintln!("[clips-tray] shared Clip cleanup failed: {error}");
4535
+ } else {
4536
+ remove_recording_intent(path);
4537
+ if let Err(error) = remove_saved_file(path, "shared Clip temporary artifact") {
4538
+ eprintln!("[clips-tray] shared Clip cleanup failed: {error}");
4539
+ }
3959
4540
  }
3960
4541
  let _ = app.emit("clips:pending-uploads-changed", ());
3961
4542
  }
@@ -4731,6 +5312,11 @@ fn probe_local_media_duration_ms(path: &Path) -> Result<u128, String> {
4731
5312
  }
4732
5313
  }
4733
5314
 
5315
+ #[cfg(not(target_os = "macos"))]
5316
+ fn probe_local_media_duration_ms(_path: &Path) -> Result<u128, String> {
5317
+ Err("local media duration probing is only available on macOS".into())
5318
+ }
5319
+
4734
5320
  async fn upload_prepared_recording_file(
4735
5321
  app: &AppHandle,
4736
5322
  prepared: &PreparedRecordingFile,