From 2a55f25013aad3b120cd10d2391ffdb989ee224d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 21 Jun 2026 12:10:21 +0000 Subject: [PATCH 1/6] feat(web): add desktop session profile upload + Discord notify endpoints Co-authored-by: Richie McIlroy --- apps/web/app/api/desktop/[...route]/root.ts | 201 +++++++++++++++++++- 1 file changed, 200 insertions(+), 1 deletion(-) diff --git a/apps/web/app/api/desktop/[...route]/root.ts b/apps/web/app/api/desktop/[...route]/root.ts index 694119771b..613669f707 100644 --- a/apps/web/app/api/desktop/[...route]/root.ts +++ b/apps/web/app/api/desktop/[...route]/root.ts @@ -2,6 +2,7 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { sendEmail } from "@cap/database/emails/config"; import { Feedback } from "@cap/database/emails/feedback"; +import { nanoId } from "@cap/database/helpers"; import { authApiKeys, organizationMembers, @@ -11,7 +12,7 @@ import { import { buildEnv, serverEnv } from "@cap/env"; import { stripe, userIsPro } from "@cap/utils"; import { OrganizationBrandingPatchBody } from "@cap/web-api-contract"; -import { ImageUploads } from "@cap/web-backend"; +import { ImageUploads, S3Buckets } from "@cap/web-backend"; import { type ImageUpload, Organisation } from "@cap/web-domain"; import { zValidator } from "@hono/zod-validator"; import { and, eq, isNull } from "drizzle-orm"; @@ -440,6 +441,204 @@ app.post( }, ); +const SESSION_PROFILE_PREFIX = "desktop-session-profiles"; +const SESSION_PROFILE_UPLOAD_EXPIRY_SECONDS = 6 * 60 * 60; +const SESSION_PROFILE_DOWNLOAD_EXPIRY_SECONDS = 7 * 24 * 60 * 60; +const DISCORD_MESSAGE_MAX_LENGTH = 2000; + +function sanitizeSessionProfileFileName(fileName: string): string { + const base = fileName.split(/[\\/]/).pop() ?? ""; + const cleaned = base.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^\.+/, ""); + const trimmed = cleaned.slice(0, 128); + return trimmed.length > 0 ? trimmed : "session-profile.zip"; +} + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const exponent = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + units.length - 1, + ); + const value = bytes / 1024 ** exponent; + return `${value.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`; +} + +function formatDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) return "0s"; + const total = Math.round(seconds); + const mins = Math.floor(total / 60); + const secs = total % 60; + return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; +} + +const sessionProfileNotifySchema = z.object({ + id: z.string().min(1).max(64), + key: z.string().min(1), + note: z.string().max(4000).optional(), + os: z.string().max(64).optional(), + version: z.string().max(64).optional(), + sizeBytes: z.number().nonnegative().optional(), + recordings: z + .array( + z.object({ + mode: z.enum(["studio", "instant"]), + prettyName: z.string().max(256), + durationSeconds: z.number().nonnegative().optional(), + sizeBytes: z.number().nonnegative().optional(), + }), + ) + .max(8) + .default([]), + diagnosticsSummary: z.string().max(4000).optional(), +}); + +app.post( + "/session-profile/create", + withAuth, + zValidator( + "json", + z.object({ + fileName: z.string().min(1).max(256), + }), + ), + async (c) => { + const user = c.get("user"); + const { fileName } = c.req.valid("json"); + + const id = nanoId(); + const safeName = sanitizeSessionProfileFileName(fileName); + const key = `${SESSION_PROFILE_PREFIX}/${user.id}/${id}/${safeName}`; + + try { + const uploadUrl = await Effect.gen(function* () { + const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); + return yield* bucket.getPresignedPutUrl( + key, + {}, + { + expiresIn: SESSION_PROFILE_UPLOAD_EXPIRY_SECONDS, + }, + ); + }).pipe(runPromise); + + return c.json({ id, key, uploadUrl }); + } catch (error) { + console.error("Failed to create session profile upload URL:", error); + return c.json( + { error: "Failed to create session profile upload URL" }, + { status: 500 }, + ); + } + }, +); + +app.post( + "/session-profile/notify", + withAuth, + zValidator("json", sessionProfileNotifySchema), + async (c) => { + const user = c.get("user"); + const { + id, + key, + note, + os, + version, + sizeBytes, + recordings, + diagnosticsSummary, + } = c.req.valid("json"); + + const expectedPrefix = `${SESSION_PROFILE_PREFIX}/${user.id}/${id}/`; + if (!key.startsWith(expectedPrefix) || key.includes("..")) { + return c.json({ error: "Invalid object key" }, { status: 400 }); + } + + try { + const downloadUrl = await Effect.gen(function* () { + const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); + return yield* bucket.getSignedObjectUrl(key, { + expiresIn: SESSION_PROFILE_DOWNLOAD_EXPIRY_SECONDS, + }); + }).pipe(runPromise); + + const discordWebhookUrl = serverEnv().DISCORD_FEEDBACK_WEBHOOK_URL; + let discordDelivered = false; + + if (discordWebhookUrl) { + const recordingLines = recordings.map((recording) => { + const label = recording.mode === "studio" ? "Studio" : "Instant"; + const details = [ + recording.durationSeconds !== undefined + ? formatDuration(recording.durationSeconds) + : null, + recording.sizeBytes !== undefined + ? formatBytes(recording.sizeBytes) + : null, + ] + .filter(Boolean) + .join(", "); + return `• **${label}:** ${recording.prettyName}${ + details ? ` (${details})` : "" + }`; + }); + + const header = [ + "🧪 **New Session Profile**", + "", + `**User:** ${user.email} (${user.id})`, + os ? `**Platform:** ${os}` : null, + version ? `**App Version:** ${version}` : null, + sizeBytes !== undefined + ? `**Bundle Size:** ${formatBytes(sizeBytes)}` + : null, + note ? `**Note:** ${note}` : null, + recordingLines.length > 0 ? "" : null, + recordingLines.length > 0 ? "**Recordings:**" : null, + ...recordingLines, + "", + `**Download (expires in 7 days):** ${downloadUrl}`, + diagnosticsSummary ? "" : null, + diagnosticsSummary || null, + ] + .filter((line): line is string => line !== null) + .join("\n"); + + const content = + header.length > DISCORD_MESSAGE_MAX_LENGTH + ? `${header.slice(0, DISCORD_MESSAGE_MAX_LENGTH - 1)}…` + : header; + + const response = await fetch(discordWebhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content, + allowed_mentions: { parse: [] }, + }), + }); + + if (!response.ok) { + throw new Error( + `Failed to send session profile to Discord: ${response.statusText}`, + ); + } + + discordDelivered = true; + } + + return c.json({ success: true, downloadUrl, discordDelivered }); + } catch (error) { + console.error("Failed to notify session profile:", error); + return c.json( + { error: "Failed to notify session profile" }, + { status: 500 }, + ); + } + }, +); + app.get("/org-custom-domain", withAuth, async (c) => { const user = c.get("user"); From dd88b9217bbe77ebec552b2a32ec918ccd9d4c15 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 21 Jun 2026 12:10:28 +0000 Subject: [PATCH 2/6] feat(desktop): session profiling backend with recording bundling and S3 upload Co-authored-by: Richie McIlroy --- Cargo.lock | 16 + apps/desktop/src-tauri/Cargo.toml | 2 + apps/desktop/src-tauri/src/lib.rs | 4 + apps/desktop/src-tauri/src/logging.rs | 145 ++- apps/desktop/src-tauri/src/session_profile.rs | 1088 +++++++++++++++++ apps/desktop/src-tauri/src/web_api.rs | 2 +- apps/desktop/src/utils/tauri.ts | 13 + 7 files changed, 1266 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src-tauri/src/session_profile.rs diff --git a/Cargo.lock b/Cargo.lock index 4ea804e930..a2175c8ad4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1423,6 +1423,7 @@ dependencies = [ "tracing-opentelemetry", "tracing-subscriber", "uuid", + "walkdir", "webview2-com", "wgpu", "whisper-rs", @@ -1431,6 +1432,7 @@ dependencies = [ "windows-sys 0.59.0", "winreg 0.55.0", "workspace-hack", + "zip", ] [[package]] @@ -13223,8 +13225,10 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", + "flate2", "indexmap 2.11.4", "memchr", + "zopfli", ] [[package]] @@ -13233,6 +13237,18 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index fd8f1eecfb..bb20a29fc5 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -96,6 +96,8 @@ scap-screencapturekit = { path = "../../../crates/scap-screencapturekit" } scap-direct3d = { path = "../../../crates/scap-direct3d" } flume.workspace = true +zip = { version = "4", default-features = false, features = ["deflate"] } +walkdir = "2" tracing-subscriber = "0.3.19" tracing-appender = "0.2.3" dirs = "6.0.0" diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index f6158a1bec..bdd4bcbda0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -37,6 +37,7 @@ mod recording_settings; mod recording_telemetry; mod recovery; mod screenshot_editor; +mod session_profile; mod target_select_overlay; mod thumbnails; mod tray; @@ -4278,6 +4279,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { set_native_camera_preview_enabled, recording_settings::set_recording_mode, upload_logs, + session_profile::get_session_profile_status, + session_profile::upload_session_profile, get_system_diagnostics, cli::get_cli_install_status, cli::install_cli, @@ -4443,6 +4446,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { import::VideoImportProgress, SetCaptureAreaPending, DevicesUpdated, + session_profile::SessionProfileProgress, ]) .error_handling(tauri_specta::ErrorHandlingMode::Throw) .typ::() diff --git a/apps/desktop/src-tauri/src/logging.rs b/apps/desktop/src-tauri/src/logging.rs index c75d35ce5c..6c36354966 100644 --- a/apps/desktop/src-tauri/src/logging.rs +++ b/apps/desktop/src-tauri/src/logging.rs @@ -7,7 +7,7 @@ use serde::Serialize; use std::{fs, path::PathBuf}; use tauri::{AppHandle, Manager}; -async fn get_latest_log_file(app: &AppHandle) -> Option { +pub async fn get_latest_log_file(app: &AppHandle) -> Option { let logs_dir = app .state::>() .read() @@ -36,7 +36,7 @@ async fn get_latest_log_file(app: &AppHandle) -> Option { #[derive(Serialize)] #[serde(rename_all = "camelCase")] -struct LogUploadDiagnostics { +pub struct LogUploadDiagnostics { hardware: HardwareInfo, system: cap_recording::diagnostics::SystemDiagnostics, displays: Vec, @@ -130,7 +130,7 @@ fn collect_storage_info(recordings_path: &std::path::Path) -> Option String { + let value = serde_json::to_value(diagnostics).unwrap_or(serde_json::Value::Null); + let mut lines: Vec = Vec::new(); + + if let Some(hardware) = value.get("hardware") { + let cpu = hardware + .get("cpuBrand") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + let cores = hardware + .get("cpuCores") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let arch = hardware + .get("architecture") + .and_then(|v| v.as_str()) + .unwrap_or(""); + lines.push(format!("**CPU:** {cpu} ({cores} cores, {arch})")); + + let total = hardware + .get("totalMemoryMb") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let available = hardware + .get("availableMemoryMb") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + lines.push(format!("**Memory:** {available} MB free / {total} MB")); + } + + let system = value.get("system"); + + let os_line = system + .and_then(|s| s.get("macosVersion")) + .and_then(|v| v.get("displayName")) + .and_then(|v| v.as_str()) + .or_else(|| { + system + .and_then(|s| s.get("windowsVersion")) + .and_then(|v| v.get("displayName")) + .and_then(|v| v.as_str()) + }); + if let Some(os) = os_line { + lines.push(format!("**OS:** {os}")); + } else if let Some(kernel) = system + .and_then(|s| s.get("kernelVersion")) + .and_then(|v| v.as_str()) + { + lines.push(format!("**Kernel:** {kernel}")); + } + + let gpu = system + .and_then(|s| s.get("gpuName")) + .and_then(|v| v.as_str()) + .or_else(|| { + system + .and_then(|s| s.get("gpuInfo")) + .and_then(|v| v.get("description")) + .and_then(|v| v.as_str()) + }); + if let Some(gpu) = gpu { + lines.push(format!("**GPU:** {gpu}")); + } + + if let Some(encoders) = system + .and_then(|s| s.get("availableEncoders")) + .and_then(|v| v.as_array()) + { + let list: Vec<&str> = encoders.iter().filter_map(|e| e.as_str()).collect(); + if !list.is_empty() { + lines.push(format!("**Encoders:** {}", list.join(", "))); + } + } + + let capture_supported = system + .and_then(|s| s.get("screenCaptureSupported")) + .and_then(|v| v.as_bool()) + .or_else(|| { + system + .and_then(|s| s.get("graphicsCaptureSupported")) + .and_then(|v| v.as_bool()) + }); + if let Some(supported) = capture_supported { + lines.push(format!( + "**Screen Capture:** {}", + if supported { + "āœ… Supported" + } else { + "āŒ Not Supported" + } + )); + } + + if let Some(displays) = value.get("displays").and_then(|v| v.as_array()) { + lines.push(format!("**Displays:** {}", displays.len())); + } + if let Some(cameras) = value.get("cameras").and_then(|v| v.as_array()) { + lines.push(format!("**Cameras:** {}", cameras.len())); + } + if let Some(microphones) = value.get("microphones").and_then(|v| v.as_array()) { + lines.push(format!("**Mics:** {}", microphones.len())); + } + + if let Some(permissions) = value.get("permissions") { + let screen = permissions + .get("screenRecording") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let camera = permissions + .get("camera") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let microphone = permissions + .get("microphone") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + lines.push(format!( + "**Permissions:** Screen: {screen}, Camera: {camera}, Mic: {microphone}" + )); + } + + if let Some(storage) = value.get("storage").filter(|v| !v.is_null()) { + let available = storage + .get("availableSpaceMb") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let total = storage + .get("totalSpaceMb") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + lines.push(format!("**Disk:** {available} MB free / {total} MB")); + } + + lines.join("\n") +} + pub async fn upload_log_file(app: &AppHandle) -> Result<(), String> { let log_file = get_latest_log_file(app).await.ok_or("No log file found")?; diff --git a/apps/desktop/src-tauri/src/session_profile.rs b/apps/desktop/src-tauri/src/session_profile.rs new file mode 100644 index 0000000000..bdc708b98b --- /dev/null +++ b/apps/desktop/src-tauri/src/session_profile.rs @@ -0,0 +1,1088 @@ +//! Session profiling: bundles a user's latest Studio and Instant recordings +//! together with full system diagnostics and logs, uploads the bundle to S3 and +//! posts the download link to the Cap feedback Discord channel. This gives the +//! team everything required to reproduce and debug a user's session. + +use std::{ + io::{Read, Seek, Write}, + path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicI64, Ordering}, + }, + time::{SystemTime, UNIX_EPOCH}, +}; + +use cap_project::{RecordingMeta, RecordingMetaInner}; +use cap_recording::RecordingMode; +use futures::StreamExt; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri::{AppHandle, Manager}; +use tauri_specta::Event; +use tokio_util::io::ReaderStream; +use tracing::{info, instrument, warn}; +use walkdir::WalkDir; +use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions}; + +use crate::{ + auth::{AuthSecret, AuthStore}, + http_client::RetryableHttpClient, + logging, + web_api::ManagerExt, +}; + +const STUDIO_DIR: &str = "studio"; +const INSTANT_DIR: &str = "instant"; +const ZIP_LARGE_FILE_THRESHOLD: u64 = u32::MAX as u64; +const ZIP_COPY_BUFFER_SIZE: usize = 1024 * 1024; + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SessionProfileRecording { + pub mode: RecordingMode, + pub pretty_name: String, + #[specta(type = String)] + pub path: PathBuf, + pub modified_at: Option, + pub size_bytes: f64, +} + +#[derive(Debug, Clone, Default, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SessionProfileStatus { + pub studio: Option, + pub instant: Option, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SessionProfileUploadResult { + pub uploaded: bool, + pub download_url: Option, + pub discord_delivered: bool, + pub included_modes: Vec, + pub bundle_size_bytes: f64, +} + +#[derive(Debug, Clone, Copy, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum SessionProfileStage { + Collecting, + Compressing, + Uploading, + Notifying, + Done, +} + +#[derive(Clone, Serialize, Type, tauri_specta::Event)] +#[serde(rename_all = "camelCase")] +pub struct SessionProfileProgress { + pub stage: SessionProfileStage, + pub progress: f64, + pub message: String, +} + +struct RecordingCandidate { + mode: RecordingMode, + path: PathBuf, + pretty_name: String, + modified: SystemTime, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProfileSummaryRecording { + mode: RecordingMode, + pretty_name: String, + bundle_path: String, + source_path: String, + size_bytes: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProfileSummary { + generated_at: String, + app_version: String, + os: String, + arch: String, + #[serde(skip_serializing_if = "Option::is_none")] + note: Option, + recordings: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CreateUploadRequest { + file_name: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateUploadResponse { + id: String, + key: String, + upload_url: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct NotifyRecording { + mode: RecordingMode, + pretty_name: String, + size_bytes: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct NotifyRequest { + id: String, + key: String, + #[serde(skip_serializing_if = "Option::is_none")] + note: Option, + os: String, + version: String, + size_bytes: u64, + recordings: Vec, + diagnostics_summary: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct NotifyResponse { + #[allow(dead_code)] + success: bool, + download_url: Option, + #[serde(default)] + discord_delivered: bool, +} + +struct TempFileGuard(PathBuf); + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if self.0.exists() + && let Err(err) = std::fs::remove_file(&self.0) + { + warn!(error = %err, path = %self.0.display(), "Failed to clean up session profile bundle"); + } + } +} + +fn directory_size(path: &Path) -> u64 { + WalkDir::new(path) + .into_iter() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_file()) + .filter_map(|entry| entry.metadata().ok()) + .map(|metadata| metadata.len()) + .sum() +} + +fn system_time_to_millis(time: SystemTime) -> Option { + time.duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_millis() as f64) +} + +fn candidate_to_recording(candidate: RecordingCandidate) -> SessionProfileRecording { + SessionProfileRecording { + mode: candidate.mode, + pretty_name: candidate.pretty_name, + modified_at: system_time_to_millis(candidate.modified), + size_bytes: directory_size(&candidate.path) as f64, + path: candidate.path, + } +} + +/// Scans the recordings directory and returns the most-recently-modified Studio +/// and Instant recordings, if any exist. +pub fn find_latest_recordings(recordings_dir: &Path) -> SessionProfileStatus { + let mut studio: Option = None; + let mut instant: Option = None; + + let Ok(entries) = std::fs::read_dir(recordings_dir) else { + return SessionProfileStatus::default(); + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let Ok(meta) = RecordingMeta::load_for_project(&path) else { + continue; + }; + + let mode = match meta.inner { + RecordingMetaInner::Studio(_) => RecordingMode::Studio, + RecordingMetaInner::Instant(_) => RecordingMode::Instant, + }; + + let modified = entry + .metadata() + .and_then(|metadata| metadata.modified()) + .unwrap_or(UNIX_EPOCH); + + let slot = match mode { + RecordingMode::Studio => &mut studio, + RecordingMode::Instant => &mut instant, + RecordingMode::Screenshot => continue, + }; + + let is_newer = slot + .as_ref() + .map(|existing| modified > existing.modified) + .unwrap_or(true); + + if is_newer { + *slot = Some(RecordingCandidate { + mode, + path, + pretty_name: meta.pretty_name, + modified, + }); + } + } + + SessionProfileStatus { + studio: studio.map(candidate_to_recording), + instant: instant.map(candidate_to_recording), + } +} + +fn zip_compression_method(path: &Path) -> CompressionMethod { + const STORED_EXTENSIONS: &[&str] = &[ + "mp4", "mov", "m4a", "m4s", "mp3", "aac", "ogg", "opus", "wav", "webm", "mkv", "flac", + "jpg", "jpeg", "png", "gif", "webp", "heic", "zip", "gz", + ]; + + let is_stored = path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| STORED_EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str())) + .unwrap_or(false); + + if is_stored { + CompressionMethod::Stored + } else { + CompressionMethod::Deflated + } +} + +fn relative_zip_path(relative: &Path) -> String { + relative + .components() + .filter_map(|component| match component { + std::path::Component::Normal(value) => value.to_str().map(ToString::to_string), + _ => None, + }) + .collect::>() + .join("/") +} + +fn write_zip_text( + zip: &mut ZipWriter, + name: &str, + contents: &str, +) -> Result<(), String> { + let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + zip.start_file(name, options) + .map_err(|err| format!("Failed to start zip entry {name}: {err}"))?; + zip.write_all(contents.as_bytes()) + .map_err(|err| format!("Failed to write zip entry {name}: {err}"))?; + Ok(()) +} + +fn stream_file_into_zip( + zip: &mut ZipWriter, + source: &Path, + done: &mut u64, + total: u64, + progress: &mut P, +) -> Result<(), String> { + let mut file = + std::fs::File::open(source).map_err(|err| format!("Failed to open {source:?}: {err}"))?; + let mut buffer = vec![0u8; ZIP_COPY_BUFFER_SIZE]; + + loop { + let read = file + .read(&mut buffer) + .map_err(|err| format!("Failed to read {source:?}: {err}"))?; + if read == 0 { + break; + } + zip.write_all(&buffer[..read]) + .map_err(|err| format!("Failed to write {source:?} into bundle: {err}"))?; + *done = done.saturating_add(read as u64); + progress(*done, total); + } + + Ok(()) +} + +/// Builds the zip bundle containing every supplied recording directory along +/// with the diagnostics, profile summary and latest log file. Returns the final +/// bundle size in bytes. +pub fn build_profile_zip( + output_path: &Path, + recordings: &[SessionProfileRecording], + diagnostics_json: &str, + profile_json: &str, + log_file: Option<&Path>, + mut progress: impl FnMut(u64, u64), +) -> Result { + let log_size = log_file + .and_then(|path| std::fs::metadata(path).ok()) + .map(|metadata| metadata.len()) + .unwrap_or(0); + + let total: u64 = recordings + .iter() + .map(|recording| recording.size_bytes as u64) + .sum::() + + log_size + + diagnostics_json.len() as u64 + + profile_json.len() as u64; + + let mut done: u64 = 0; + + let file = std::fs::File::create(output_path) + .map_err(|err| format!("Failed to create bundle file: {err}"))?; + let mut zip = ZipWriter::new(file); + + write_zip_text(&mut zip, "profile.json", profile_json)?; + done = done.saturating_add(profile_json.len() as u64); + progress(done, total); + + write_zip_text(&mut zip, "diagnostics.json", diagnostics_json)?; + done = done.saturating_add(diagnostics_json.len() as u64); + progress(done, total); + + if let Some(log) = log_file + && log.exists() + { + let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + zip.start_file("cap-desktop.log", options) + .map_err(|err| format!("Failed to start log zip entry: {err}"))?; + stream_file_into_zip(&mut zip, log, &mut done, total, &mut progress)?; + } + + for recording in recordings { + let mode_dir = match recording.mode { + RecordingMode::Studio => STUDIO_DIR, + RecordingMode::Instant => INSTANT_DIR, + RecordingMode::Screenshot => continue, + }; + + let root = recording.path.as_path(); + let base_name = root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("recording"); + + for entry in WalkDir::new(root).into_iter().filter_map(Result::ok) { + if !entry.file_type().is_file() { + continue; + } + + let file_path = entry.path(); + let relative = file_path.strip_prefix(root).unwrap_or(file_path); + let relative_unix = relative_zip_path(relative); + if relative_unix.is_empty() { + continue; + } + + let name = format!("{mode_dir}/{base_name}/{relative_unix}"); + let file_size = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0); + let options = SimpleFileOptions::default() + .compression_method(zip_compression_method(file_path)) + .large_file(file_size >= ZIP_LARGE_FILE_THRESHOLD); + + zip.start_file(name.clone(), options) + .map_err(|err| format!("Failed to start zip entry {name}: {err}"))?; + stream_file_into_zip(&mut zip, file_path, &mut done, total, &mut progress)?; + } + } + + let mut finished = zip + .finish() + .map_err(|err| format!("Failed to finalize bundle: {err}"))?; + finished + .flush() + .map_err(|err| format!("Failed to flush bundle: {err}"))?; + + let size = std::fs::metadata(output_path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + progress(total, total); + + Ok(size) +} + +async fn request_upload_target( + client: &Client, + base_url: &str, + bearer: &str, + file_name: &str, +) -> Result { + let url = format!("{base_url}/api/desktop/session-profile/create"); + let request = crate::web_api::apply_env_headers(client.post(&url).bearer_auth(bearer).json( + &CreateUploadRequest { + file_name: file_name.to_string(), + }, + )); + + let response = request + .send() + .await + .map_err(|err| format!("Failed to request upload URL: {err}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("Upload URL request failed ({status}): {body}")); + } + + response + .json::() + .await + .map_err(|err| format!("Failed to parse upload URL response: {err}")) +} + +async fn upload_file_streaming( + client: &Client, + url: &str, + path: &Path, + on_progress: F, +) -> Result<(), String> +where + F: Fn(u64, u64) + Send + 'static, +{ + let file = tokio::fs::File::open(path) + .await + .map_err(|err| format!("Failed to open bundle for upload: {err}"))?; + let total = file + .metadata() + .await + .map_err(|err| format!("Failed to read bundle metadata: {err}"))? + .len(); + + let body_stream = async_stream::stream! { + let mut reader = ReaderStream::new(file); + let mut sent: u64 = 0; + while let Some(chunk) = reader.next().await { + match chunk { + Ok(bytes) => { + sent = sent.saturating_add(bytes.len() as u64); + on_progress(sent, total); + yield Ok::(bytes); + } + Err(err) => yield Err(err), + } + } + }; + + let response = client + .put(url) + .header("Content-Length", total) + .body(reqwest::Body::wrap_stream(body_stream)) + .send() + .await + .map_err(|err| format!("Failed to upload bundle: {err}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("Bundle upload failed ({status}): {body}")); + } + + Ok(()) +} + +async fn send_notify( + client: &Client, + base_url: &str, + bearer: &str, + payload: &NotifyRequest, +) -> Result { + let url = format!("{base_url}/api/desktop/session-profile/notify"); + let request = + crate::web_api::apply_env_headers(client.post(&url).bearer_auth(bearer).json(payload)); + + let response = request + .send() + .await + .map_err(|err| format!("Failed to notify session profile: {err}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("Session profile notify failed ({status}): {body}")); + } + + response + .json::() + .await + .map_err(|err| format!("Failed to parse notify response: {err}")) +} + +fn auth_bearer_token(app: &AppHandle) -> Result { + let auth = AuthStore::get(app) + .map_err(|err| format!("Failed to read auth store: {err}"))? + .ok_or("You must be signed in to send a session profile.")?; + + Ok(match auth.secret { + AuthSecret::ApiKey { api_key } => api_key, + AuthSecret::Session { token, .. } => token, + }) +} + +fn emit_progress(app: &AppHandle, stage: SessionProfileStage, progress: f64, message: &str) { + let _ = SessionProfileProgress { + stage, + progress: progress.clamp(0.0, 1.0), + message: message.to_string(), + } + .emit(app); +} + +fn build_profile_summary( + recordings: &[SessionProfileRecording], + note: Option<&str>, +) -> ProfileSummary { + ProfileSummary { + generated_at: chrono::Utc::now().to_rfc3339(), + app_version: env!("CARGO_PKG_VERSION").to_string(), + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + note: note.map(ToString::to_string), + recordings: recordings + .iter() + .map(|recording| { + let mode_dir = match recording.mode { + RecordingMode::Studio => STUDIO_DIR, + RecordingMode::Instant | RecordingMode::Screenshot => INSTANT_DIR, + }; + let base_name = recording + .path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("recording"); + ProfileSummaryRecording { + mode: recording.mode, + pretty_name: recording.pretty_name.clone(), + bundle_path: format!("{mode_dir}/{base_name}"), + source_path: recording.path.display().to_string(), + size_bytes: recording.size_bytes as u64, + } + }) + .collect(), + } +} + +async fn run_upload( + app: &AppHandle, + note: Option, +) -> Result { + emit_progress( + app, + SessionProfileStage::Collecting, + 0.0, + "Collecting recordings…", + ); + + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|err| format!("Failed to resolve app data dir: {err}"))?; + let recordings_dir = app_data_dir.join("recordings"); + + let status = find_latest_recordings(&recordings_dir); + let mut recordings: Vec = Vec::new(); + if let Some(studio) = status.studio { + recordings.push(studio); + } + if let Some(instant) = status.instant { + recordings.push(instant); + } + + if recordings.is_empty() { + return Err("No Studio or Instant recordings found to profile.".to_string()); + } + + let is_recording = { + let app_lock = app.state::>(); + let state = app_lock.read().await; + matches!( + state.recording_state, + crate::RecordingState::Active(_) | crate::RecordingState::Pending { .. } + ) + }; + + let diagnostics = + logging::collect_diagnostics_for_upload(&recordings_dir, &app_data_dir, is_recording); + let diagnostics_json = + serde_json::to_string_pretty(&diagnostics).unwrap_or_else(|_| "{}".to_string()); + let diagnostics_summary = logging::summarize_diagnostics(&diagnostics); + + let profile_summary = build_profile_summary(&recordings, note.as_deref()); + let profile_json = + serde_json::to_string_pretty(&profile_summary).unwrap_or_else(|_| "{}".to_string()); + + let log_file = logging::get_latest_log_file(app).await; + + let file_name = format!( + "cap-session-profile-{}.zip", + chrono::Utc::now().format("%Y%m%d-%H%M%S") + ); + let zip_path = std::env::temp_dir().join(format!("{}-{file_name}", uuid::Uuid::new_v4())); + let bundle_guard = TempFileGuard(zip_path.clone()); + + emit_progress( + app, + SessionProfileStage::Compressing, + 0.0, + "Compressing recordings…", + ); + + let bundle_size = { + let app_progress = app.clone(); + let zip_path = zip_path.clone(); + let recordings = recordings.clone(); + let diagnostics_json = diagnostics_json.clone(); + let profile_json = profile_json.clone(); + let log_file = log_file.clone(); + + tokio::task::spawn_blocking(move || { + let mut last_pct: i64 = -1; + build_profile_zip( + &zip_path, + &recordings, + &diagnostics_json, + &profile_json, + log_file.as_deref(), + |done, total| { + let pct = if total > 0 { + ((done.saturating_mul(100)) / total) as i64 + } else { + 100 + }; + if pct != last_pct { + last_pct = pct; + emit_progress( + &app_progress, + SessionProfileStage::Compressing, + pct as f64 / 100.0, + "Compressing recordings…", + ); + } + }, + ) + }) + .await + .map_err(|err| format!("Bundle task failed: {err}"))?? + }; + + let client = app + .state::() + .as_ref() + .map_err(|err| format!("HTTP client unavailable: {err:?}"))? + .clone(); + let base_url = app.make_app_url("").await; + let bearer = auth_bearer_token(app)?; + + emit_progress( + app, + SessionProfileStage::Uploading, + 0.0, + "Uploading bundle…", + ); + + let upload_target = request_upload_target(&client, &base_url, &bearer, &file_name).await?; + + { + let app_progress = app.clone(); + let last = Arc::new(AtomicI64::new(-1)); + upload_file_streaming( + &client, + &upload_target.upload_url, + &zip_path, + move |sent, total| { + let pct = if total > 0 { + ((sent.saturating_mul(100)) / total) as i64 + } else { + 100 + }; + if last.swap(pct, Ordering::Relaxed) != pct { + emit_progress( + &app_progress, + SessionProfileStage::Uploading, + pct as f64 / 100.0, + "Uploading bundle…", + ); + } + }, + ) + .await?; + } + + emit_progress( + app, + SessionProfileStage::Notifying, + 0.9, + "Notifying the Cap team…", + ); + + let included_modes: Vec = + recordings.iter().map(|recording| recording.mode).collect(); + + let notify_request = NotifyRequest { + id: upload_target.id, + key: upload_target.key, + note, + os: std::env::consts::OS.to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + size_bytes: bundle_size, + recordings: recordings + .iter() + .map(|recording| NotifyRecording { + mode: recording.mode, + pretty_name: recording.pretty_name.clone(), + size_bytes: recording.size_bytes as u64, + }) + .collect(), + diagnostics_summary, + }; + + let notify = send_notify(&client, &base_url, &bearer, ¬ify_request).await?; + + drop(bundle_guard); + + emit_progress(app, SessionProfileStage::Done, 1.0, "Session profile sent!"); + + info!( + modes = ?included_modes, + bundle_size, + "Session profile uploaded" + ); + + Ok(SessionProfileUploadResult { + uploaded: true, + download_url: notify.download_url, + discord_delivered: notify.discord_delivered, + included_modes, + bundle_size_bytes: bundle_size as f64, + }) +} + +#[tauri::command] +#[specta::specta] +#[instrument(skip(app))] +pub async fn get_session_profile_status(app: AppHandle) -> Result { + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|err| format!("Failed to resolve app data dir: {err}"))?; + let recordings_dir = app_data_dir.join("recordings"); + Ok(find_latest_recordings(&recordings_dir)) +} + +#[tauri::command] +#[specta::specta] +#[instrument(skip(app))] +pub async fn upload_session_profile( + app: AppHandle, + note: Option, +) -> Result { + let note = note.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); + + run_upload(&app, note).await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_file(path: &Path, contents: &[u8]) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, contents).unwrap(); + } + + fn make_studio_recording(dir: &Path, name: &str, display_bytes: &[u8]) { + write_file( + &dir.join("recording-meta.json"), + format!( + "{{ \"pretty_name\": \"{name}\", \"display\": {{ \"path\": \"content/display.mp4\" }} }}" + ) + .as_bytes(), + ); + write_file(&dir.join("content/display.mp4"), display_bytes); + } + + fn make_instant_recording(dir: &Path, name: &str, output_bytes: &[u8]) { + write_file( + &dir.join("recording-meta.json"), + format!("{{ \"pretty_name\": \"{name}\", \"fps\": 30 }}").as_bytes(), + ); + write_file(&dir.join("content/output.mp4"), output_bytes); + } + + #[test] + fn zip_method_selects_store_for_media() { + assert_eq!( + zip_compression_method(Path::new("content/display.mp4")), + CompressionMethod::Stored + ); + assert_eq!( + zip_compression_method(Path::new("recording-meta.json")), + CompressionMethod::Deflated + ); + assert_eq!( + zip_compression_method(Path::new("a/b/IMAGE.PNG")), + CompressionMethod::Stored + ); + } + + #[test] + fn finds_latest_recordings_per_mode() { + let temp = tempfile::tempdir().unwrap(); + let recordings_dir = temp.path(); + + make_studio_recording( + &recordings_dir.join("studio-old"), + "Studio Old", + b"old-video", + ); + std::thread::sleep(std::time::Duration::from_millis(50)); + make_studio_recording( + &recordings_dir.join("studio-new"), + "Studio New", + b"new-video-data", + ); + make_instant_recording( + &recordings_dir.join("instant-one"), + "Instant One", + b"instant-video", + ); + + let status = find_latest_recordings(recordings_dir); + + let studio = status.studio.expect("studio recording should be found"); + assert_eq!(studio.mode, RecordingMode::Studio); + assert_eq!(studio.pretty_name, "Studio New"); + assert!(studio.size_bytes > 0.0); + + let instant = status.instant.expect("instant recording should be found"); + assert_eq!(instant.mode, RecordingMode::Instant); + assert_eq!(instant.pretty_name, "Instant One"); + } + + #[test] + fn returns_empty_status_when_no_recordings() { + let temp = tempfile::tempdir().unwrap(); + let status = find_latest_recordings(temp.path()); + assert!(status.studio.is_none()); + assert!(status.instant.is_none()); + } + + #[test] + fn builds_bundle_with_all_artifacts() { + let temp = tempfile::tempdir().unwrap(); + let recordings_dir = temp.path().join("recordings"); + make_studio_recording( + &recordings_dir.join("studio-1"), + "Studio One", + b"studio-video", + ); + make_instant_recording( + &recordings_dir.join("instant-1"), + "Instant One", + b"instant-video", + ); + + let status = find_latest_recordings(&recordings_dir); + let recordings: Vec = [status.studio, status.instant] + .into_iter() + .flatten() + .collect(); + assert_eq!(recordings.len(), 2); + + let log_path = temp.path().join("cap-desktop.log"); + std::fs::write(&log_path, b"line one\nline two\n").unwrap(); + + let zip_path = temp.path().join("bundle.zip"); + let size = build_profile_zip( + &zip_path, + &recordings, + "{\"diagnostics\":true}", + "{\"profile\":true}", + Some(&log_path), + |_, _| {}, + ) + .unwrap(); + assert!(size > 0); + + let file = std::fs::File::open(&zip_path).unwrap(); + let mut archive = zip::ZipArchive::new(file).unwrap(); + let names: Vec = archive.file_names().map(ToString::to_string).collect(); + + assert!(names.contains(&"profile.json".to_string())); + assert!(names.contains(&"diagnostics.json".to_string())); + assert!(names.contains(&"cap-desktop.log".to_string())); + assert!( + names + .iter() + .any(|name| name == "studio/studio-1/content/display.mp4") + ); + assert!( + names + .iter() + .any(|name| name == "instant/instant-1/content/output.mp4") + ); + + let mut diagnostics = String::new(); + archive + .by_name("diagnostics.json") + .unwrap() + .read_to_string(&mut diagnostics) + .unwrap(); + assert_eq!(diagnostics, "{\"diagnostics\":true}"); + } + + #[tokio::test] + async fn upload_and_notify_round_trip() { + use std::sync::Mutex as StdMutex; + + let temp = tempfile::tempdir().unwrap(); + let bundle_path = temp.path().join("bundle.zip"); + let bundle_bytes = b"this-is-the-bundle-contents".to_vec(); + std::fs::write(&bundle_path, &bundle_bytes).unwrap(); + + let received_put = Arc::new(StdMutex::new(Vec::::new())); + let received_notify = Arc::new(StdMutex::new(String::new())); + let created_with = Arc::new(StdMutex::new(String::new())); + + let put_state = received_put.clone(); + let notify_state = received_notify.clone(); + let create_state = created_with.clone(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://{addr}"); + let put_url = format!("{base_url}/s3-put/object.zip"); + let put_url_for_create = put_url.clone(); + + let router = axum::Router::new() + .route( + "/api/desktop/session-profile/create", + axum::routing::post({ + let put_url = put_url_for_create.clone(); + move |body: String| { + let create_state = create_state.clone(); + let put_url = put_url.clone(); + async move { + *create_state.lock().unwrap() = body; + axum::Json(serde_json::json!({ + "id": "profile-id", + "key": "desktop-session-profiles/user/profile-id/object.zip", + "uploadUrl": put_url, + })) + } + } + }), + ) + .route( + "/s3-put/object.zip", + axum::routing::put(move |body: axum::body::Bytes| { + let put_state = put_state.clone(); + async move { + put_state.lock().unwrap().extend_from_slice(&body); + axum::http::StatusCode::OK + } + }), + ) + .route( + "/api/desktop/session-profile/notify", + axum::routing::post(move |body: String| { + let notify_state = notify_state.clone(); + async move { + *notify_state.lock().unwrap() = body; + axum::Json(serde_json::json!({ + "success": true, + "downloadUrl": "https://example.com/download", + "discordDelivered": true, + })) + } + }), + ); + + let server = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + + let client = reqwest::Client::new(); + + let create = request_upload_target(&client, &base_url, "test-token", "bundle.zip") + .await + .unwrap(); + assert_eq!(create.id, "profile-id"); + assert_eq!(create.upload_url, put_url); + assert!( + created_with + .lock() + .unwrap() + .contains("\"fileName\":\"bundle.zip\"") + ); + + upload_file_streaming(&client, &create.upload_url, &bundle_path, |_, _| {}) + .await + .unwrap(); + assert_eq!(*received_put.lock().unwrap(), bundle_bytes); + + let notify_request = NotifyRequest { + id: create.id, + key: create.key, + note: Some("it crashed".to_string()), + os: "linux".to_string(), + version: "0.0.0".to_string(), + size_bytes: bundle_bytes.len() as u64, + recordings: vec![NotifyRecording { + mode: RecordingMode::Studio, + pretty_name: "Studio One".to_string(), + size_bytes: 123, + }], + diagnostics_summary: "**CPU:** test".to_string(), + }; + + let notify = send_notify(&client, &base_url, "test-token", ¬ify_request) + .await + .unwrap(); + assert_eq!( + notify.download_url.as_deref(), + Some("https://example.com/download") + ); + assert!(notify.discord_delivered); + + let notify_body = received_notify.lock().unwrap().clone(); + assert!( + notify_body.contains("\"key\":\"desktop-session-profiles/user/profile-id/object.zip\"") + ); + assert!(notify_body.contains("\"mode\":\"studio\"")); + assert!(notify_body.contains("\"note\":\"it crashed\"")); + + server.abort(); + } +} diff --git a/apps/desktop/src-tauri/src/web_api.rs b/apps/desktop/src-tauri/src/web_api.rs index e0e4ec6d2a..afe9195f9b 100644 --- a/apps/desktop/src-tauri/src/web_api.rs +++ b/apps/desktop/src-tauri/src/web_api.rs @@ -50,7 +50,7 @@ impl From for AuthedApiError { } } -fn apply_env_headers(req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { +pub(crate) fn apply_env_headers(req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { let mut req = req .header("X-Cap-Desktop-Version", env!("CARGO_PKG_VERSION")) .header("X-Cap-Desktop-Features", "googleDriveUpload"); diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index b397b3ed66..e5b665be0a 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -20,6 +20,12 @@ async setRecordingMode(mode: RecordingMode) : Promise { async uploadLogs() : Promise { return await TAURI_INVOKE("upload_logs"); }, +async getSessionProfileStatus() : Promise { + return await TAURI_INVOKE("get_session_profile_status"); +}, +async uploadSessionProfile(note: string | null) : Promise { + return await TAURI_INVOKE("upload_session_profile", { note }); +}, async getSystemDiagnostics() : Promise { return await TAURI_INVOKE("get_system_diagnostics"); }, @@ -464,6 +470,7 @@ requestScreenCapturePrewarm: RequestScreenCapturePrewarm, requestScrollToSettingsSection: RequestScrollToSettingsSection, requestSetTargetMode: RequestSetTargetMode, requestStartRecording: RequestStartRecording, +sessionProfileProgress: SessionProfileProgress, setCaptureAreaPending: SetCaptureAreaPending, targetUnderCursor: TargetUnderCursor, uploadProgressEvent: UploadProgressEvent, @@ -491,6 +498,7 @@ requestScreenCapturePrewarm: "request-screen-capture-prewarm", requestScrollToSettingsSection: "request-scroll-to-settings-section", requestSetTargetMode: "request-set-target-mode", requestStartRecording: "request-start-recording", +sessionProfileProgress: "session-profile-progress", setCaptureAreaPending: "set-capture-area-pending", targetUnderCursor: "target-under-cursor", uploadProgressEvent: "upload-progress-event", @@ -715,6 +723,11 @@ export type ScreenshotOcrResult = { text: string; lines: ScreenshotOcrLine[]; en export type SegmentRecordings = { display: Video; camera: Video | null; mic: Audio | null; system_audio: Audio | null } export type SerializedEditorInstance = { framesSocketUrl: string; recordingDuration: number; savedProjectConfig: ProjectConfiguration; recordings: ProjectRecordingsMeta; path: string } export type SerializedScreenshotEditorInstance = { framesSocketUrl: string; path: string; config: ProjectConfiguration | null; prettyName: string; imageWidth: number; imageHeight: number } +export type SessionProfileProgress = { stage: SessionProfileStage; progress: number; message: string } +export type SessionProfileRecording = { mode: RecordingMode; prettyName: string; path: string; modifiedAt: number | null; sizeBytes: number } +export type SessionProfileStage = "collecting" | "compressing" | "uploading" | "notifying" | "done" +export type SessionProfileStatus = { studio: SessionProfileRecording | null; instant: SessionProfileRecording | null } +export type SessionProfileUploadResult = { uploaded: boolean; downloadUrl: string | null; discordDelivered: boolean; includedModes: RecordingMode[]; bundleSizeBytes: number } export type SetCaptureAreaPending = boolean export type ShadowConfiguration = { size: number; opacity: number; blur: number } export type SharingMeta = { id: string; link: string } From b2f45beeefc5f08d2dbc01e76cccd812a9a3e5d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 21 Jun 2026 12:10:29 +0000 Subject: [PATCH 3/6] =?UTF-8?q?feat(desktop):=20add=20Session=20Profile=20?= =?UTF-8?q?sharing=20to=20Settings=20=E2=86=92=20Feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Richie McIlroy --- .../(window-chrome)/settings/feedback.tsx | 208 +++++++++++++++++- 1 file changed, 206 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx b/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx index 7a8d83b6d7..f1f3dc94cc 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx @@ -3,13 +3,41 @@ import { action, useAction, useSubmission } from "@solidjs/router"; import { getVersion } from "@tauri-apps/api/app"; import { type OsType, type as ostype } from "@tauri-apps/plugin-os"; import * as shell from "@tauri-apps/plugin-shell"; -import { createResource, createSignal, For, Show } from "solid-js"; +import { createResource, createSignal, For, onCleanup, Show } from "solid-js"; import toast from "solid-toast"; -import { commands, type SystemDiagnostics } from "~/utils/tauri"; +import { + commands, + events, + type SessionProfileProgress, + type SessionProfileRecording, + type SessionProfileStatus, + type SessionProfileUploadResult, + type SystemDiagnostics, +} from "~/utils/tauri"; import { apiClient, protectedHeaders } from "~/utils/web-api"; import { Section, SettingsPageContent } from "./Setting"; +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const exponent = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + units.length - 1, + ); + const value = bytes / 1024 ** exponent; + return `${value.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`; +} + +function formatRecordingDate(modifiedAt: number | null): string | null { + if (modifiedAt == null || !Number.isFinite(modifiedAt)) return null; + try { + return new Date(modifiedAt).toLocaleString(); + } catch { + return null; + } +} + const getFeedbackOs = (): Extract => { const os = ostype(); if (os === "macos" || os === "windows" || os === "linux") return os; @@ -35,11 +63,38 @@ async function fetchDiagnostics(): Promise { } } +async function fetchSessionProfileStatus(): Promise { + try { + return await commands.getSessionProfileStatus(); + } catch (e) { + console.error("Failed to fetch session profile status:", e); + return null; + } +} + export default function FeedbackTab() { const [feedback, setFeedback] = createSignal(""); const [uploadingLogs, setUploadingLogs] = createSignal(false); const [diagnostics] = createResource(fetchDiagnostics); + const [profileStatus, { refetch: refetchProfileStatus }] = createResource( + fetchSessionProfileStatus, + ); + const [profileNote, setProfileNote] = createSignal(""); + const [sendingProfile, setSendingProfile] = createSignal(false); + const [profileProgress, setProfileProgress] = + createSignal(null); + const [profileResult, setProfileResult] = + createSignal(null); + + const availableRecordings = () => { + const status = profileStatus(); + if (!status) return [] as SessionProfileRecording[]; + return [status.studio, status.instant].filter( + (recording): recording is SessionProfileRecording => recording != null, + ); + }; + const submission = useSubmission(sendFeedbackAction); const sendFeedback = useAction(sendFeedbackAction); @@ -56,6 +111,37 @@ export default function FeedbackTab() { } }; + const handleSendProfile = async () => { + setSendingProfile(true); + setProfileResult(null); + setProfileProgress(null); + + let unlisten: (() => void) | undefined; + try { + unlisten = await events.sessionProfileProgress.listen((event) => + setProfileProgress(event.payload), + ); + const note = profileNote().trim(); + const result = await commands.uploadSessionProfile(note || null); + setProfileResult(result); + setProfileNote(""); + toast.success("Session profile sent. Thank you!"); + } catch (error) { + console.error("Failed to send session profile:", error); + toast.error( + typeof error === "string" + ? error + : "Failed to send session profile. Please try again.", + ); + } finally { + unlisten?.(); + setProfileProgress(null); + setSendingProfile(false); + } + }; + + onCleanup(() => setProfileProgress(null)); + return (
@@ -105,6 +191,124 @@ export default function FeedbackTab() { +
+ + Looking for recent recordings... +

+ } + > + 0} + fallback={ +
+ No Studio or Instant recordings found yet. Record something + first, then come back here to share a session profile with the + team. +
+ } + > +
+
+

+ Recordings to include +

+
+ + {(recording) => ( +
+ + {recording.mode === "studio" ? "Studio" : "Instant"} + +
+ + {recording.prettyName} + + + {formatBytes(recording.sizeBytes)} + + {(date) => <> Ā· {date()}} + + +
+
+ )} +
+
+
+ +