diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 29338b56c..76a27642b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod flags; mod frame_ws; mod general_settings; mod hotkeys; +mod logging; mod notifications; mod permissions; mod platform; @@ -110,24 +111,17 @@ pub enum RecordingState { Active(InProgressRecording), } -#[derive(specta::Type, Serialize)] -#[serde(rename_all = "camelCase")] pub struct App { #[deprecated = "can be removed when native camera preview is ready"] camera_ws_port: u16, - #[serde(skip)] camera_preview: CameraPreviewManager, - #[serde(skip)] handle: AppHandle, - #[serde(skip)] recording_state: RecordingState, - #[serde(skip)] recording_logging_handle: LoggingHandle, - #[serde(skip)] mic_feed: ActorRef, - #[serde(skip)] camera_feed: ActorRef, server_url: String, + logs_dir: PathBuf, } #[derive(specta::Type, Serialize, Deserialize, Clone, Debug)] @@ -1938,7 +1932,7 @@ pub type DynLoggingLayer = Box + type LoggingHandle = tracing_subscriber::reload::Handle, FilteredRegistry>; #[cfg_attr(mobile, tauri::mobile_entry_point)] -pub async fn run(recording_logging_handle: LoggingHandle) { +pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { ffmpeg::init() .map_err(|e| { error!("Failed to initialize ffmpeg: {e}"); @@ -2256,6 +2250,7 @@ pub async fn run(recording_logging_handle: LoggingHandle) { .map(|v| v.server_url.clone()) }) .unwrap_or_else(|| "https://cap.so".to_string()), + logs_dir: logs_dir.clone(), }))); app.manage(Arc::new(RwLock::new( diff --git a/apps/desktop/src-tauri/src/logging.rs b/apps/desktop/src-tauri/src/logging.rs new file mode 100644 index 000000000..c88f39abc --- /dev/null +++ b/apps/desktop/src-tauri/src/logging.rs @@ -0,0 +1,83 @@ +use crate::{ArcLock, web_api::ManagerExt}; +use std::{fs, path::PathBuf}; +use tauri::{AppHandle, Manager}; + +async fn get_latest_log_file(app: &AppHandle) -> Option { + let logs_dir = app + .state::>() + .read() + .await + .logs_dir + .clone(); + + let entries = fs::read_dir(&logs_dir).ok()?; + let mut log_files: Vec<_> = entries + .filter_map(|entry| { + let entry = entry.ok()?; + let path = entry.path(); + if path.is_file() && path.file_name()?.to_str()?.contains("cap-desktop.log") { + let metadata = fs::metadata(&path).ok()?; + let modified = metadata.modified().ok()?; + Some((path, modified)) + } else { + None + } + }) + .collect(); + + log_files.sort_by(|a, b| b.1.cmp(&a.1)); + log_files.first().map(|(path, _)| path.clone()) +} + +pub async fn upload_log_file(app: &AppHandle) -> Result<(), String> { + let log_file = get_latest_log_file(app).await.ok_or("No log file found")?; + + let metadata = + fs::metadata(&log_file).map_err(|e| format!("Failed to read log file metadata: {}", e))?; + let file_size = metadata.len(); + + const MAX_SIZE: u64 = 1 * 1024 * 1024; + + let log_content = if file_size > MAX_SIZE { + let content = + fs::read_to_string(&log_file).map_err(|e| format!("Failed to read log file: {}", e))?; + + let header = format!( + "⚠️ Log file truncated (original size: {} bytes, showing last ~1MB)\n\n", + file_size + ); + let max_content_size = (MAX_SIZE as usize) - header.len(); + + if content.len() > max_content_size { + let start_pos = content.len() - max_content_size; + let truncated = &content[start_pos..]; + if let Some(newline_pos) = truncated.find('\n') { + format!("{}{}", header, &truncated[newline_pos + 1..]) + } else { + format!("{}{}", header, truncated) + } + } else { + content + } + } else { + fs::read_to_string(&log_file).map_err(|e| format!("Failed to read log file: {}", e))? + }; + + let form = reqwest::multipart::Form::new() + .text("log", log_content) + .text("os", std::env::consts::OS) + .text("version", env!("CARGO_PKG_VERSION")); + + let response = app + .api_request("/api/desktop/logs", |client, url| { + client.post(url).multipart(form) + }) + .await + .map_err(|e| format!("Failed to upload logs: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Upload failed with status: {}", response.status())); + } + + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 097261ede..4dae15806 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -134,5 +134,5 @@ fn main() { .enable_all() .build() .expect("Failed to build multi threaded tokio runtime") - .block_on(cap_desktop_lib::run(handle)); + .block_on(cap_desktop_lib::run(handle, logs_dir)); } diff --git a/apps/desktop/src-tauri/src/thumbnails/mod.rs b/apps/desktop/src-tauri/src/thumbnails/mod.rs index 66303520e..c40f67c23 100644 --- a/apps/desktop/src-tauri/src/thumbnails/mod.rs +++ b/apps/desktop/src-tauri/src/thumbnails/mod.rs @@ -102,7 +102,6 @@ pub async fn collect_displays_with_thumbnails() -> Result Result, String> { let windows = list_windows(); - debug!(window_count = windows.len(), "Collecting window thumbnails"); let mut results = Vec::new(); for (capture_window, window) in windows { let thumbnail = capture_window_thumbnail(&window).await; @@ -117,22 +116,6 @@ pub async fn collect_windows_with_thumbnails() -> Result Result for MenuId { TrayItem::PreviousRecordings => "previous_recordings", TrayItem::PreviousScreenshots => "previous_screenshots", TrayItem::OpenSettings => "open_settings", + TrayItem::UploadLogs => "upload_logs", TrayItem::Quit => "quit", } .into() @@ -50,6 +53,7 @@ impl TryFrom for TrayItem { "previous_recordings" => Ok(TrayItem::PreviousRecordings), "previous_screenshots" => Ok(TrayItem::PreviousScreenshots), "open_settings" => Ok(TrayItem::OpenSettings), + "upload_logs" => Ok(TrayItem::UploadLogs), "quit" => Ok(TrayItem::Quit), value => Err(format!("Invalid tray item id {value}")), } @@ -78,6 +82,7 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { )?, &MenuItem::with_id(app, TrayItem::OpenSettings, "Settings", true, None::<&str>)?, &PredefinedMenuItem::separator(app)?, + &MenuItem::with_id(app, TrayItem::UploadLogs, "Upload Logs", true, None::<&str>)?, &MenuItem::with_id( app, "version", @@ -130,6 +135,20 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { async move { ShowCapWindow::Settings { page: None }.show(&app).await }, ); } + Ok(TrayItem::UploadLogs) => { + let app = app.clone(); + tokio::spawn(async move { + match crate::logging::upload_log_file(&app).await { + Ok(_) => { + tracing::info!("Successfully uploaded logs"); + } + Err(e) => { + tracing::error!("Failed to upload logs: {e:#}"); + app.dialog().message("Failed to upload logs").show(|_| {}); + } + } + }); + } Ok(TrayItem::Quit) => { app.exit(0); } diff --git a/apps/desktop/src-tauri/src/web_api.rs b/apps/desktop/src-tauri/src/web_api.rs index 5f18c35ea..73a1aa6b9 100644 --- a/apps/desktop/src-tauri/src/web_api.rs +++ b/apps/desktop/src-tauri/src/web_api.rs @@ -39,6 +39,16 @@ impl From for AuthedApiError { } } +fn apply_env_headers(req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + let mut req = req.header("X-Cap-Desktop-Version", env!("CARGO_PKG_VERSION")); + + if let Ok(s) = std::env::var("VITE_VERCEL_AUTOMATION_BYPASS_SECRET") { + req = req.header("x-vercel-protection-bypass", s); + } + + req +} + async fn do_authed_request( auth: &AuthStore, build: impl FnOnce(reqwest::Client, String) -> reqwest::RequestBuilder, @@ -46,24 +56,18 @@ async fn do_authed_request( ) -> Result { let client = reqwest::Client::new(); - let mut req = build(client, url) - .header( - "Authorization", - format!( - "Bearer {}", - match &auth.secret { - AuthSecret::ApiKey { api_key } => api_key, - AuthSecret::Session { token, .. } => token, - } - ), - ) - .header("X-Cap-Desktop-Version", env!("CARGO_PKG_VERSION")); - - if let Ok(s) = std::env::var("VITE_VERCEL_AUTOMATION_BYPASS_SECRET") { - req = req.header("x-vercel-protection-bypass", s); - } + let req = build(client, url).header( + "Authorization", + format!( + "Bearer {}", + match &auth.secret { + AuthSecret::ApiKey { api_key } => api_key, + AuthSecret::Session { token, .. } => token, + } + ), + ); - req.send().await + apply_env_headers(req).send().await } pub trait ManagerExt: Manager { @@ -73,6 +77,12 @@ pub trait ManagerExt: Manager { build: impl FnOnce(reqwest::Client, String) -> reqwest::RequestBuilder, ) -> Result; + async fn api_request( + &self, + path: impl Into, + build: impl FnOnce(reqwest::Client, String) -> reqwest::RequestBuilder, + ) -> Result; + async fn make_app_url(&self, pathname: impl AsRef) -> String; } @@ -99,6 +109,17 @@ impl + Emitter, R: Runtime> ManagerExt for T { Ok(response) } + async fn api_request( + &self, + path: impl Into, + build: impl FnOnce(reqwest::Client, String) -> reqwest::RequestBuilder, + ) -> Result { + let url = self.make_app_url(path.into()).await; + let client = reqwest::Client::new(); + + apply_env_headers(build(client, url)).send().await + } + async fn make_app_url(&self, pathname: impl AsRef) -> String { let app_state = self.state::>(); let server_url = &app_state.read().await.server_url; diff --git a/apps/web/app/api/desktop/[...route]/root.ts b/apps/web/app/api/desktop/[...route]/root.ts index 2b87779bc..dca8c3420 100644 --- a/apps/web/app/api/desktop/[...route]/root.ts +++ b/apps/web/app/api/desktop/[...route]/root.ts @@ -9,12 +9,69 @@ import { Hono } from "hono"; import { PostHog } from "posthog-node"; import type Stripe from "stripe"; import { z } from "zod"; -import { withAuth } from "../../utils"; +import { withAuth, withOptionalAuth } from "../../utils"; -export const app = new Hono().use(withAuth); +export const app = new Hono(); + +app.post( + "/logs", + zValidator( + "form", + z.object({ + log: z.string(), + os: z.string().optional(), + version: z.string().optional(), + }), + ), + withOptionalAuth, + async (c) => { + const { log, os, version } = c.req.valid("form"); + const user = c.get("user"); + + try { + const discordWebhookUrl = serverEnv().DISCORD_LOGS_WEBHOOK_URL; + if (!discordWebhookUrl) + throw new Error("Discord webhook URL is not configured"); + + const formData = new FormData(); + const logBlob = new Blob([log], { type: "text/plain" }); + const fileName = `cap-desktop-${os || "unknown"}-${version || "unknown"}-${Date.now()}.log`; + formData.append("file", logBlob, fileName); + + const content = [ + "New log file uploaded", + user && `User: ${user.email} (${user.id})`, + os && `OS: ${os}`, + version && `Version: ${version}`, + ] + .filter(Boolean) + .join("\n"); + + formData.append("content", content); + + const response = await fetch(discordWebhookUrl, { + method: "POST", + body: formData, + }); + + if (!response.ok) + throw new Error( + `Failed to send logs to Discord: ${response.statusText}`, + ); + + return c.json({ + success: true, + message: "Logs uploaded successfully", + }); + } catch (error) { + return c.json({ error: "Failed to upload logs" }, { status: 500 }); + } + }, +); app.post( "/feedback", + withAuth, zValidator( "form", z.object({ @@ -60,7 +117,7 @@ app.post( }, ); -app.get("/org-custom-domain", async (c) => { +app.get("/org-custom-domain", withAuth, async (c) => { const user = c.get("user"); try { @@ -92,7 +149,7 @@ app.get("/org-custom-domain", async (c) => { } }); -app.get("/plan", async (c) => { +app.get("/plan", withAuth, async (c) => { const user = c.get("user"); let isSubscribed = userIsPro(user); @@ -138,6 +195,7 @@ app.get("/plan", async (c) => { app.post( "/subscribe", + withAuth, zValidator("json", z.object({ priceId: z.string() })), async (c) => { const { priceId } = c.req.valid("json"); diff --git a/packages/env/server.ts b/packages/env/server.ts index 187c548be..a57b82b7d 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -40,6 +40,7 @@ function createServerEnv() { STRIPE_SECRET_KEY: z.string().optional(), STRIPE_WEBHOOK_SECRET: z.string().optional(), DISCORD_FEEDBACK_WEBHOOK_URL: z.string().optional(), + DISCORD_LOGS_WEBHOOK_URL: z.string().optional(), OPENAI_API_KEY: z.string().optional(), GROQ_API_KEY: z.string().optional(), INTERCOM_SECRET: z.string().optional(),