@agent-native/core 0.153.0 → 0.153.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/templates/analytics/.agents/skills/adhoc-analysis/SKILL.md +21 -19
- package/corpus/templates/calendar/app/components/calendar/DayView.tsx +3 -2
- package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +3 -2
- package/corpus/templates/calendar/app/lib/event-layout.ts +16 -35
- package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +27 -55
- package/corpus/templates/clips/app/components/player/share-dialog.tsx +104 -66
- package/corpus/templates/clips/app/components/sharing/share-ui.tsx +45 -43
- package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +0 -1
- package/corpus/templates/clips/desktop/src/app.tsx +818 -442
- package/corpus/templates/clips/desktop/src/styles.css +244 -2
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +2 -2
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +1 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +584 -1
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +25 -0
- package/corpus/templates/clips/desktop/src-tauri/src/util.rs +1 -1
- package/corpus/templates/factory/app/routes/factory.tsx +0 -3
- package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +1 -1
- package/corpus/templates/slides/app/components/editor/ShareDialog.tsx +28 -25
- package/corpus/templates/slides/app/components/editor/SlideContextToolbar.tsx +50 -0
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +12 -60
- package/dist/agent/production-agent.d.ts +8 -0
- package/dist/agent/production-agent.js +25 -2
- package/dist/agent/run-manager.js +13 -3
- package/dist/client/AgentPanel.js +6 -4
- package/dist/client/org/WorkspaceNotice.js +7 -4
- package/dist/client/sharing/ShareButton.d.ts +2 -2
- package/dist/client/sharing/ShareButton.js +18 -13
- package/dist/client/sharing/ShareDialog.d.ts +1 -1
- package/dist/client/sharing/ShareDialog.js +16 -11
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/auth.js +18 -4
- package/dist/server/better-auth-instance.js +8 -1
- package/dist/server/magic-link-attribution.d.ts +21 -0
- package/dist/server/magic-link-attribution.js +115 -0
- package/dist/server/realtime-token.d.ts +1 -1
- 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)
|
|
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
|
}
|
|
@@ -4731,6 +5309,11 @@ fn probe_local_media_duration_ms(path: &Path) -> Result<u128, String> {
|
|
|
4731
5309
|
}
|
|
4732
5310
|
}
|
|
4733
5311
|
|
|
5312
|
+
#[cfg(not(target_os = "macos"))]
|
|
5313
|
+
fn probe_local_media_duration_ms(_path: &Path) -> Result<u128, String> {
|
|
5314
|
+
Err("local media duration probing is only available on macOS".into())
|
|
5315
|
+
}
|
|
5316
|
+
|
|
4734
5317
|
async fn upload_prepared_recording_file(
|
|
4735
5318
|
app: &AppHandle,
|
|
4736
5319
|
prepared: &PreparedRecordingFile,
|