From e1f4886aaf56a59e46ef33948688f32b1dc9a53c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 20 May 2025 15:31:37 +0100 Subject: [PATCH 1/5] feat: update share link metadata --- Cargo.toml | 1 + apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/src/lib.rs | 2 +- apps/desktop/src-tauri/src/recording.rs | 34 ++++++++++- apps/desktop/src-tauri/src/upload.rs | 79 +++++++++++++++++++++++-- apps/web/app/api/desktop/video/app.ts | 62 ++++++++++++++++++- packages/database/types/metadata.ts | 16 +++++ 7 files changed, 187 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 246e5f3ea..716aa8863 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ cidre = { git = "https://github.com/yury/cidre", rev = "ef04aaabe14ffbbce4a33097 windows = "0.58.0" windows-sys = "0.59.0" windows-capture = "1.4.2" +percent-encoding = "2.3.1" [workspace.lints.clippy] dbg_macro = "deny" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 0451e1921..c2c5c09b0 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -76,6 +76,7 @@ cpal.workspace = true keyed_priority_queue = "0.4.2" sentry.workspace = true clipboard-rs = "0.2.2" +percent-encoding = { workspace = true } cap-utils = { path = "../../../crates/utils" } cap-project = { path = "../../../crates/project" } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 391e9f6af..110dbe010 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1263,7 +1263,7 @@ async fn upload_exported_video( } }; - get_s3_config(&app, false, video_id).await + get_s3_config(&app, false, video_id, Some(duration), None).await } .await?; diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index c95c3f070..4283206cd 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -9,7 +9,14 @@ use crate::{ }, open_external_link, presets::PresetsStore, - upload::{get_s3_config, prepare_screenshot_upload, upload_video, InstantMultipartUpload}, + upload::{ + get_s3_config, + prepare_screenshot_upload, + upload_video, + InstantMultipartUpload, + update_video_metadata, + get_video_details, + }, web_api::{self, ManagerExt}, windows::{CapWindowId, ShowCapWindow}, App, CurrentRecordingChanged, DynLoggingLayer, MutableState, NewStudioRecordingAdded, @@ -193,12 +200,16 @@ pub async fn start_recording( window.set_content_protected(matches!(recording_options.mode, RecordingMode::Studio)); } + let source_name = recording_options.capture_target.get_title(); + let video_upload_info = match recording_options.mode { RecordingMode::Instant => { match AuthStore::get(&app).ok().flatten() { Some(_) => { // Pre-create the video and get the shareable link - if let Ok(s3_config) = get_s3_config(&app, false, None).await { + if let Ok(s3_config) = + get_s3_config(&app, false, None, None, source_name.clone()).await + { let link = app.make_app_url(format!("/s/{}", s3_config.id())).await; info!("Pre-created shareable link: {}", link); @@ -590,6 +601,25 @@ async fn handle_recording_finish( } } } + + match get_video_details(&output_path) { + Ok((duration, resolution, fps)) => { + if let Err(e) = update_video_metadata( + &app, + &video_upload_info.id, + Some(duration), + Some(resolution), + Some(fps), + ) + .await + { + error!("Failed to update video metadata: {}", e); + } + } + Err(e) => { + error!("Failed to get video details: {}", e); + } + } } } }); diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index ef6a0fb60..4e48ffa6b 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -18,6 +18,7 @@ use tokio::sync::mpsc; use tokio::sync::RwLock; use tokio::task; use tokio::time::sleep; +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; use tracing::{info, trace, warn}; use crate::web_api::{self, ManagerExt}; @@ -205,7 +206,7 @@ pub async fn upload_video( let client = reqwest::Client::new(); let s3_config = match existing_config { Some(config) => config, - None => get_s3_config(app, false, Some(video_id)).await?, + None => get_s3_config(app, false, Some(video_id), None, None).await?, }; let file_key = format!( @@ -341,7 +342,7 @@ pub async fn upload_image(app: &AppHandle, file_path: PathBuf) -> Result Result, + duration: Option, + source_name: Option, ) -> Result { - let s3_config_url = if let Some(id) = video_id { + let mut s3_config_url = if let Some(id) = &video_id { format!("/api/desktop/video/create?recordingMode=desktopMP4&videoId={id}") } else if is_screenshot { "/api/desktop/video/create?recordingMode=desktopMP4&isScreenshot=true".to_string() @@ -488,6 +491,15 @@ pub async fn get_s3_config( "/api/desktop/video/create?recordingMode=desktopMP4".to_string() }; + if let Some(d) = duration { + s3_config_url.push_str(&format!("&duration={}", d)); + } + + if let Some(name) = source_name { + let encoded = utf8_percent_encode(&name, NON_ALPHANUMERIC).to_string(); + s3_config_url.push_str(&format!("&sourceName={}", encoded)); + } + let response = app .authed_api_request(s3_config_url, |client, url| client.get(url)) .await @@ -1386,3 +1398,62 @@ struct UploadedPart { etag: String, size: usize, } + +pub async fn update_video_metadata( + app: &AppHandle, + video_id: &str, + duration: Option, + resolution: Option, + fps: Option, +) -> Result<(), String> { + let body = serde_json::json!({ + "videoId": video_id, + "duration": duration, + "resolution": resolution, + "fps": fps, + }); + + let response = app + .authed_api_request("/api/desktop/video/metadata", |client, url| { + client.post(url).json(&body) + }) + .await + .map_err(|e| format!("Failed to send request to Next.js handler: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(format!( + "Failed to update metadata. Status: {}. Body: {}", + status, error_body + )); + } + + Ok(()) +} + +pub fn get_video_details(path: &PathBuf) -> Result<(f64, String, f64), String> { + let input = ffmpeg::format::input(path) + .map_err(|e| format!("Failed to read input file: {e}"))?; + let stream = input + .streams() + .best(ffmpeg::media::Type::Video) + .ok_or_else(|| "Failed to find appropriate video stream in file".to_string())?; + + let duration = input.duration() as f64 / 1000.0; + + let codec = ffmpeg::codec::context::Context::from_parameters(stream.parameters()) + .map_err(|e| format!("Unable to read video codec information: {e}"))?; + let video = codec.decoder().video().unwrap(); + let width = video.width(); + let height = video.height(); + let fps = video + .frame_rate() + .map(|f| f.0 as f64 / f.1 as f64) + .unwrap_or(0.0); + + Ok((duration, format!("{}x{}", width, height), fps)) +} diff --git a/apps/web/app/api/desktop/video/app.ts b/apps/web/app/api/desktop/video/app.ts index 70748525f..4799e1158 100644 --- a/apps/web/app/api/desktop/video/app.ts +++ b/apps/web/app/api/desktop/video/app.ts @@ -1,6 +1,7 @@ import { dub } from "@/utils/dub"; import { getS3Bucket, getS3Config } from "@/utils/s3"; import { db } from "@cap/database"; +import { VideoMetadata } from "@cap/database/types"; import { sendEmail } from "@cap/database/emails/config"; import { FirstShareableLink } from "@cap/database/emails/first-shareable-link"; import { nanoId } from "@cap/database/helpers"; @@ -21,6 +22,7 @@ app.get( "query", z.object({ duration: z.number().optional(), + sourceName: z.string().optional(), recordingMode: z .union([z.literal("hls"), z.literal("desktopMP4")]) .optional(), @@ -29,7 +31,7 @@ app.get( }) ), async (c) => { - const { duration, recordingMode, isScreenshot, videoId } = + const { duration, recordingMode, isScreenshot, videoId, sourceName } = c.req.valid("query"); const user = c.get("user"); @@ -59,6 +61,20 @@ app.get( .where(eq(videos.id, videoId)); if (video) { + const currentMetadata = (video.metadata as VideoMetadata) || {}; + const updatedMetadata: VideoMetadata = { + ...currentMetadata, + ...(sourceName ? { sourceName } : {}), + ...(duration ? { duration } : {}), + }; + + if (sourceName || duration) { + await db() + .update(videos) + .set({ metadata: updatedMetadata }) + .where(eq(videos.id, videoId)); + } + return c.json({ id: video.id, user_id: user.id, @@ -86,6 +102,10 @@ app.get( : undefined, isScreenshot, bucket: bucket?.id, + metadata: { + ...(sourceName ? { sourceName } : {}), + ...(duration ? { duration } : {}), + } as VideoMetadata, }; await db().insert(videos).values(videoData); @@ -147,3 +167,43 @@ app.get( }); } ); + +app.post( + "/metadata", + zValidator( + "json", + z.object({ + videoId: z.string(), + duration: z.number().optional(), + sourceName: z.string().optional(), + resolution: z.string().optional(), + fps: z.number().optional(), + }) + ), + async (c) => { + const { videoId, duration, sourceName, resolution, fps } = c.req.valid("json"); + const user = c.get("user"); + + const [video] = await db().select().from(videos).where(eq(videos.id, videoId)); + + if (!video || video.ownerId !== user.id) { + return c.json({ error: "not_found" }, { status: 404 }); + } + + const currentMetadata = (video.metadata as VideoMetadata) || {}; + const updatedMetadata: VideoMetadata = { + ...currentMetadata, + ...(sourceName ? { sourceName } : {}), + ...(duration ? { duration } : {}), + ...(resolution ? { resolution } : {}), + ...(fps ? { fps } : {}), + }; + + await db() + .update(videos) + .set({ metadata: updatedMetadata }) + .where(eq(videos.id, videoId)); + + return c.json({ success: true }); + } +); diff --git a/packages/database/types/metadata.ts b/packages/database/types/metadata.ts index 3ee362794..2eabf335a 100644 --- a/packages/database/types/metadata.ts +++ b/packages/database/types/metadata.ts @@ -11,6 +11,22 @@ export interface VideoMetadata { * This overrides the display of the actual createdAt timestamp */ customCreatedAt?: string; + /** + * Title of the captured monitor or window + */ + sourceName?: string; + /** + * Duration of the video in seconds + */ + duration?: number; + /** + * Resolution of the recording (e.g. 1920x1080) + */ + resolution?: string; + /** + * Frames per second of the recording + */ + fps?: number; [key: string]: any; } From 3be95a85e95e19b184f7ec620b2948a2ed15528e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 20 May 2025 16:15:36 +0100 Subject: [PATCH 2/5] feat: Duration, window/monitor name and resolution. --- Cargo.lock | 1 + apps/desktop/src-tauri/src/recording.rs | 10 +++------ apps/desktop/src-tauri/src/upload.rs | 6 +++--- apps/web/app/api/desktop/video/app.ts | 27 ++++++++----------------- 4 files changed, 15 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cddf8da31..f3d00a173 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -964,6 +964,7 @@ dependencies = [ "nix 0.29.0", "objc", "objc2-app-kit 0.3.0", + "percent-encoding", "png", "rand 0.8.5", "relative-path", diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 4283206cd..e86fc768a 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -10,12 +10,8 @@ use crate::{ open_external_link, presets::PresetsStore, upload::{ - get_s3_config, - prepare_screenshot_upload, - upload_video, - InstantMultipartUpload, - update_video_metadata, - get_video_details, + get_s3_config, get_video_details, prepare_screenshot_upload, update_video_metadata, + upload_video, InstantMultipartUpload, }, web_api::{self, ManagerExt}, windows::{CapWindowId, ShowCapWindow}, @@ -585,7 +581,7 @@ async fn handle_recording_finish( match upload_video( &app, video_upload_info.id.clone(), - output_path, + output_path.clone(), Some(video_upload_info.config.clone()), Some(display_screenshot.clone()), ) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 4e48ffa6b..67c50e63d 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -5,6 +5,7 @@ use flume::Receiver; use futures::{stream, StreamExt}; use image::codecs::jpeg::JpegEncoder; use image::ImageReader; +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; use reqwest::{multipart::Form, StatusCode}; use std::io::SeekFrom; use std::path::PathBuf; @@ -18,7 +19,6 @@ use tokio::sync::mpsc; use tokio::sync::RwLock; use tokio::task; use tokio::time::sleep; -use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; use tracing::{info, trace, warn}; use crate::web_api::{self, ManagerExt}; @@ -1436,8 +1436,8 @@ pub async fn update_video_metadata( } pub fn get_video_details(path: &PathBuf) -> Result<(f64, String, f64), String> { - let input = ffmpeg::format::input(path) - .map_err(|e| format!("Failed to read input file: {e}"))?; + let input = + ffmpeg::format::input(path).map_err(|e| format!("Failed to read input file: {e}"))?; let stream = input .streams() .best(ffmpeg::media::Type::Video) diff --git a/apps/web/app/api/desktop/video/app.ts b/apps/web/app/api/desktop/video/app.ts index 4799e1158..770a30915 100644 --- a/apps/web/app/api/desktop/video/app.ts +++ b/apps/web/app/api/desktop/video/app.ts @@ -21,7 +21,7 @@ app.get( zValidator( "query", z.object({ - duration: z.number().optional(), + duration: z.coerce.number().optional(), sourceName: z.string().optional(), recordingMode: z .union([z.literal("hls"), z.literal("desktopMP4")]) @@ -35,11 +35,11 @@ app.get( c.req.valid("query"); const user = c.get("user"); - // Check if user is on free plan and video is over 5 minutes const isUpgraded = user.stripeSubscriptionStatus === "active"; - if (!isUpgraded && duration && duration > 300) + if (!isUpgraded && duration && duration > 300) { return c.json({ error: "upgrade_required" }, { status: 403 }); + } const [bucket] = await db() .select() @@ -110,14 +110,14 @@ app.get( await db().insert(videos).values(videoData); - if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") + if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") { await dub().links.create({ url: `${serverEnv().WEB_URL}/s/${idToUse}`, domain: "cap.link", key: idToUse, }); + } - // Check if this is the user's first video and send the first shareable link email try { const videoCount = await db() .select({ count: count() }) @@ -130,15 +130,10 @@ app.get( videoCount[0].count === 1 && user.email ) { - console.log( - "[SendFirstShareableLinkEmail] Sending first shareable link email with 5-minute delay" - ); - const videoUrl = buildEnv.NEXT_PUBLIC_IS_CAP ? `https://cap.link/${idToUse}` : `${serverEnv().WEB_URL}/s/${idToUse}`; - // Send email with 5-minute delay using Resend's scheduling feature await sendEmail({ email: user.email, subject: "You created your first Cap! 🥳", @@ -150,14 +145,8 @@ app.get( marketing: true, scheduledAt: "in 5 min", }); - - console.log( - "[SendFirstShareableLinkEmail] First shareable link email scheduled to be sent in 5 minutes" - ); } - } catch (error) { - console.error("Error checking for first video or sending email:", error); - } + } catch (error) {} return c.json({ id: idToUse, @@ -174,10 +163,10 @@ app.post( "json", z.object({ videoId: z.string(), - duration: z.number().optional(), + duration: z.coerce.number().optional(), sourceName: z.string().optional(), resolution: z.string().optional(), - fps: z.number().optional(), + fps: z.coerce.number().optional(), }) ), async (c) => { From 77fe5c1b96f488fd4ff7dfb56af5935c0951b919 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 23 May 2025 18:14:58 +0100 Subject: [PATCH 3/5] Add Cap AI summary and chapters (#567) * Use mp4 links for social embeds (#555) * feat: Add referrer to headers and check for robots.txt * feat: Add twitter bot check to robots.txt * feat: Robots.txt changes * feat: use userAgent utility * allow deleting split clips (#556) * allow deleting split clips * remove active preset * fix end trim marker * Fix crop normalization (#551) * Fix crop normalization logic * cleanup * add tests --------- Co-authored-by: Brendan Allan * presets fixes (#563) * preserve timeline when switching presets * make preset deserialization a silent failure * use api keys for desktop auth (#564) * use api keys for desktop auth * add primary * fix upgrade flow * use vercel branch url * update http plugin versions * Fix license activation refresh and add settings page (#561) * fix license activation update and add license settings page * move license page to correct location * update tauri plugin http * ui tweaks * cleanup ui * fix up license/upgrade page flows * remove Manage Subscription button * fix: License text colour --------- Co-authored-by: Brendan Allan Co-authored-by: ameer2468 <33054370+ameer2468@users.noreply.github.com> * feat: add Cap AI summary and chapters * add additional info to feedback * 0.3.47 * fix deeplink auth * 0.3.48 * version: 0.3.47 * feat: More AI impl progress + better player UX * feat: More transcription/AI on page load bits * feat: Add feature flag for AI generation --------- Co-authored-by: Brendan Allan Co-authored-by: ameer2468 <33054370+ameer2468@users.noreply.github.com> --- .env.example | 2 + Cargo.lock | 6 +- apps/desktop/package.json | 5 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/src/auth.rs | 16 +- apps/desktop/src-tauri/src/lib.rs | 6 +- apps/desktop/src-tauri/src/presets.rs | 6 +- apps/desktop/src-tauri/src/web_api.rs | 15 +- apps/desktop/src/components/SignInButton.tsx | 140 +- apps/desktop/src/components/Toggle.tsx | 26 +- .../src/routes/(window-chrome)/(main).tsx | 60 +- .../src/routes/(window-chrome)/licensing.tsx | 286 ---- .../src/routes/(window-chrome)/settings.tsx | 5 + .../(window-chrome)/settings/experimental.tsx | 4 +- .../(window-chrome)/settings/feedback.tsx | 90 +- .../(window-chrome)/settings/license.tsx | 331 ++++ .../src/routes/(window-chrome)/upgrade.tsx | 402 ++--- .../src/routes/editor/ConfigSidebar.tsx | 158 +- apps/desktop/src/routes/editor/Player.tsx | 14 +- .../src/routes/editor/PresetsDropdown.tsx | 25 +- .../src/routes/editor/Timeline/ClipTrack.tsx | 89 +- .../src/routes/editor/Timeline/Track.tsx | 18 +- .../src/routes/editor/Timeline/ZoomTrack.tsx | 41 +- .../src/routes/editor/Timeline/index.tsx | 53 +- apps/desktop/src/routes/editor/context.ts | 5 +- apps/desktop/src/routes/editor/index.tsx | 2 +- apps/desktop/src/routes/editor/ui.tsx | 25 +- apps/desktop/src/styles/timeline.css | 34 +- apps/desktop/src/utils/auth.ts | 157 ++ apps/desktop/src/utils/tauri.ts | 3 +- apps/desktop/src/utils/web-api.ts | 12 +- .../actions/videos/generate-ai-metadata.ts | 258 ++++ apps/web/actions/videos/get-analytics.ts | 2 - apps/web/actions/videos/transcribe.ts | 34 +- apps/web/app/api/desktop/app.ts | 19 +- apps/web/app/api/desktop/session/app.ts | 45 +- .../route.ts => [...route]/multipart.ts} | 9 +- apps/web/app/api/upload/[...route]/route.ts | 18 + .../{signed/route.ts => [...route]/signed.ts} | 76 +- apps/web/app/api/upload/mux/create/route.ts | 298 ---- apps/web/app/api/upload/mux/status/route.ts | 164 -- apps/web/app/api/upload/new/route.ts | 49 - apps/web/app/api/utils.ts | 55 +- apps/web/app/api/video/ai/route.ts | 103 ++ apps/web/app/api/video/status/route.ts | 170 +++ .../app/api/video/transcribe/status/route.ts | 14 + apps/web/app/robots.ts | 92 +- apps/web/app/s/[videoId]/Share.tsx | 283 +++- .../s/[videoId]/_components/ShareHeader.tsx | 6 +- .../s/[videoId]/_components/ShareVideo.tsx | 453 +++--- .../app/s/[videoId]/_components/Sidebar.tsx | 30 +- .../s/[videoId]/_components/tabs/Summary.tsx | 235 +++ .../[videoId]/_components/tabs/Transcript.tsx | 17 +- apps/web/app/s/[videoId]/page.tsx | 217 ++- apps/web/content/changelog/59.mdx | 12 + apps/web/middleware.ts | 28 +- apps/web/scripts/reset-ai-processing-flags.ts | 44 + apps/web/utils/flags.ts | 34 + crates/cursor-capture/src/position.rs | 153 +- .../migrations/0003_small_chamber.sql | 5 + .../database/migrations/0004_good_microbe.sql | 2 + .../migrations/meta/0003_snapshot.json | 1318 ++++++++++++++++ .../migrations/meta/0004_snapshot.json | 1332 +++++++++++++++++ .../database/migrations/meta/_journal.json | 14 + packages/database/schema.ts | 63 +- packages/database/types/metadata.ts | 13 + packages/env/server.ts | 2 +- packages/web-api-contract/src/desktop.ts | 6 +- pnpm-lock.yaml | 213 +-- 69 files changed, 5896 insertions(+), 2028 deletions(-) delete mode 100644 apps/desktop/src/routes/(window-chrome)/licensing.tsx create mode 100644 apps/desktop/src/routes/(window-chrome)/settings/license.tsx create mode 100644 apps/desktop/src/utils/auth.ts create mode 100644 apps/web/actions/videos/generate-ai-metadata.ts rename apps/web/app/api/upload/{multipart/[...route]/route.ts => [...route]/multipart.ts} (98%) create mode 100644 apps/web/app/api/upload/[...route]/route.ts rename apps/web/app/api/upload/{signed/route.ts => [...route]/signed.ts} (79%) delete mode 100644 apps/web/app/api/upload/mux/create/route.ts delete mode 100644 apps/web/app/api/upload/mux/status/route.ts delete mode 100644 apps/web/app/api/upload/new/route.ts create mode 100644 apps/web/app/api/video/ai/route.ts create mode 100644 apps/web/app/api/video/status/route.ts create mode 100644 apps/web/app/s/[videoId]/_components/tabs/Summary.tsx create mode 100644 apps/web/content/changelog/59.mdx create mode 100644 apps/web/scripts/reset-ai-processing-flags.ts create mode 100644 apps/web/utils/flags.ts create mode 100644 packages/database/migrations/0003_small_chamber.sql create mode 100644 packages/database/migrations/0004_good_microbe.sql create mode 100644 packages/database/migrations/meta/0003_snapshot.json create mode 100644 packages/database/migrations/meta/0004_snapshot.json diff --git a/.env.example b/.env.example index 318d355bc..9e67c7ec7 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,8 @@ NEXTAUTH_SECRET= # Audio transcription # DEEPGRAM_API_KEY= +# Cap AI +# OPENAI_API_KEY= # Only needed by cap.so cloud # NEXT_LOOPS_KEY= diff --git a/Cargo.lock b/Cargo.lock index f3d00a173..2da95d142 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -929,7 +929,7 @@ dependencies = [ [[package]] name = "cap-desktop" -version = "0.3.46" +version = "0.3.48" dependencies = [ "anyhow", "axum", @@ -7585,9 +7585,9 @@ dependencies = [ [[package]] name = "tauri-plugin-http" -version = "2.2.0" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e62a9bde54d6a0218b63f5a248f02056ad4316ba6ad81dfb9e4f73715df5deb1" +checksum = "696ef548befeee6c6c17b80ef73e7c41205b6c2204e87ef78ccc231212389a5c" dependencies = [ "data-url", "http", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8a598bec8..ff39e3669 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -41,7 +41,7 @@ "@tauri-apps/plugin-deep-link": "^2.2.0", "@tauri-apps/plugin-dialog": "2.0.1", "@tauri-apps/plugin-fs": "2.2.0", - "@tauri-apps/plugin-http": "^2.0.1", + "@tauri-apps/plugin-http": "^2.4.4", "@tauri-apps/plugin-notification": "2.0.0", "@tauri-apps/plugin-opener": "^2.2.6", "@tauri-apps/plugin-os": "2.0.0", @@ -64,7 +64,8 @@ "unplugin-icons": "^0.19.2", "uuid": "^9.0.1", "vinxi": "^0.5.6", - "webcodecs": "^0.1.0" + "webcodecs": "^0.1.0", + "zod": "^3.24.2" }, "devDependencies": { "@fontsource/geist-sans": "^5.0.3", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index c2c5c09b0..14f40f18c 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cap-desktop" -version = "0.3.46" +version = "0.3.48" description = "Beautiful screen recordings, owned by you." authors = ["you"] edition = "2021" diff --git a/apps/desktop/src-tauri/src/auth.rs b/apps/desktop/src-tauri/src/auth.rs index 40984ce82..406954161 100644 --- a/apps/desktop/src-tauri/src/auth.rs +++ b/apps/desktop/src-tauri/src/auth.rs @@ -10,13 +10,19 @@ use crate::web_api; #[derive(Serialize, Deserialize, Type, Debug)] pub struct AuthStore { - pub token: String, + pub secret: AuthSecret, pub user_id: Option, - pub expires: i32, pub plan: Option, pub intercom_hash: Option, } +#[derive(Serialize, Deserialize, Type, Debug)] +#[serde(untagged)] +pub enum AuthSecret { + ApiKey { api_key: String }, + Session { token: String, expires: i32 }, +} + #[derive(Serialize, Deserialize, Type, Debug)] pub struct Plan { pub upgraded: bool, @@ -108,12 +114,6 @@ impl AuthStore { return Err("Store not found".to_string()); }; - let value = value.map(|mut auth| { - // Set expiration to 100 years in the future - auth.expires = (chrono::Utc::now() + chrono::Duration::days(36500)).timestamp() as i32; - auth - }); - store.set("auth", json!(value)); store.save().map_err(|e| e.to_string()) } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 110dbe010..c50d9f452 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1715,7 +1715,7 @@ async fn check_upgraded_and_update(app: AppHandle) -> Result { } } - let Ok(Some(mut auth)) = AuthStore::get(&app) else { + let Ok(Some(auth)) = AuthStore::get(&app) else { println!("No auth found, clearing auth store"); AuthStore::set(&app, None).map_err(|e| e.to_string())?; return Ok(false); @@ -1732,7 +1732,6 @@ async fn check_upgraded_and_update(app: AppHandle) -> Result { "Fetching plan for user {}", auth.user_id.as_deref().unwrap_or("unknown") ); - let plan_url = app.make_app_url("/api/desktop/plan"); let response = app .authed_api_request("/api/desktop/plan", |client, url| client.get(url)) .await @@ -1759,9 +1758,8 @@ async fn check_upgraded_and_update(app: AppHandle) -> Result { .unwrap_or(false); println!("Pro status: {}", is_pro); let updated_auth = AuthStore { - token: auth.token, + secret: auth.secret, user_id: auth.user_id, - expires: auth.expires, intercom_hash: auth.intercom_hash, plan: Some(Plan { upgraded: is_pro, diff --git a/apps/desktop/src-tauri/src/presets.rs b/apps/desktop/src-tauri/src/presets.rs index 3b846e943..56ba8db4d 100644 --- a/apps/desktop/src-tauri/src/presets.rs +++ b/apps/desktop/src-tauri/src/presets.rs @@ -4,6 +4,7 @@ use serde_json::json; use specta::Type; use tauri::{AppHandle, Wry}; use tauri_plugin_store::StoreExt; +use tracing::error; #[derive(Serialize, Deserialize, Type, Debug, Default)] #[serde(rename_all = "camelCase")] @@ -26,7 +27,10 @@ impl PresetsStore { // Handle potential deserialization errors gracefully match serde_json::from_value(store) { Ok(settings) => Ok(Some(settings)), - Err(_) => Err("Failed to deserialize presets store".to_string()), + Err(_) => { + error!("Failed to deserialize presets store"); + Ok(None) + } } } _ => Ok(None), diff --git a/apps/desktop/src-tauri/src/web_api.rs b/apps/desktop/src-tauri/src/web_api.rs index c8478523d..9afb4ec38 100644 --- a/apps/desktop/src-tauri/src/web_api.rs +++ b/apps/desktop/src-tauri/src/web_api.rs @@ -3,8 +3,8 @@ use tauri::{Emitter, Manager, Runtime}; use tauri_specta::Event; use crate::{ - auth::{AuthStore, AuthenticationInvalid}, - ArcLock, MutableState, + auth::{AuthSecret, AuthStore, AuthenticationInvalid}, + ArcLock, }; async fn do_authed_request( @@ -14,7 +14,16 @@ async fn do_authed_request( ) -> Result { let client = reqwest::Client::new(); - let mut req = build(client, url).header("Authorization", format!("Bearer {}", auth.token)); + let mut req = build(client, url).header( + "Authorization", + format!( + "Bearer {}", + match &auth.secret { + AuthSecret::ApiKey { api_key } => api_key, + AuthSecret::Session { token, .. } => token, + } + ), + ); if let Some(s) = std::option_env!("VITE_VERCEL_AUTOMATION_BYPASS_SECRET") { req = req.header("x-vercel-protection-bypass", s); diff --git a/apps/desktop/src/components/SignInButton.tsx b/apps/desktop/src/components/SignInButton.tsx index 3ef0ab41e..755f724ca 100644 --- a/apps/desktop/src/components/SignInButton.tsx +++ b/apps/desktop/src/components/SignInButton.tsx @@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { onOpenUrl } from "@tauri-apps/plugin-deep-link"; import * as shell from "@tauri-apps/plugin-shell"; +import { z } from "zod"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { ComponentProps } from "solid-js"; @@ -12,27 +13,12 @@ import { identifyUser, trackEvent } from "~/utils/analytics"; import { clientEnv } from "~/utils/env"; import { commands } from "~/utils/tauri"; import callbackTemplate from "./callback.template"; +import { createSignInMutation } from "~/utils/auth"; export function SignInButton( props: Omit, "onClick"> ) { - const signIn = createMutation(() => ({ - mutationFn: async (abort: AbortController) => { - const platform = import.meta.env.DEV ? "web" : "desktop"; - - let session; - - if (platform === "web") - session = await createLocalServerSession(abort.signal); - else session = await createDeepLinkSession(abort.signal); - - await shell.open(session.url.toString()); - - await processAuthData(await session.complete()); - - getCurrentWindow().setFocus(); - }, - })); + const signIn = createSignInMutation(); return ( - - - Previous Recordings - - } - > + getCurrentWindow().hide(); + }} + class="flex items-center justify-center w-5 h-5 -ml-[1.5px]" + > + + + + Previous Recordings}> - + type="button" + onClick={async () => { + await commands.showWindow({ Settings: { page: "recordings" } }); + getCurrentWindow().hide(); + }} + class="flex justify-center items-center w-5 h-5" + > + + + @@ -794,7 +782,6 @@ function MicrophoneSelect(props: { const [dbs, setDbs] = createSignal(); const [isInitialized, setIsInitialized] = createSignal(false); - const requestPermission = useRequestPermission(); const permissionGranted = () => @@ -1036,7 +1023,7 @@ function TargetSelectInfoPill(props: { ? "On" : "Off"} -); + ); } function InfoPill( @@ -1112,10 +1099,7 @@ function ChangelogButton() { }); return ( - + - }> - -
-

- Your account is upgraded to Cap Pro and already includes a - commercial license. -

-
-
- - {(license) => ( -
-

- Your License -

-
- -
-                  {license().licenseKey}
-                
-
- - {(expiry) => ( -
- - - {new Date(expiry()).toUTCString()} - -
- )} -
- - {/*

- Instance ID: {license().instanceId} -

*/} -
- -
-
- )} -
-
- - ); -} - -function LicenseKeyActivate(props: { - onActivated: ( - value: ClientInferResponseBody< - (typeof licenseContract)["activateCommercialLicense"], - 200 - > & { licenseKey: string } - ) => void; -}) { - const [store] = createResource(() => generalSettingsStore.get()); - - return ( - - - {(generalSettings) => { - const [licenseKey, setLicenseKey] = createSignal(""); - - const activateLicenseKey = createMutation(() => ({ - mutationFn: async (vars: { licenseKey: string }) => { - const resp = await licenseApiClient.activateCommercialLicense({ - headers: { - licensekey: vars.licenseKey, - instanceid: generalSettings().instanceId!, - }, - body: { reset: false }, - }); - - if (resp.status === 200) return resp.body; - if ( - typeof resp.body === "object" && - resp.body && - "message" in resp.body - ) - throw resp.body.message; - throw new Error((resp.body as any).toString()); - }, - onSuccess: (value, { licenseKey }) => - props.onActivated({ ...value, licenseKey }), - })); - - return ( -
-

- Got a license key? Enter it below -

- setLicenseKey(e.currentTarget.value)} - /> - - {/*

- Instance ID: {generalSettings().instanceId} -

*/} -
- ); - }} -
-
- ); -} - -type CommercialLicenseType = "yearly" | "lifetime"; -function CommercialLicensePurchase() { - const openCheckoutInExternalBrowser = createMutation(() => ({ - mutationFn: async ({ type }: { type: CommercialLicenseType }) => { - const resp = await licenseApiClient.createCommercialCheckoutUrl({ - body: { type }, - }); - - if (resp.status === 200) tauriShell.open(resp.body.url); - else throw resp.body; - }, - })); - - const [type, setType] = createSignal("yearly"); - - return ( - <> -
-
-
-

- Commercial License -

-

- For professional use without cloud features. -

-
-
-

- {type() === "yearly" ? "$29/year" : "$58"} -

-
- {type() === "lifetime" && ( -

- billed once -

- )} - {type() === "lifetime" && ( -

- or, $29/year. -

- )} -
-
-
-
-
-
- - Switch to {type() === "yearly" ? "lifetime" : "yearly"} - - -
-
-
- -
-
-
-
-
    - {[ - "Commercial Use of Cap Recorder + Editor", - "Community Support", - ].map((feature) => ( -
  • -
    - -
    - - {feature} - -
  • - ))} -
-
-
-
-
-
- { - await generalSettingsStore.set({ - commercialLicense: { - activatedOn: Date.now(), - expiryDate: value.expiryDate ?? null, - refresh: value.refresh, - licenseKey: value.licenseKey, - }, - }); - }} - /> - - ); -} diff --git a/apps/desktop/src/routes/(window-chrome)/settings.tsx b/apps/desktop/src/routes/(window-chrome)/settings.tsx index 3aee92597..c3f886498 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings.tsx @@ -46,6 +46,11 @@ export default function Settings(props: RouteSectionProps) { name: "Integrations", icon: IconLucideUnplug, }, + { + href: "license", + name: "License", + icon: IconLucideGift, + }, { href: "experimental", name: "Experimental", diff --git a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx index c6740bcfa..38b44c65f 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx @@ -41,10 +41,10 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) {
-

+

Experimental Features

-

+

These features are still in development and may not work as expected.

diff --git a/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx b/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx index 775f70e85..70d390088 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx @@ -1,12 +1,14 @@ import { Button } from "@cap/ui-solid"; import { action, useAction, useSubmission } from "@solidjs/router"; import { createSignal } from "solid-js"; +import { type as ostype } from "@tauri-apps/plugin-os"; +import { getVersion } from "@tauri-apps/api/app"; import { apiClient, protectedHeaders } from "~/utils/web-api"; const sendFeedbackAction = action(async (feedback: string) => { const response = await apiClient.desktop.submitFeedback({ - body: { feedback }, + body: { feedback, os: ostype() as any, version: await getVersion() }, headers: await protectedHeaders(), }); @@ -21,50 +23,56 @@ export default function FeedbackTab() { const sendFeedback = useAction(sendFeedbackAction); return ( -
-

Send Feedback

-

- Help us improve Cap by submitting feedback or reporting bugs. We'll get - right on it. -

-
{ - e.preventDefault(); - sendFeedback(feedback()); - }} - > -
-
-