diff --git a/apps/desktop/src-tauri/src/api.rs b/apps/desktop/src-tauri/src/api.rs index 92a2ba2030..fdfab9c4c2 100644 --- a/apps/desktop/src-tauri/src/api.rs +++ b/apps/desktop/src-tauri/src/api.rs @@ -5,9 +5,12 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tauri::AppHandle; -use crate::web_api::ManagerExt; +use crate::web_api::{AuthedApiError, ManagerExt}; -pub async fn upload_multipart_initiate(app: &AppHandle, video_id: &str) -> Result { +pub async fn upload_multipart_initiate( + app: &AppHandle, + video_id: &str, +) -> Result { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct Response { @@ -32,14 +35,12 @@ pub async fn upload_multipart_initiate(app: &AppHandle, video_id: &str) -> Resul .text() .await .unwrap_or_else(|_| "".to_string()); - return Err(format!( - "api/upload_multipart_initiate/{status}: {error_body}" - )); + return Err(format!("api/upload_multipart_initiate/{status}: {error_body}").into()); } resp.json::() .await - .map_err(|err| format!("api/upload_multipart_initiate/response: {err}")) + .map_err(|err| format!("api/upload_multipart_initiate/response: {err}").into()) .map(|data| data.upload_id) } @@ -49,7 +50,7 @@ pub async fn upload_multipart_presign_part( upload_id: &str, part_number: u32, md5_sum: &str, -) -> Result { +) -> Result { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct Response { @@ -76,14 +77,12 @@ pub async fn upload_multipart_presign_part( .text() .await .unwrap_or_else(|_| "".to_string()); - return Err(format!( - "api/upload_multipart_presign_part/{status}: {error_body}" - )); + return Err(format!("api/upload_multipart_presign_part/{status}: {error_body}").into()); } resp.json::() .await - .map_err(|err| format!("api/upload_multipart_presign_part/response: {err}")) + .map_err(|err| format!("api/upload_multipart_presign_part/response: {err}").into()) .map(|data| data.presigned_url) } @@ -114,7 +113,7 @@ pub async fn upload_multipart_complete( upload_id: &str, parts: &[UploadedPart], meta: Option, -) -> Result, String> { +) -> Result, AuthedApiError> { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct MultipartCompleteRequest<'a> { @@ -150,14 +149,12 @@ pub async fn upload_multipart_complete( .text() .await .unwrap_or_else(|_| "".to_string()); - return Err(format!( - "api/upload_multipart_complete/{status}: {error_body}" - )); + return Err(format!("api/upload_multipart_complete/{status}: {error_body}").into()); } resp.json::() .await - .map_err(|err| format!("api/upload_multipart_complete/response: {err}")) + .map_err(|err| format!("api/upload_multipart_complete/response: {err}").into()) .map(|data| data.location) } @@ -179,7 +176,10 @@ pub struct PresignedS3PutRequest { pub meta: Option, } -pub async fn upload_signed(app: &AppHandle, body: PresignedS3PutRequest) -> Result { +pub async fn upload_signed( + app: &AppHandle, + body: PresignedS3PutRequest, +) -> Result { #[derive(Deserialize)] struct Data { url: String, @@ -204,12 +204,12 @@ pub async fn upload_signed(app: &AppHandle, body: PresignedS3PutRequest) -> Resu .text() .await .unwrap_or_else(|_| "".to_string()); - return Err(format!("api/upload_signed/{status}: {error_body}")); + return Err(format!("api/upload_signed/{status}: {error_body}").into()); } resp.json::() .await - .map_err(|err| format!("api/upload_signed/response: {err}")) + .map_err(|err| format!("api/upload_signed/response: {err}").into()) .map(|data| data.presigned_put_data.url) } @@ -218,7 +218,7 @@ pub async fn desktop_video_progress( video_id: &str, uploaded: u64, total: u64, -) -> Result<(), String> { +) -> Result<(), AuthedApiError> { let resp = app .authed_api_request("/api/desktop/video/progress", |client, url| { client.post(url).json(&json!({ @@ -237,7 +237,7 @@ pub async fn desktop_video_progress( .text() .await .unwrap_or_else(|_| "".to_string()); - return Err(format!("api/desktop_video_progress/{status}: {error_body}")); + return Err(format!("api/desktop_video_progress/{status}: {error_body}").into()); } Ok(()) diff --git a/apps/desktop/src-tauri/src/auth.rs b/apps/desktop/src-tauri/src/auth.rs index 97f999a4b2..698c41f10b 100644 --- a/apps/desktop/src-tauri/src/auth.rs +++ b/apps/desktop/src-tauri/src/auth.rs @@ -118,6 +118,3 @@ impl AuthStore { store.save().map_err(|e| e.to_string()) } } - -#[derive(specta::Type, serde::Serialize, tauri_specta::Event, Debug, Clone, serde::Deserialize)] -pub struct AuthenticationInvalid; diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index aaf6a62b80..7372da2b9b 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -138,7 +138,9 @@ impl DeepLinkAction { mode, }; - crate::recording::start_recording(app.clone(), state, inputs).await + crate::recording::start_recording(app.clone(), state, inputs) + .await + .map(|_| ()) } DeepLinkAction::StopRecording => { crate::recording::stop_recording(app.clone(), app.state()).await diff --git a/apps/desktop/src-tauri/src/hotkeys.rs b/apps/desktop/src-tauri/src/hotkeys.rs index 4ba18473cb..79a4a5eff3 100644 --- a/apps/desktop/src-tauri/src/hotkeys.rs +++ b/apps/desktop/src-tauri/src/hotkeys.rs @@ -146,9 +146,9 @@ async fn handle_hotkey(app: AppHandle, action: HotkeyAction) -> Result<(), Strin Ok(()) } HotkeyAction::StopRecording => recording::stop_recording(app.clone(), app.state()).await, - HotkeyAction::RestartRecording => { - recording::restart_recording(app.clone(), app.state()).await - } + HotkeyAction::RestartRecording => recording::restart_recording(app.clone(), app.state()) + .await + .map(|_| ()), HotkeyAction::OpenRecordingPicker => { let _ = RequestOpenRecordingPicker { target_mode: None }.emit(&app); Ok(()) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 58ce372540..77e6377fc8 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -28,7 +28,7 @@ mod web_api; mod windows; use audio::AppSounds; -use auth::{AuthStore, AuthenticationInvalid, Plan}; +use auth::{AuthStore, Plan}; use camera::CameraPreviewState; use cap_editor::{EditorInstance, EditorState}; use cap_project::{ @@ -87,6 +87,7 @@ use tokio::sync::Mutex; use tokio::sync::{RwLock, oneshot}; use tracing::{error, trace, warn}; use upload::{create_or_get_video, upload_image, upload_video}; +use web_api::AuthedApiError; use web_api::ManagerExt as WebManagerExt; use windows::{CapWindowId, EditorWindowIds, ShowCapWindow, set_window_transparent}; @@ -1084,7 +1085,7 @@ async fn upload_exported_video( channel.send(UploadProgress { progress: 0.0 }).ok(); - let s3_config = async { + let s3_config = match async { let video_id = match mode { UploadMode::Initial { pre_created_video } => { if let Some(pre_created) = pre_created_video { @@ -1094,7 +1095,7 @@ async fn upload_exported_video( } UploadMode::Reupload => { let Some(sharing) = meta.sharing.clone() else { - return Err("No sharing metadata found".to_string()); + return Err("No sharing metadata found".into()); }; Some(sharing.id) @@ -1110,7 +1111,13 @@ async fn upload_exported_video( ) .await } - .await?; + .await + { + Ok(data) => data, + Err(AuthedApiError::InvalidAuthentication) => return Ok(UploadResult::NotAuthenticated), + Err(AuthedApiError::UpgradeRequired) => return Ok(UploadResult::UpgradeRequired), + Err(err) => return Err(err.to_string()), + }; let screenshot_path = meta.project_path.join("screenshots/display.jpg"); meta.upload = Some(UploadMeta::SinglePartUpload { @@ -1154,17 +1161,20 @@ async fn upload_exported_video( NotificationType::ShareableLinkCopied.send(&app); Ok(UploadResult::Success(uploaded_video.link)) } + Err(AuthedApiError::UpgradeRequired) => Ok(UploadResult::UpgradeRequired), Err(e) => { error!("Failed to upload video: {e}"); NotificationType::UploadFailed.send(&app); - meta.upload = Some(UploadMeta::Failed { error: e.clone() }); + meta.upload = Some(UploadMeta::Failed { + error: e.to_string(), + }); meta.save_for_project() .map_err(|e| error!("Failed to save recording meta: {e}")) .ok(); - Err(e) + Err(e.to_string().into()) } } } @@ -1597,16 +1607,10 @@ async fn check_upgraded_and_update(app: AppHandle) -> Result { .await .map_err(|e| { println!("Failed to fetch plan: {e}"); - format!("Failed to fetch plan: {e}") + e.to_string() })?; println!("Plan fetch response status: {}", response.status()); - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - println!("Unauthorized response, clearing auth store"); - AuthStore::set(&app, None).map_err(|e| e.to_string())?; - return Ok(false); - } - let plan_data = response.json::().await.map_err(|e| { println!("Failed to parse plan response: {e}"); format!("Failed to parse plan response: {e}") @@ -1998,7 +2002,6 @@ pub async fn run(recording_logging_handle: LoggingHandle) { RequestOpenSettings, RequestScreenCapturePrewarm, NewNotification, - AuthenticationInvalid, audio_meter::AudioInputLevelChange, captions::DownloadProgress, recording::RecordingEvent, @@ -2548,7 +2551,7 @@ async fn resume_uploads(app: AppHandle) -> Result<(), String> { error!("Error completing resumed upload for video: {error}"); if let Ok(mut meta) = RecordingMeta::load_for_project(&recording_dir).map_err(|err| error!("Error loading project metadata: {err}")) { - meta.upload = Some(UploadMeta::Failed { error }); + meta.upload = Some(UploadMeta::Failed { error: error.to_string() }); meta.save_for_project().map_err(|err| error!("Error saving project metadata: {err}")).ok(); } }) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 8db9417449..8e38a60dae 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -37,6 +37,7 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogBuilder}; use tauri_specta::Event; use tracing::*; +use crate::web_api::AuthedApiError; use crate::{ App, CurrentRecordingChanged, MutableState, NewStudioRecordingAdded, RecordingState, RecordingStopped, VideoUploadInfo, @@ -245,6 +246,13 @@ pub enum RecordingEvent { Failed { error: String }, } +#[derive(Serialize, Type)] +pub enum RecordingAction { + Started, + InvalidAuthentication, + UpgradeRequired, +} + #[tauri::command] #[specta::specta] #[tracing::instrument(name = "recording", skip_all)] @@ -252,7 +260,7 @@ pub async fn start_recording( app: AppHandle, state_mtx: MutableState<'_, App>, inputs: StartRecordingInputs, -) -> Result<(), String> { +) -> Result { if !matches!(state_mtx.read().await.recording_state, RecordingState::None) { return Err("Recording already in progress".to_string()); } @@ -294,7 +302,7 @@ pub async fn start_recording( match AuthStore::get(&app).ok().flatten() { Some(_) => { // Pre-create the video and get the shareable link - let s3_config = create_or_get_video( + let s3_config = match create_or_get_video( &app, false, None, @@ -305,10 +313,19 @@ pub async fn start_recording( None, ) .await - .map_err(|err| { - error!("Error creating instant mode video: {err}"); - err - })?; + { + Ok(meta) => meta, + Err(AuthedApiError::InvalidAuthentication) => { + return Ok(RecordingAction::InvalidAuthentication); + } + Err(AuthedApiError::UpgradeRequired) => { + return Ok(RecordingAction::UpgradeRequired); + } + Err(err) => { + error!("Error creating instant mode video: {err}"); + return Err(err.to_string()); + } + }; let link = app.make_app_url(format!("/s/{}", s3_config.id)).await; info!("Pre-created shareable link: {}", link); @@ -618,7 +635,7 @@ pub async fn start_recording( AppSounds::StartRecording.play(); - Ok(()) + Ok(RecordingAction::Started) } #[tauri::command] @@ -663,7 +680,10 @@ pub async fn stop_recording(app: AppHandle, state: MutableState<'_, App>) -> Res #[tauri::command] #[specta::specta] -pub async fn restart_recording(app: AppHandle, state: MutableState<'_, App>) -> Result<(), String> { +pub async fn restart_recording( + app: AppHandle, + state: MutableState<'_, App>, +) -> Result { let Some(recording) = state.write().await.clear_current_recording() else { return Err("No recording in progress".to_string()); }; @@ -877,7 +897,7 @@ async fn handle_recording_finish( .handle .await .map_err(|e| e.to_string()) - .and_then(|r| r) + .and_then(|r| r.map_err(|v| v.to_string())) { Ok(()) => { info!( @@ -935,7 +955,9 @@ async fn handle_recording_finish( error!("Error in upload_video: {error}"); if let Ok(mut meta) = RecordingMeta::load_for_project(&recording_dir) { - meta.upload = Some(UploadMeta::Failed { error }); + meta.upload = Some(UploadMeta::Failed { + error: error.to_string(), + }); meta.save_for_project() .map_err(|e| format!("Failed to save recording meta: {e}")) .ok(); diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index d0bb1a535d..4956f35a36 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -5,7 +5,7 @@ use crate::{ api::{self, PresignedS3PutRequest, PresignedS3PutRequestMethod, S3VideoMeta, UploadedPart}, general_settings::GeneralSettingsStore, upload_legacy, - web_api::ManagerExt, + web_api::{AuthedApiError, ManagerExt}, }; use async_stream::{stream, try_stream}; use axum::http::Uri; @@ -17,6 +17,7 @@ use flume::Receiver; use futures::{Stream, StreamExt, TryStreamExt, stream}; use image::{ImageReader, codecs::jpeg::JpegEncoder}; use reqwest::StatusCode; +use sentry::types::Auth; use serde::{Deserialize, Serialize}; use specta::Type; use std::{ @@ -63,7 +64,8 @@ pub async fn upload_video( screenshot_path: PathBuf, meta: S3VideoMeta, channel: Option>, -) -> Result { +) -> Result { + println!("Uploading video {video_id}..."); let is_new_uploader_enabled = GeneralSettingsStore::get(&app) .map_err(|err| error!("Error checking status of new uploader flow from settings: {err}")) .ok() @@ -128,7 +130,7 @@ pub async fn upload_video( stream::once(async move { Ok::<_, std::io::Error>(bytes::Bytes::from(bytes)) }), ); - let (video_result, thumbnail_result): (Result<_, String>, Result<_, String>) = + let (video_result, thumbnail_result): (Result<_, AuthedApiError>, Result<_, AuthedApiError>) = tokio::join!(video_fut, thumbnail_fut); let _ = (video_result?, thumbnail_result?); @@ -153,7 +155,10 @@ async fn file_reader_stream(path: impl AsRef) -> Result<(ReaderStream Result { +pub async fn upload_image( + app: &AppHandle, + file_path: PathBuf, +) -> Result { let is_new_uploader_enabled = GeneralSettingsStore::get(app) .map_err(|err| error!("Error checking status of new uploader flow from settings: {err}")) .ok() @@ -166,7 +171,8 @@ pub async fn upload_image(app: &AppHandle, file_path: PathBuf) -> Result, name: Option, meta: Option, -) -> Result { +) -> Result { let mut s3_config_url = if let Some(id) = video_id { format!("/api/desktop/video/create?recordingMode=desktopMP4&videoId={id}") } else if is_screenshot { @@ -227,12 +233,7 @@ pub async fn create_or_get_video( let response = app .authed_api_request(s3_config_url, |client, url| client.get(url)) - .await - .map_err(|e| format!("Failed to send request to Next.js handler: {e}"))?; - - if response.status() == StatusCode::UNAUTHORIZED { - return Err("Failed to authenticate request; please log in again".into()); - } + .await?; if response.status() != StatusCode::OK { #[derive(Deserialize, Clone, Debug)] @@ -242,13 +243,8 @@ pub async fn create_or_get_video( if let Ok(error) = response.json::().await { if error.error == "upgrade_required" { - return Err( - "You must upgrade to Cap Pro to upload recordings over 5 minutes in length" - .into(), - ); + return Err(AuthedApiError::UpgradeRequired); } - - return Err(format!("server error: {}", error.error)); } return Err("Unknown error uploading video".into()); @@ -322,7 +318,7 @@ pub async fn compress_image(path: PathBuf) -> Result, String> { } pub struct InstantMultipartUpload { - pub handle: tokio::task::JoinHandle>, + pub handle: tokio::task::JoinHandle>, } impl InstantMultipartUpload { @@ -352,7 +348,7 @@ impl InstantMultipartUpload { pre_created_video: VideoUploadInfo, realtime_video_done: Option>, recording_dir: PathBuf, - ) -> Result<(), String> { + ) -> Result<(), AuthedApiError> { let is_new_uploader_enabled = GeneralSettingsStore::get(&app) .map_err(|err| { error!("Error checking status of new uploader flow from settings: {err}") @@ -369,7 +365,8 @@ impl InstantMultipartUpload { pre_created_video, realtime_video_done, ) - .await; + .await + .map_err(Into::into); } let video_id = pre_created_video.id.clone(); @@ -601,7 +598,7 @@ fn multipart_uploader( video_id: String, upload_id: String, stream: impl Stream>, -) -> impl Stream> { +) -> impl Stream> { debug!("Initializing multipart uploader for video {video_id:?}"); try_stream! { @@ -654,7 +651,7 @@ pub async fn singlepart_uploader( request: PresignedS3PutRequest, total_size: u64, stream: impl Stream> + Send + 'static, -) -> Result<(), String> { +) -> Result<(), AuthedApiError> { let presigned_url = api::upload_signed(&app, request).await?; let url = Uri::from_str(&presigned_url) diff --git a/apps/desktop/src-tauri/src/upload_legacy.rs b/apps/desktop/src-tauri/src/upload_legacy.rs index ee069f329a..223b8573b3 100644 --- a/apps/desktop/src-tauri/src/upload_legacy.rs +++ b/apps/desktop/src-tauri/src/upload_legacy.rs @@ -6,7 +6,7 @@ // credit @filleduchaos use crate::api::S3VideoMeta; -use crate::web_api::ManagerExt; +use crate::web_api::{AuthedApiError, ManagerExt}; use crate::{UploadProgress, VideoUploadInfo}; use ffmpeg::ffi::AV_TIME_BASE; use flume::Receiver; @@ -210,7 +210,7 @@ pub async fn upload_video( screenshot_path: Option, meta: Option, channel: Option>, -) -> Result { +) -> Result { println!("Uploading video {video_id}..."); let client = reqwest::Client::new(); @@ -320,9 +320,7 @@ pub async fn upload_video( status, error_body ); - Err(format!( - "Failed to upload file. Status: {status}. Body: {error_body}" - )) + Err(format!("Failed to upload file. Status: {status}. Body: {error_body}").into()) } pub async fn upload_image(app: &AppHandle, file_path: PathBuf) -> Result { diff --git a/apps/desktop/src-tauri/src/web_api.rs b/apps/desktop/src-tauri/src/web_api.rs index b87b4721fc..5f18c35ea3 100644 --- a/apps/desktop/src-tauri/src/web_api.rs +++ b/apps/desktop/src-tauri/src/web_api.rs @@ -1,13 +1,44 @@ use reqwest::StatusCode; +use serde::Serialize; +use specta::Type; use tauri::{Emitter, Manager, Runtime}; use tauri_specta::Event; -use tracing::error; +use thiserror::Error; +use tracing::{error, warn}; use crate::{ ArcLock, - auth::{AuthSecret, AuthStore, AuthenticationInvalid}, + auth::{AuthSecret, AuthStore}, }; +#[derive(Error, Debug)] +pub enum AuthedApiError { + #[error("User is not authenticated or credentials have expired!")] + InvalidAuthentication, + #[error("User needs to upgrade their account to use this feature!")] + UpgradeRequired, + #[error("AuthedApiError/AuthStore: {0}")] + AuthStore(String), + #[error("AuthedApiError/Request: {0}")] + Request(#[from] reqwest::Error), + #[error("AuthedApiError/Deserialization: {0}")] + Deserialization(#[from] serde_json::Error), + #[error("AuthedApiError/Other: {0}")] + Other(String), +} + +impl From<&'static str> for AuthedApiError { + fn from(value: &'static str) -> Self { + AuthedApiError::Other(value.into()) + } +} + +impl From for AuthedApiError { + fn from(value: String) -> Self { + AuthedApiError::Other(value) + } +} + async fn do_authed_request( auth: &AuthStore, build: impl FnOnce(reqwest::Client, String) -> reqwest::RequestBuilder, @@ -28,7 +59,7 @@ async fn do_authed_request( ) .header("X-Cap-Desktop-Version", env!("CARGO_PKG_VERSION")); - if let Some(s) = std::option_env!("VITE_VERCEL_AUTOMATION_BYPASS_SECRET") { + if let Ok(s) = std::env::var("VITE_VERCEL_AUTOMATION_BYPASS_SECRET") { req = req.header("x-vercel-protection-bypass", s); } @@ -40,7 +71,7 @@ pub trait ManagerExt: Manager { &self, path: impl Into, build: impl FnOnce(reqwest::Client, String) -> reqwest::RequestBuilder, - ) -> Result; + ) -> Result; async fn make_app_url(&self, pathname: impl AsRef) -> String; } @@ -50,26 +81,19 @@ impl + Emitter, R: Runtime> ManagerExt for T { &self, path: impl Into, build: impl FnOnce(reqwest::Client, String) -> reqwest::RequestBuilder, - ) -> Result { - let Some(auth) = AuthStore::get(self.app_handle())? else { - println!("Not logged in"); - - AuthenticationInvalid.emit(self).ok(); - - return Err("Unauthorized".to_string()); + ) -> Result { + let Some(auth) = AuthStore::get(self.app_handle()).map_err(AuthedApiError::AuthStore)? + else { + warn!("Not logged in"); + return Err(AuthedApiError::InvalidAuthentication); }; let url = self.make_app_url(path.into()).await; - let response = do_authed_request(&auth, build, url) - .await - .map_err(|e| e.to_string())?; + let response = do_authed_request(&auth, build, url).await?; if response.status() == StatusCode::UNAUTHORIZED { error!("Authentication expired. Please log in again."); - - AuthenticationInvalid.emit(self).ok(); - - return Err("Unauthorized".to_string()); + return Err(AuthedApiError::InvalidAuthentication); } Ok(response) diff --git a/apps/desktop/src/routes/(window-chrome)/(main).tsx b/apps/desktop/src/routes/(window-chrome)/(main).tsx index 22c667683f..5a98956cc4 100644 --- a/apps/desktop/src/routes/(window-chrome)/(main).tsx +++ b/apps/desktop/src/routes/(window-chrome)/(main).tsx @@ -238,11 +238,14 @@ function Page() { } })(); - await commands.startRecording({ - capture_target, - mode: payload.mode, - capture_system_audio: rawOptions.captureSystemAudio, - }); + await handleRecordingResult( + commands.startRecording({ + capture_target, + mode: payload.mode, + capture_system_audio: rawOptions.captureSystemAudio, + }), + setOptions, + ); } else await commands.stopRecording(); }, })); @@ -589,6 +592,7 @@ import { Transition } from "solid-transition-group"; import { SignInButton } from "~/components/SignInButton"; import { authStore, generalSettingsStore } from "~/store"; import { createTauriEventListener } from "~/utils/createEventListener"; +import { handleRecordingResult } from "~/utils/recording"; import { apiClient } from "~/utils/web-api"; import { WindowChromeHeader } from "./Context"; import { diff --git a/apps/desktop/src/routes/in-progress-recording.tsx b/apps/desktop/src/routes/in-progress-recording.tsx index 17682f5da5..9459c4136d 100644 --- a/apps/desktop/src/routes/in-progress-recording.tsx +++ b/apps/desktop/src/routes/in-progress-recording.tsx @@ -19,6 +19,7 @@ import { createCurrentRecordingQuery, createOptionsQuery, } from "~/utils/queries"; +import { handleRecordingResult } from "~/utils/recording"; import { commands, events } from "~/utils/tauri"; type State = @@ -128,7 +129,7 @@ export default function () { if (!shouldRestart) return; - await commands.restartRecording(); + await handleRecordingResult(commands.restartRecording(), undefined); setState({ variant: "recording" }); setTime(Date.now()); diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index 1ceafbb712..e0c44bf295 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -7,6 +7,7 @@ import { useSearchParams } from "@solidjs/router"; import { createQuery } from "@tanstack/solid-query"; import { emit } from "@tauri-apps/api/event"; import { CheckMenuItem, Menu, Submenu } from "@tauri-apps/api/menu"; +import * as dialog from "@tauri-apps/plugin-dialog"; import { cx } from "cva"; import { type ComponentProps, @@ -24,6 +25,7 @@ import { createStore, reconcile } from "solid-js/store"; import ModeSelect from "~/components/ModeSelect"; import { authStore, generalSettingsStore } from "~/store"; import { createOptionsQuery } from "~/utils/queries"; +import { handleRecordingResult } from "~/utils/recording"; import { commands, type DisplayId, @@ -811,11 +813,14 @@ function RecordingControls(props: { return; } - commands.startRecording({ - capture_target: props.target, - mode: rawOptions.mode, - capture_system_audio: rawOptions.captureSystemAudio, - }); + handleRecordingResult( + commands.startRecording({ + capture_target: props.target, + mode: rawOptions.mode, + capture_system_audio: rawOptions.captureSystemAudio, + }), + setOptions, + ); }} >
diff --git a/apps/desktop/src/utils/recording.ts b/apps/desktop/src/utils/recording.ts new file mode 100644 index 0000000000..bd9011633e --- /dev/null +++ b/apps/desktop/src/utils/recording.ts @@ -0,0 +1,48 @@ +import { emit } from "@tauri-apps/api/event"; +import * as dialog from "@tauri-apps/plugin-dialog"; +import type { createOptionsQuery } from "./queries"; +import { commands, type RecordingAction } from "./tauri"; + +export function handleRecordingResult( + result: Promise, + setOptions: ReturnType["setOptions"] | undefined, +) { + return result + .then(async (result) => { + if (result === "InvalidAuthentication") { + const buttons = setOptions + ? { + yes: "Login", + no: "Switch to Studio mode", + cancel: "Cancel", + } + : { + ok: "Login", + cancel: "Cancel", + }; + + const result = await dialog.message( + "You must be authenticated to start an instant mode recording. Login or switch to Studio mode.", + { + title: "Authentication required", + buttons, + }, + ); + + if (result === buttons.yes || result === buttons.ok) + emit("start-sign-in"); + else if (result === buttons.no && setOptions) + setOptions({ mode: "studio" }); + } else if (result === "UpgradeRequired") commands.showWindow("Upgrade"); + else + await dialog.message(`Error: ${result}`, { + title: "Error starting recording", + }); + }) + .catch((err) => + dialog.message(err, { + title: "Error starting recording", + kind: "error", + }), + ); +} diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index cfece4f3a0..0ae980b3ff 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -11,7 +11,7 @@ async setMicInput(label: string | null) : Promise { async setCameraInput(id: DeviceOrModelID | null) : Promise { return await TAURI_INVOKE("set_camera_input", { id }); }, -async startRecording(inputs: StartRecordingInputs) : Promise { +async startRecording(inputs: StartRecordingInputs) : Promise { return await TAURI_INVOKE("start_recording", { inputs }); }, async stopRecording() : Promise { @@ -23,7 +23,7 @@ async pauseRecording() : Promise { async resumeRecording() : Promise { return await TAURI_INVOKE("resume_recording"); }, -async restartRecording() : Promise { +async restartRecording() : Promise { return await TAURI_INVOKE("restart_recording"); }, async deleteRecording() : Promise { @@ -282,7 +282,6 @@ async editorDeleteProject() : Promise { export const events = __makeEvents__<{ audioInputLevelChange: AudioInputLevelChange, -authenticationInvalid: AuthenticationInvalid, currentRecordingChanged: CurrentRecordingChanged, downloadProgress: DownloadProgress, editorStateChanged: EditorStateChanged, @@ -305,7 +304,6 @@ targetUnderCursor: TargetUnderCursor, uploadProgressEvent: UploadProgressEvent }>({ audioInputLevelChange: "audio-input-level-change", -authenticationInvalid: "authentication-invalid", currentRecordingChanged: "current-recording-changed", downloadProgress: "download-progress", editorStateChanged: "editor-state-changed", @@ -346,7 +344,6 @@ export type AudioMeta = { path: string; start_time?: number | null } export type AuthSecret = { api_key: string } | { token: string; expires: number } export type AuthStore = { secret: AuthSecret; user_id: string | null; plan: Plan | null; intercom_hash: string | null } -export type AuthenticationInvalid = null export type BackgroundConfiguration = { source: BackgroundSource; blur: number; padding: number; rounding: number; inset: number; crop: Crop | null; shadow?: number; advancedShadow?: ShadowConfiguration | null; border?: BorderConfiguration | null } export type BackgroundSource = { type: "wallpaper"; path: string | null } | { type: "image"; path: string | null } | { type: "color"; value: [number, number, number] } | { type: "gradient"; from: [number, number, number]; to: [number, number, number]; angle?: number } export type BorderConfiguration = { enabled: boolean; width: number; color: [number, number, number]; opacity: number } @@ -433,6 +430,7 @@ export type Preset = { name: string; config: ProjectConfiguration } export type PresetsStore = { presets: Preset[]; default: number | null } export type ProjectConfiguration = { aspectRatio: AspectRatio | null; background: BackgroundConfiguration; camera: Camera; audio: AudioConfiguration; cursor: CursorConfiguration; hotkeys: HotkeysConfiguration; timeline?: TimelineConfiguration | null; captions?: CaptionsData | null; clips?: ClipConfiguration[] } export type ProjectRecordingsMeta = { segments: SegmentRecordings[] } +export type RecordingAction = "Started" | "InvalidAuthentication" | "UpgradeRequired" export type RecordingDeleted = { path: string } export type RecordingEvent = { variant: "Countdown"; value: number } | { variant: "Started" } | { variant: "Stopped" } | { variant: "Failed"; error: string } export type RecordingMeta = (StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadMeta | null }