diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml index 2872fa99..513746a6 100644 --- a/.github/workflows/debug.yml +++ b/.github/workflows/debug.yml @@ -53,7 +53,7 @@ jobs: - name: Create .env file in root run: | echo "appVersion=debug" >> .env - echo "CAP_DESKTOP_SENTRY_URL=https://efd3156d9c0a8a49bee3ee675bec80d8@o4506859771527168.ingest.us.sentry.io/4506859844403200" >> .env + echo "CAP_DESKTOP_SENTRY_URL=https://6a3b6a09e6ae976c2ad6fff710e88748@o4506859771527168.ingest.us.sentry.io/4508330917101568" >> .env echo "NEXT_PUBLIC_URL=${{ secrets.NEXT_PUBLIC_URL }}" >> .env echo 'NEXTAUTH_URL=${NEXT_PUBLIC_URL}' >> .env echo 'VITE_SERVER_URL=${NEXT_PUBLIC_URL}' >> .env diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 74160d06..5d70f3b2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -50,7 +50,7 @@ jobs: echo "appVersion=${{ steps.get_version.outputs.appVersion }}" >> .env echo "NEXT_PUBLIC_ENVIRONMENT=production" >> .env echo "NEXT_PUBLIC_LOCAL_MODE=false" >> .env - echo "CAP_DESKTOP_SENTRY_URL=https://efd3156d9c0a8a49bee3ee675bec80d8@o4506859771527168.ingest.us.sentry.io/4506859844403200" >> .env + echo "CAP_DESKTOP_SENTRY_URL=https://6a3b6a09e6ae976c2ad6fff710e88748@o4506859771527168.ingest.us.sentry.io/4508330917101568" >> .env - name: Copy .env to apps/desktop run: cp .env apps/desktop/.env diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cbc5895f..70896056 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -128,7 +128,7 @@ jobs: - name: Create .env file in root run: | echo "appVersion=${{ needs.draft.outputs.version }}" >> .env - echo "CAP_DESKTOP_SENTRY_URL=https://efd3156d9c0a8a49bee3ee675bec80d8@o4506859771527168.ingest.us.sentry.io/4506859844403200" >> .env + echo "CAP_DESKTOP_SENTRY_URL=https://6a3b6a09e6ae976c2ad6fff710e88748@o4506859771527168.ingest.us.sentry.io/4508330917101568" >> .env echo "NEXT_PUBLIC_URL=${{ secrets.NEXT_PUBLIC_URL }}" >> .env echo 'NEXTAUTH_URL=${NEXT_PUBLIC_URL}' >> .env echo 'VITE_SERVER_URL=${NEXT_PUBLIC_URL}' >> .env diff --git a/Cargo.lock b/Cargo.lock index f1a9c763..48f634df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,6 +138,9 @@ name = "anyhow" version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +dependencies = [ + "backtrace", +] [[package]] name = "arbitrary" @@ -752,6 +755,7 @@ dependencies = [ "cap-rendering", "cpal 0.15.3 (git+https://github.com/RustAudio/cpal?rev=f43d36e55494993bbbde3299af0c53e5cdf4d4cf)", "ffmpeg-next", + "sentry", "serde", "specta", "tokio", @@ -1651,6 +1655,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + [[package]] name = "deranged" version = "0.3.11" @@ -1734,6 +1748,7 @@ dependencies = [ "reqwest", "rodio", "scap", + "sentry", "serde", "serde_json", "specta", @@ -2155,6 +2170,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "findshlibs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" +dependencies = [ + "cc", + "lazy_static", + "libc", + "winapi", +] + [[package]] name = "flate2" version = "1.0.32" @@ -2901,6 +2928,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hostname" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "windows 0.52.0", +] + [[package]] name = "hound" version = "3.5.1" @@ -5382,6 +5420,7 @@ dependencies = [ "cookie", "cookie_store", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -5739,6 +5778,126 @@ dependencies = [ "serde", ] +[[package]] +name = "sentry" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5484316556650182f03b43d4c746ce0e3e48074a21e2f51244b648b6542e1066" +dependencies = [ + "httpdate", + "native-tls", + "reqwest", + "sentry-anyhow", + "sentry-backtrace", + "sentry-contexts", + "sentry-core", + "sentry-debug-images", + "sentry-panic", + "sentry-tracing", + "tokio", + "ureq", +] + +[[package]] +name = "sentry-anyhow" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d672bfd1ed4e90978435f3c0704edb71a7a9d86403657839d518cd6aa278aff5" +dependencies = [ + "anyhow", + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-backtrace" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40aa225bb41e2ec9d7c90886834367f560efc1af028f1c5478a6cce6a59c463a" +dependencies = [ + "backtrace", + "once_cell", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8dd746da3d16cb8c39751619cefd4fcdbd6df9610f3310fd646b55f6e39910" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161283cfe8e99c8f6f236a402b9ccf726b201f365988b5bb637ebca0abbd4a30" +dependencies = [ + "once_cell", + "rand 0.8.5", + "sentry-types", + "serde", + "serde_json", +] + +[[package]] +name = "sentry-debug-images" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc6b25e945fcaa5e97c43faee0267eebda9f18d4b09a251775d8fef1086238a" +dependencies = [ + "findshlibs", + "once_cell", + "sentry-core", +] + +[[package]] +name = "sentry-panic" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74f229c7186dd971a9491ffcbe7883544aa064d1589bd30b83fb856cd22d63" +dependencies = [ + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-tracing" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3c5faf2103cd01eeda779ea439b68c4ee15adcdb16600836e97feafab362ec" +dependencies = [ + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-types" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d68cdf6bc41b8ff3ae2a9c4671e97426dcdd154cc1d4b6b72813f285d6b163f" +dependencies = [ + "debugid", + "hex", + "rand 0.8.5", + "serde", + "serde_json", + "thiserror", + "time", + "url", + "uuid", +] + [[package]] name = "serde" version = "1.0.214" @@ -7236,6 +7395,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "tracing-core", ] [[package]] @@ -7306,6 +7475,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -7401,6 +7579,19 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74fc6b57825be3373f7054754755f03ac3a8f5d70015ccad699ba2029956f4a" +dependencies = [ + "base64 0.22.1", + "log", + "native-tls", + "once_cell", + "url", +] + [[package]] name = "url" version = "2.5.2" @@ -7485,6 +7676,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index 0512956d..51221b36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ nokhwa-bindings-macos = { git = "https://github.com/CapSoftware/nokhwa", rev = " wgpu = "22.1.0" flume = "0.11.0" thiserror = "1.0" +sentry = { version = "0.34.0", features = ["anyhow"] } windows = "0.58.0" windows-sys = "0.59.0" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index ec2277a3..13e4f369 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -65,6 +65,7 @@ global-hotkey = "0.6.3" rand = "0.8.5" cpal.workspace = true keyed_priority_queue = "0.4.2" +sentry.workspace = true cap-utils = { path = "../../../crates/utils" } cap-project = { path = "../../../crates/project" } diff --git a/apps/desktop/src-tauri/src/auth.rs b/apps/desktop/src-tauri/src/auth.rs index af03c4f1..c2cb90bc 100644 --- a/apps/desktop/src-tauri/src/auth.rs +++ b/apps/desktop/src-tauri/src/auth.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; @@ -11,6 +13,7 @@ use crate::web_api; #[derive(Serialize, Deserialize, Type, Debug)] pub struct AuthStore { pub token: String, + pub user_id: String, pub expires: i32, pub plan: Option, } @@ -22,6 +25,18 @@ pub struct Plan { } impl AuthStore { + pub fn load(app: &AppHandle) -> Result, String> { + let Some(store) = app + .store("store") + .map(|s| s.get("auth")) + .map_err(|e| e.to_string())? + else { + return Ok(None); + }; + + serde_json::from_value(store).map_err(|e| e.to_string()) + } + pub fn get(app: &AppHandle) -> Result, String> { let Some(Some(store)) = app.get_store("store").map(|s| s.get("auth")) else { return Ok(None); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 13d252c9..70446b91 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -21,7 +21,7 @@ use auth::{AuthStore, AuthenticationInvalid}; use cap_editor::{EditorInstance, FRAMES_WS_PATH}; use cap_editor::{EditorState, ProjectRecordings}; use cap_media::feeds::{AudioInputFeed, AudioInputSamplesSender}; -use cap_media::sources::CaptureScreen; +use cap_media::sources::{CaptureScreen, CaptureWindow}; use cap_media::{ feeds::{CameraFeed, CameraFrameSender}, sources::ScreenCaptureTarget, @@ -130,6 +130,29 @@ impl App { } async fn set_start_recording_options(&mut self, new_options: RecordingOptions) { + let options = new_options.clone(); + sentry::configure_scope(move |scope| { + scope.set_tag("cmd", "set_start_recording_options"); + let mut ctx = std::collections::BTreeMap::new(); + ctx.insert( + "capture_target".into(), + match options.capture_target { + ScreenCaptureTarget::Screen(screen) => screen.name, + ScreenCaptureTarget::Window(window) => window.owner_name, + } + .into(), + ); + ctx.insert( + "camera".into(), + options.camera_label.unwrap_or("None".into()).into(), + ); + ctx.insert( + "microphone".into(), + options.audio_input_name.unwrap_or("None".into()).into(), + ); + scope.set_context("recording_options", sentry::protocol::Context::Other(ctx)); + }); + match CapWindowId::Camera.get(&self.handle) { Some(window) if new_options.camera_label().is_none() => { println!("closing camera window"); @@ -488,35 +511,92 @@ async fn get_rendered_video( project: ProjectConfiguration, ) -> Result { let editor_instance = upsert_editor_instance(&app, video_id.clone()).await; - get_rendered_video_impl(editor_instance, project, false).await + let output_path = editor_instance + .project_path + .join("output") + .join("result.mp4"); + + // If the file doesn't exist, return an error to trigger the progress-enabled path + if !output_path.exists() { + return Err("Rendered video does not exist".to_string()); + } + + Ok(output_path) +} + +#[tauri::command] +#[specta::specta] +async fn get_rendered_video_with_progress( + app: AppHandle, + video_id: String, + project: ProjectConfiguration, + progress_channel: tauri::ipc::Channel, +) -> Result { + get_rendered_video_impl(app, video_id, project, Some(progress_channel)).await } async fn get_rendered_video_impl( - editor_instance: Arc, + app: AppHandle, + video_id: String, project: ProjectConfiguration, - force_render: bool, + progress_channel: Option>, ) -> Result { + let editor_instance = upsert_editor_instance(&app, video_id.clone()).await; let output_path = editor_instance .project_path .join("output") .join("result.mp4"); - if !force_render && output_path.exists() { + // If the file exists, return it immediately + if output_path.exists() { return Ok(output_path); } - cap_export::export_video_to_file( - project, - output_path.clone(), - |_| {}, - &editor_instance.project_path, - editor_instance.audio.clone(), - editor_instance.meta(), - editor_instance.render_constants.clone(), - editor_instance.cursor.clone(), - ) - .await - .map_err(|e| e.to_string())?; + // If we need to render and have a progress channel, use it + if let Some(progress) = progress_channel { + let (duration, _size) = + get_video_metadata(app.clone(), video_id.clone(), Some(VideoType::Screen)) + .await + .unwrap(); + + // 30 FPS (calculated for output video) + let total_frames = (duration * 30.0).round() as u32; + + progress + .send(RenderProgress::EstimatedTotalFrames { total_frames }) + .ok(); + + cap_export::export_video_to_file( + project, + output_path.clone(), + move |current_frame| { + progress + .send(RenderProgress::FrameRendered { current_frame }) + .ok(); + }, + &editor_instance.project_path, + editor_instance.audio.clone(), + editor_instance.meta(), + editor_instance.render_constants.clone(), + editor_instance.cursor.clone(), + ) + .await + .map_err(|e| e.to_string())?; + } else { + // Render without progress updates + cap_export::export_video_to_file( + project, + output_path.clone(), + |_| {}, + &editor_instance.project_path, + editor_instance.audio.clone(), + editor_instance.meta(), + editor_instance.render_constants.clone(), + editor_instance.cursor.clone(), + ) + .await + .map_err(|e| e.to_string())?; + } Ok(output_path) } @@ -564,6 +644,10 @@ async fn copy_file_to_path(app: AppHandle, src: String, dst: String) -> Result<( #[tauri::command] #[specta::specta] async fn copy_screenshot_to_clipboard(app: AppHandle, path: PathBuf) -> Result<(), String> { + sentry::configure_scope(|scope| { + scope.set_tag("cmd", "copy_screenshot_to_clipboard"); + }); + println!("Copying screenshot to clipboard: {:?}", path); let image_data = match tokio::fs::read(&path).await { @@ -620,6 +704,7 @@ async fn copy_screenshot_to_clipboard(app: AppHandle, path: PathBuf) -> Result<( ); } + // TODO(Ilya) (Windows) Add support #[cfg(not(target_os = "macos"))] { notifications::send_notification( @@ -1032,6 +1117,10 @@ async fn render_to_file( project: ProjectConfiguration, progress_channel: tauri::ipc::Channel, ) -> Result { + sentry::configure_scope(|scope| { + scope.set_tag("cmd", "render_to_file"); + }); + let (duration, _size) = get_video_metadata(app.clone(), video_id.clone(), Some(VideoType::Screen)) .await @@ -1067,7 +1156,10 @@ async fn render_to_file( editor_instance.cursor.clone(), ) .await - .map_err(|e| e.to_string())?; + .map_err(|e| { + sentry::capture_message(&e.to_string(), sentry::Level::Error); + e.to_string() + })?; ShowCapturesPanel.emit(&app).ok(); @@ -1189,7 +1281,7 @@ async fn upload_rendered_video( .emit(&app) .ok(); - let output_path = match get_rendered_video_impl(editor_instance.clone(), project, false).await { + let output_path = match get_rendered_video(app.clone(), video_id.clone(), project).await { Ok(path) => { // Emit rendering complete UploadProgress { @@ -1806,6 +1898,8 @@ pub async fn run() { get_current_recording, render_to_file, get_rendered_video, + get_rendered_video_with_progress, + render_video_with_progress, copy_file_to_path, copy_video_to_clipboard, copy_screenshot_to_clipboard, @@ -1886,7 +1980,10 @@ pub async fn run() { tauri::async_runtime::set(tokio::runtime::Handle::current()); #[allow(unused_mut)] - let mut builder = tauri::Builder::default(); + let mut builder = + tauri::Builder::default().plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + let _ = ShowCapWindow::Main.show(app); + })); #[cfg(target_os = "macos")] { @@ -1894,9 +1991,6 @@ pub async fn run() { } builder - .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { - let _ = ShowCapWindow::Main.show(app); - })) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_store::Builder::new().build()) @@ -1915,6 +2009,15 @@ pub async fn run() { let app_handle = app.handle().clone(); + if let Ok(Some(auth)) = AuthStore::load(&app_handle) { + sentry::configure_scope(|scope| { + scope.set_user(Some(sentry::User { + id: Some(auth.user_id), + ..Default::default() + })); + }); + } + // Add this line to check notification permissions on startup let notification_handle = app_handle.clone(); tauri::async_runtime::spawn(async move { @@ -2076,8 +2179,8 @@ pub async fn run() { tokio::task::yield_now().await; }); } - CapWindowId::Settings { .. } => { - // Don't quit the app when settings window is closed + CapWindowId::Settings | CapWindowId::Upgrade => { + // Don't quit the app when settings or upgrade window is closed return; } _ => {} @@ -2286,7 +2389,6 @@ async fn reupload_rendered_video( return Err("No sharing metadata found".to_string()); }; - // Emit initial rendering progress UploadProgress { stage: "rendering".to_string(), progress: 0.0, @@ -2295,10 +2397,8 @@ async fn reupload_rendered_video( .emit(&app) .ok(); - // Pass true to force_render to ensure we create a new video - let output_path = match get_rendered_video_impl(editor_instance.clone(), project, true).await { + let output_path = match get_rendered_video(app.clone(), video_id.clone(), project).await { Ok(path) => { - // Emit rendering complete UploadProgress { stage: "rendering".to_string(), progress: 1.0, @@ -2359,3 +2459,49 @@ async fn reupload_rendered_video( fn global_message_dialog(app: AppHandle, message: String) { app.dialog().message(message).show(|_| {}); } + +#[tauri::command] +#[specta::specta] +async fn render_video_with_progress( + app: AppHandle, + video_id: String, + project: ProjectConfiguration, + progress_channel: tauri::ipc::Channel, +) -> Result { + let editor_instance = upsert_editor_instance(&app, video_id.clone()).await; + let output_path = editor_instance + .project_path + .join("output") + .join("result.mp4"); + + let (duration, _size) = + get_video_metadata(app.clone(), video_id.clone(), Some(VideoType::Screen)) + .await + .unwrap(); + + // 30 FPS (calculated for output video) + let total_frames = (duration * 30.0).round() as u32; + + progress_channel + .send(RenderProgress::EstimatedTotalFrames { total_frames }) + .ok(); + + cap_export::export_video_to_file( + project, + output_path.clone(), + move |current_frame| { + progress_channel + .send(RenderProgress::FrameRendered { current_frame }) + .ok(); + }, + &editor_instance.project_path, + editor_instance.audio.clone(), + editor_instance.meta(), + editor_instance.render_constants.clone(), + editor_instance.cursor.clone(), + ) + .await + .map_err(|e| e.to_string())?; + + Ok(output_path) +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 6d742f68..d307108b 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,7 +1,46 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -#[tokio::main] -async fn main() { - desktop_solid_lib::run().await +use std::sync::Arc; + +fn main() { + // We have to hold onto the ClientInitGuard until the very end + let _guard = sentry::init(( + dotenvy_macro::dotenv!("CAP_DESKTOP_SENTRY_URL"), + sentry::ClientOptions { + release: sentry::release_name!(), + debug: cfg!(debug_assertions), + before_send: Some(Arc::new(|event| { + #[cfg(debug_assertions)] + { + let msg = event.message.clone().unwrap_or("No message".into()); + println!("Sentry captured {}: {}", &event.level, &msg); + println!("-- user: {:?}", &event.user); + println!("-- event tags: {:?}", &event.tags); + println!("-- event contexts: {:?}", &event.contexts); + None + } + + #[cfg(not(debug_assertions))] + { + Some(event) + } + })), + ..Default::default() + }, + )); + + #[cfg(debug_assertions)] + sentry::configure_scope(|scope| { + scope.set_user(Some(sentry::User { + username: Some("_DEV_".into()), + ..Default::default() + })); + }); + + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("Failed to build multi threaded tokio runtime") + .block_on(desktop_solid_lib::run()); } diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index c44a44cf..004ff2d2 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -38,6 +38,10 @@ pub fn list_cameras() -> Vec { #[tauri::command] #[specta::specta] pub async fn start_recording(app: AppHandle, state: MutableState<'_, App>) -> Result<(), String> { + sentry::configure_scope(|scope| { + scope.set_tag("cmd", "start_recording"); + }); + let mut state = state.write().await; let id = uuid::Uuid::new_v4().to_string(); @@ -56,6 +60,10 @@ pub async fn start_recording(app: AppHandle, state: MutableState<'_, App>) -> Re .unwrap_or(false); if auto_create_shareable_link { + sentry::configure_scope(|scope| { + scope.set_tag("task", "auto_create_shareable_link"); + }); + if let Ok(Some(auth)) = AuthStore::get(&app) { if auth.is_upgraded() { // Pre-create the video and get the shareable link @@ -131,6 +139,10 @@ pub async fn resume_recording(state: MutableState<'_, App>) -> Result<(), String #[tauri::command] #[specta::specta] pub async fn stop_recording(app: AppHandle, state: MutableState<'_, App>) -> Result<(), String> { + sentry::configure_scope(|scope| { + scope.set_tag("cmd", "stop_recording"); + }); + let mut state = state.write().await; let Some(current_recording) = state.clear_current_recording() else { return Err("Recording not in progress".to_string())?; diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index d62ca949..37c963c2 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -181,8 +181,11 @@ impl ShowCapWindow { Self::Upgrade => { let mut window_builder = self .window_builder(app, "/upgrade") - .inner_size(800.0, 850.0) + .inner_size(800.0, 800.0) .resizable(false) + .focused(true) + .visible(true) + .always_on_top(true) .maximized(false) .transparent(true); @@ -263,6 +266,7 @@ impl ShowCapWindow { .fullscreen(false) .shadow(true) .always_on_top(true) + .transparent(true) .visible_on_all_workspaces(true) .content_protected(true) .inner_size(width, height) diff --git a/apps/desktop/src/routes/(window-chrome)/signin.tsx b/apps/desktop/src/routes/(window-chrome)/signin.tsx index 5f447f45..c73eaaf6 100644 --- a/apps/desktop/src/routes/(window-chrome)/signin.tsx +++ b/apps/desktop/src/routes/(window-chrome)/signin.tsx @@ -46,20 +46,22 @@ const signInAction = action(async () => { stopListening(); const token = url.searchParams.get("token"); + const user_id = url.searchParams.get("user_id"); const expires = Number(url.searchParams.get("expires")); - if (!token || !expires) { + if (!token || !expires || !user_id) { throw new Error("Invalid token or expires"); } await authStore.set({ token, + user_id, expires, plan: { upgraded: false, last_checked: 0 }, }); getCurrentWindow() .setFocus() - .catch(() => {}); + .catch(() => { }); return redirect("/"); } catch (error) { diff --git a/apps/desktop/src/routes/(window-chrome)/upgrade.tsx b/apps/desktop/src/routes/(window-chrome)/upgrade.tsx index c4e4cfbc..e49585a5 100644 --- a/apps/desktop/src/routes/(window-chrome)/upgrade.tsx +++ b/apps/desktop/src/routes/(window-chrome)/upgrade.tsx @@ -9,7 +9,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; export default function Page() { const proFeatures = [ "Unlimited cloud storage & Shareable links", - "Connect custom S3 storage bucket (soon!)", + "Connect custom S3 storage bucket", "Advanced teams features", "Unlimited views", "Password protected videos", @@ -105,20 +105,23 @@ export default function Page() { {!upgradeComplete() && ( <>
-

+

Upgrade to Cap Pro

Cap is currently in public beta, and we're offering special early - adopter pricing to our first users. This pricing will be locked in - for the lifetime of your subscription. + adopter pricing to our first users.{" "} + + This pricing will be locked in for the lifetime of your + subscription. +

-
+
-

+

Cap Pro — Early Adopter Pricing

diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index 5a3cc2ff..d1a2b5f4 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -209,7 +209,7 @@ function ExportButton() { renderProgress: 0, totalFrames: 0, message: "Preparing to render...", - mediaPath: p, + mediaPath: path, stage: "rendering", }); diff --git a/apps/desktop/src/routes/in-progress-recording.tsx b/apps/desktop/src/routes/in-progress-recording.tsx index 751d9d56..727fd2e9 100644 --- a/apps/desktop/src/routes/in-progress-recording.tsx +++ b/apps/desktop/src/routes/in-progress-recording.tsx @@ -53,7 +53,7 @@ export default function () { return (

diff --git a/apps/desktop/src/routes/prev-recordings.tsx b/apps/desktop/src/routes/prev-recordings.tsx index 6345d250..fa3575d8 100644 --- a/apps/desktop/src/routes/prev-recordings.tsx +++ b/apps/desktop/src/routes/prev-recordings.tsx @@ -178,65 +178,102 @@ export default function () { progress: 0, renderProgress: 0, totalFrames: 0, - message: "Preparing to render...", + message: "Preparing...", mediaPath: media.path, stage: "rendering", }); try { if (isRecording) { - console.log("Setting up render progress channel"); - const progress = new Channel(); - progress.onmessage = (p) => { - console.log("Progress channel message:", p); - if ( - p.type === "FrameRendered" && - progressState.type === "copying" - ) { - console.log( - "Frame rendered in copy:", - p.current_frame, - "Current state:", - progressState - ); - setProgressState({ - ...progressState, - renderProgress: p.current_frame, - }); - } - if ( - p.type === "EstimatedTotalFrames" && - progressState.type === "copying" - ) { - console.log( - "Got total frames in copy:", - p.total_frames - ); - setProgressState({ - ...progressState, - totalFrames: p.total_frames, - }); - } - }; - - console.log("Starting render to file"); - const outputPath = await commands.renderToFile( - mediaId, - presets.getDefaultConfig() ?? DEFAULT_PROJECT_CONFIG, - progress - ); - console.log("Render to file completed"); - - await commands.copyVideoToClipboard(outputPath); + let outputPath: string; + + try { + // First try to get existing rendered video + outputPath = await commands.getRenderedVideo( + mediaId, + presets.getDefaultConfig() ?? DEFAULT_PROJECT_CONFIG + ); + console.log("Using existing rendered video"); + + // Show quick progress animation for existing video + setProgressState({ + type: "copying", + progress: 0, + renderProgress: 100, + totalFrames: 100, + message: "Copying to clipboard...", + mediaPath: media.path, + stage: "rendering", + }); + + await commands.copyVideoToClipboard(outputPath); + } catch (error) { + console.log( + "Need to render video with progress:", + error + ); + const progress = new Channel(); + progress.onmessage = (p) => { + console.log("Progress channel message:", p); + if ( + p.type === "FrameRendered" && + progressState.type === "copying" + ) { + console.log( + "Frame rendered:", + p.current_frame, + "Total frames:", + progressState.totalFrames + ); + setProgressState({ + ...progressState, + message: "Rendering video...", + renderProgress: p.current_frame, + }); + } + if ( + p.type === "EstimatedTotalFrames" && + progressState.type === "copying" + ) { + console.log("Got total frames:", p.total_frames); + setProgressState({ + ...progressState, + totalFrames: p.total_frames, + }); + } + }; + + outputPath = await commands.renderVideoWithProgress( + mediaId, + presets.getDefaultConfig() ?? + DEFAULT_PROJECT_CONFIG, + progress + ); + console.log("Video rendered, copying to clipboard"); + await commands.copyVideoToClipboard(outputPath); + } } else { + // For screenshots, show quick progress animation + setProgressState({ + type: "copying", + progress: 50, + renderProgress: 100, + totalFrames: 100, + message: "Copying image to clipboard...", + mediaPath: media.path, + stage: "rendering", + }); await commands.copyScreenshotToClipboard(media.path); } setProgressState({ type: "copying", progress: 100, + renderProgress: 100, + totalFrames: 100, message: "Copied successfully!", mediaPath: media.path, + stage: "rendering", }); setTimeout(() => { @@ -257,7 +294,9 @@ export default function () { progress: 0, renderProgress: 0, totalFrames: 0, - message: "Preparing to render...", + message: isRecording + ? "Choose where to save video..." + : "Choose where to save image...", mediaPath: media.path, stage: "rendering", }); @@ -291,45 +330,103 @@ export default function () { } if (isRecording) { - const progress = new Channel(); - progress.onmessage = (p) => { - if ( - p.type === "FrameRendered" && - progressState.type === "saving" - ) { - setProgressState({ - ...progressState, - renderProgress: p.current_frame, - }); - } - if ( - p.type === "EstimatedTotalFrames" && - progressState.type === "saving" - ) { - console.log("Total frames: ", p.total_frames); - setProgressState({ - ...progressState, - totalFrames: p.total_frames, - }); - } - }; - - const output_path = await commands.renderToFile( - mediaId, - presets.getDefaultConfig() ?? DEFAULT_PROJECT_CONFIG, - progress - ); - - await commands.copyFileToPath(output_path, savePath); + let outputPath: string; + + try { + // First try to get existing rendered video + outputPath = await commands.getRenderedVideo( + mediaId, + presets.getDefaultConfig() ?? DEFAULT_PROJECT_CONFIG + ); + console.log("Using existing rendered video"); + + // Show quick progress animation for existing video + setProgressState({ + type: "saving", + progress: 0, + renderProgress: 100, + totalFrames: 100, + message: "Saving video...", + mediaPath: media.path, + stage: "rendering", + }); + } catch (error) { + // If it doesn't exist, render with progress + console.log("Need to render video:", error); + const progress = new Channel(); + progress.onmessage = (p) => { + console.log("Progress channel message:", p); + if ( + p.type === "FrameRendered" && + progressState.type === "saving" + ) { + console.log( + "Frame rendered:", + p.current_frame, + "Total frames:", + progressState.totalFrames + ); + setProgressState({ + ...progressState, + message: "Rendering video...", + renderProgress: p.current_frame, + }); + } + if ( + p.type === "EstimatedTotalFrames" && + progressState.type === "saving" + ) { + console.log("Got total frames:", p.total_frames); + setProgressState({ + ...progressState, + totalFrames: p.total_frames, + }); + } + }; + + outputPath = await commands.renderVideoWithProgress( + mediaId, + presets.getDefaultConfig() ?? + DEFAULT_PROJECT_CONFIG, + progress + ); + } + + // Show copying progress + setProgressState({ + type: "saving", + progress: 50, + renderProgress: 100, + totalFrames: 100, + message: "Copying file...", + mediaPath: media.path, + stage: "rendering", + }); + + await commands.copyFileToPath(outputPath, savePath); } else { + // For screenshots, show quick progress animation + setProgressState({ + type: "saving", + progress: 50, + renderProgress: 0, + totalFrames: 0, + message: "Saving image...", + mediaPath: media.path, + stage: "rendering", + }); + await commands.copyFileToPath(media.path, savePath); } setProgressState({ type: "saving", progress: 100, + renderProgress: 100, + totalFrames: 100, message: "Saved successfully!", mediaPath: media.path, + stage: "rendering", }); setTimeout(() => { @@ -360,6 +457,12 @@ export default function () { return; } + const isUpgraded = await commands.checkUpgradedAndUpdate(); + if (!isUpgraded) { + await commands.openUpgradeWindow(); + return; + } + setProgressState({ type: "uploading", renderProgress: 0, @@ -372,47 +475,9 @@ export default function () { try { let res: UploadResult; if (isRecording) { - const progress = new Channel(); - progress.onmessage = (p) => { - console.log("Upload render progress:", p); - if ( - p.type === "FrameRendered" && - progressState.type === "uploading" - ) { - console.log( - "Frame rendered in upload:", - p.current_frame, - "Current state:", - progressState - ); - setProgressState({ - ...progressState, - renderProgress: Math.round( - (p.current_frame / - (progressState.totalFrames || 1)) * - 100 - ), - }); - } - if ( - p.type === "EstimatedTotalFrames" && - progressState.type === "uploading" - ) { - console.log( - "Got total frames in upload:", - p.total_frames - ); - setProgressState({ - ...progressState, - totalFrames: p.total_frames, - }); - } - }; - - await commands.renderToFile( + const outputPath = await commands.getRenderedVideo( mediaId, - presets.getDefaultConfig() ?? DEFAULT_PROJECT_CONFIG, - progress + presets.getDefaultConfig() ?? DEFAULT_PROJECT_CONFIG ); res = await commands.uploadRenderedVideo( @@ -430,7 +495,9 @@ export default function () { case "PlanCheckFailed": throw new Error("Plan check failed"); case "UpgradeRequired": - throw new Error("Upgrade required"); + setProgressState({ type: "idle" }); + setShowUpgradeTooltip(true); + return; default: break; } @@ -531,9 +598,11 @@ export default function () { > {(state) => (

- {state().stage === "rendering" - ? "Rendering video" - : "Copying to clipboard"} + {isRecording + ? state().stage === "rendering" + ? "Rendering video" + : "Copying to clipboard" + : "Copying image to clipboard"}

)} @@ -545,9 +614,11 @@ export default function () { > {(state) => (

- {state().stage === "rendering" - ? "Rendering video" - : "Saving file"} + {isRecording + ? state().stage === "rendering" + ? "Rendering video" + : "Saving video" + : "Saving image"}

)} @@ -731,17 +802,7 @@ export default function () { } tooltipPlacement="left" onClick={() => { - uploadMedia.mutate(undefined, { - onError: (error) => { - if (error.message === "Upgrade required") { - setShowUpgradeTooltip(true); - setTimeout( - () => setShowUpgradeTooltip(false), - 10000 - ); - } - }, - }); + uploadMedia.mutate(); }} disabled={ copyMedia.isPending || diff --git a/apps/desktop/src/store.ts b/apps/desktop/src/store.ts index 02b673f7..5a6a8b94 100644 --- a/apps/desktop/src/store.ts +++ b/apps/desktop/src/store.ts @@ -1,10 +1,10 @@ import { Store } from "@tauri-apps/plugin-store"; -import type { - AuthStore, - ProjectConfiguration, - HotkeysStore, - GeneralSettingsStore, +import { + type AuthStore, + type ProjectConfiguration, + type HotkeysStore, + type GeneralSettingsStore, } from "~/utils/tauri"; let _store: Promise | undefined; diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 6d529034..1de0c5df 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -62,6 +62,12 @@ async renderToFile(videoId: string, project: ProjectConfiguration, progressChann async getRenderedVideo(videoId: string, project: ProjectConfiguration) : Promise { return await TAURI_INVOKE("get_rendered_video", { videoId, project }); }, +async getRenderedVideoWithProgress(videoId: string, project: ProjectConfiguration, progressChannel: TAURI_CHANNEL) : Promise { + return await TAURI_INVOKE("get_rendered_video_with_progress", { videoId, project, progressChannel }); +}, +async renderVideoWithProgress(videoId: string, project: ProjectConfiguration, progressChannel: TAURI_CHANNEL) : Promise { + return await TAURI_INVOKE("render_video_with_progress", { videoId, project, progressChannel }); +}, async copyFileToPath(src: string, dst: string) : Promise { return await TAURI_INVOKE("copy_file_to_path", { src, dst }); }, @@ -225,7 +231,7 @@ export type Audio = { duration: number; sample_rate: number; channels: number } export type AudioConfiguration = { mute: boolean; improve: boolean } export type AudioInputLevelChange = number export type AudioMeta = { path: string } -export type AuthStore = { token: string; expires: number; plan: Plan | null } +export type AuthStore = { token: string; user_id: string; expires: number; plan: Plan | null } export type AuthenticationInvalid = null export type BackgroundConfiguration = { source: BackgroundSource; blur: number; padding: number; rounding: number; inset: number; crop: Crop | null } export type BackgroundSource = { type: "wallpaper"; id: number } | { type: "image"; path: string | null } | { type: "color"; value: [number, number, number] } | { type: "gradient"; from: [number, number, number]; to: [number, number, number]; angle?: number } diff --git a/apps/web/app/api/desktop/session/request/route.ts b/apps/web/app/api/desktop/session/request/route.ts index 661a902c..b1a51fa7 100644 --- a/apps/web/app/api/desktop/session/request/route.ts +++ b/apps/web/app/api/desktop/session/request/route.ts @@ -2,6 +2,7 @@ import { type NextRequest } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@cap/database/auth/auth-options"; import { decode } from "next-auth/jwt"; +import { getCurrentUser } from "@cap/database/auth/session"; export async function GET(req: NextRequest) { const searchParams = req.nextUrl.searchParams; @@ -20,8 +21,9 @@ export async function GET(req: NextRequest) { const token = req.cookies.get("next-auth.session-token") ?? null; const tokenValue = token?.value ?? null; + const user = await getCurrentUser(); - if (!tokenValue) { + if (!tokenValue || !user) { return Response.redirect( `${process.env.NEXT_PUBLIC_URL}/login?next=${process.env.NEXT_PUBLIC_URL}/api/desktop/session/request?port=${port}` ); @@ -39,7 +41,7 @@ export async function GET(req: NextRequest) { } const returnUrl = new URL( - `http://127.0.0.1:${port}?token=${tokenValue}&expires=${decodedToken?.exp}` + `http://127.0.0.1:${port}?token=${tokenValue}&expires=${decodedToken?.exp}&user_id=${user.id}` ); return Response.redirect(returnUrl.href); diff --git a/apps/web/components/pages/PricingPage.tsx b/apps/web/components/pages/PricingPage.tsx index 9cc8e07c..ad0491a0 100644 --- a/apps/web/components/pages/PricingPage.tsx +++ b/apps/web/components/pages/PricingPage.tsx @@ -155,7 +155,7 @@ export const PricingPage = () => {
@@ -172,7 +172,7 @@ export const PricingPage = () => {