diff --git a/Cargo.lock b/Cargo.lock index 3753dff03..fb431fc0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -289,6 +289,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atomic_float" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c4b08ed8a30ff7320117c190eb4d73d47f0ac0c930ab853b8224cef7cd9a5e7" + [[package]] name = "autocfg" version = "1.3.0" @@ -522,6 +528,7 @@ dependencies = [ name = "cap" version = "0.0.0" dependencies = [ + "atomic_float", "bytemuck", "byteorder", "bytes", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index ba1124c65..fb5d29b48 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -60,6 +60,7 @@ specta = "=2.0.0-rc.19" tauri-specta = { version = "=2.0.0-rc.14", features = ["derive", "typescript"] } specta-typescript = "0.0.6" dirs = "5.0.1" +atomic_float = "1.0.0" [target.'cfg(windows)'.dependencies] winapi = { version = "0.3.9", features = [ diff --git a/apps/desktop/src-tauri/src/app/commands.rs b/apps/desktop/src-tauri/src/app/commands.rs index e594df746..7e6f295e8 100644 --- a/apps/desktop/src-tauri/src/app/commands.rs +++ b/apps/desktop/src-tauri/src/app/commands.rs @@ -1,6 +1,10 @@ +use std::sync::atomic::Ordering; + use tauri::{Emitter, Manager, Window}; use tauri_plugin_oauth::start; +use crate::{HEALTH_CHECK, UPLOAD_SPEED}; + #[tauri::command] #[specta::specta] pub async fn start_server(window: Window) -> Result { @@ -110,5 +114,49 @@ pub fn make_webview_transparent(app_handle: tauri::AppHandle, label: String) -> } } #[cfg(not(target_os = "macos"))] - "This command is only available on macOS." + { + Err("This command is only available on macOS.".to_string()) + } +} + +#[tauri::command] +#[specta::specta] +pub fn get_health_check_status() -> bool { + let health = HEALTH_CHECK.load(Ordering::Relaxed); + return health; } + +#[tauri::command] +#[specta::specta] +pub fn get_upload_speed() -> f64 { + let upload_speed = UPLOAD_SPEED.load(Ordering::Relaxed); + return upload_speed; +} + +#[cfg(test)] +mod tests { + use super::{get_health_check_status, get_upload_speed, HEALTH_CHECK, UPLOAD_SPEED}; + use std::sync::atomic::Ordering; + + #[test] + fn test_get_health_check_status() { + // example 1 + HEALTH_CHECK.store(true, Ordering::Relaxed); + assert_eq!(get_health_check_status(), true); + + // example 2 + HEALTH_CHECK.store(false, Ordering::Relaxed); + assert_eq!(get_health_check_status(), false); + } + + #[test] + fn test_get_upload_speed() { + // example 1 + UPLOAD_SPEED.store(10.5, Ordering::Relaxed); + assert_eq!(get_upload_speed(), 10.5); + + // example 2 + UPLOAD_SPEED.store(20.7, Ordering::Relaxed); + assert_eq!(get_upload_speed(), 20.7); + } +} \ No newline at end of file diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 91e57d899..455c933af 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,10 +1,15 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use reqwest::multipart::{Form, Part}; use sentry_tracing::EventFilter; use specta_typescript::Typescript; -use std::path::PathBuf; +use std::sync::atomic::Ordering; use std::sync::Arc; +use std::time::Instant; use std::vec; +use std::{path::PathBuf, sync::atomic::AtomicBool}; +use atomic_float::AtomicF64; + use tauri::{ tray::{MouseButton, MouseButtonState}, Emitter, Manager, @@ -34,6 +39,9 @@ use ffmpeg_sidecar::{ use winit::monitor::{MonitorHandle, VideoMode}; +static UPLOAD_SPEED: AtomicF64 = AtomicF64::new(0.0); +static HEALTH_CHECK: AtomicBool = AtomicBool::new(false); + fn main() { let _ = fix_path_env::fix(); @@ -101,6 +109,37 @@ fn main() { Ok(()) } + async fn perform_health_check_and_calculate_upload_speed() -> Result<(), Box> { + let client = reqwest::Client::new(); + let sample_screen_recording = vec![0u8; 1_000_000]; + + let health_check_url_base: &'static str = dotenvy_macro::dotenv!("NEXT_PUBLIC_URL"); + let health_check_url = format!("{}/api/health-check", health_check_url_base); + + let form = Form::new().part( + "file", + Part::bytes(sample_screen_recording.clone()) + .file_name("sample_screen_recording.webm") + .mime_str("video/webm")?, + ); + let start_time = Instant::now(); + let resp = client.post(health_check_url).multipart(form).send().await?; + let time_elapsed = start_time.elapsed(); + + let is_success = resp.status().is_success(); + HEALTH_CHECK.store(is_success, Ordering::Relaxed); + + if is_success { + let upload_speed = (sample_screen_recording.len() as f64 / time_elapsed.as_secs_f64()) / 1250000.0; + UPLOAD_SPEED.store(upload_speed, Ordering::Relaxed); + tracing::debug!("Health check successful. Upload speed: {} Mbps", upload_speed); + } else { + tracing::debug!("Health check failed."); + } + + Ok(()) + } + if let Err(error) = handle_ffmpeg_installation() { tracing::error!(error); // TODO: UI message instead @@ -141,7 +180,9 @@ fn main() { reset_microphone_permissions, reset_camera_permissions, close_webview, - make_webview_transparent + make_webview_transparent, + get_health_check_status, + get_upload_speed ]); #[cfg(debug_assertions)] // <- Only export on non-release builds @@ -156,6 +197,14 @@ fn main() { .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .invoke_handler(specta_builder.invoke_handler()) .setup(move |app| { + tracing::info!("Setting up application..."); + + tauri::async_runtime::spawn(async { + if let Err(error) = perform_health_check_and_calculate_upload_speed().await { + tracing::error!("Health check and upload speed calculation failed: {}", error); + } + }); + let handle = app.handle(); if let Some(main_window) = app.get_webview_window("main") { diff --git a/apps/desktop/src-tauri/src/media/mod.rs b/apps/desktop/src-tauri/src/media/mod.rs index 66ffdae4a..97ce98a63 100644 --- a/apps/desktop/src-tauri/src/media/mod.rs +++ b/apps/desktop/src-tauri/src/media/mod.rs @@ -16,7 +16,7 @@ use tracing::Level; use crate::{ app::config, recording::RecordingOptions, - utils::{create_named_pipe, ffmpeg_path_as_str}, + utils::{create_named_pipe, ffmpeg_path_as_str}, UPLOAD_SPEED, }; mod audio; @@ -108,6 +108,7 @@ impl MediaRecorder { max_screen_width, max_screen_height, self.should_stop.clone(), + VideoCapturer::get_dynamic_resolution(UPLOAD_SPEED.load(Ordering::Relaxed)), ); let adjusted_width = video_capturer.frame_width; let adjusted_height = video_capturer.frame_height; diff --git a/apps/desktop/src-tauri/src/media/video.rs b/apps/desktop/src-tauri/src/media/video.rs index f3419bc5d..d8a041e04 100644 --- a/apps/desktop/src-tauri/src/media/video.rs +++ b/apps/desktop/src-tauri/src/media/video.rs @@ -28,7 +28,12 @@ pub struct VideoCapturer { impl VideoCapturer { pub const FPS: u32 = 30; - pub fn new(_width: usize, _height: usize, should_stop: SharedFlag) -> VideoCapturer { + pub fn new( + _width: usize, + _height: usize, + should_stop: SharedFlag, + resolution: Resolution, + ) -> VideoCapturer { let mut capturer = Capturer::new(Options { fps: Self::FPS, target: None, @@ -36,7 +41,7 @@ impl VideoCapturer { show_highlight: true, excluded_targets: None, output_type: FrameType::BGRAFrame, - output_resolution: Resolution::Captured, + output_resolution: resolution, crop_area: None, }); @@ -229,4 +234,16 @@ impl VideoCapturer { let _ = pipe.sync_all().await; } } -} + + pub fn get_dynamic_resolution(upload_speed: f64) -> Resolution { + match upload_speed { + speed if speed >= 60.0 => Resolution::Captured, + speed if speed >= 50.0 => Resolution::_4320p, + speed if speed >= 25.0 => Resolution::_2160p, + speed if speed >= 15.0 => Resolution::_1440p, + speed if speed >= 8.0 => Resolution::_1080p, + speed if speed >= 5.0 => Resolution::_720p, + _ => Resolution::_480p, + } + } +} \ No newline at end of file diff --git a/apps/desktop/src/app/page.tsx b/apps/desktop/src/app/page.tsx index 1856b413e..2028514cb 100644 --- a/apps/desktop/src/app/page.tsx +++ b/apps/desktop/src/app/page.tsx @@ -14,6 +14,7 @@ import { authFetch } from "@/utils/auth/helpers"; import { commands } from "@/utils/commands"; import { setTrayMenu } from "@/utils/tray"; import { useMediaDevices } from "@/utils/recording/MediaDeviceContext"; +import { UploadSpeed } from "@/components/upload-speed"; export const dynamic = "force-static"; @@ -127,6 +128,7 @@ export default function CameraPage() {
+
); } @@ -149,6 +151,7 @@ export default function CameraPage() { ) : ( )} + ) : ( diff --git a/apps/desktop/src/components/WindowActions.tsx b/apps/desktop/src/components/WindowActions.tsx index b4993654d..47b13ab4d 100644 --- a/apps/desktop/src/components/WindowActions.tsx +++ b/apps/desktop/src/components/WindowActions.tsx @@ -2,6 +2,7 @@ import { Home } from "@/components/icons/Home"; import { openLinkInBrowser } from "@/utils/helpers"; +import { HealthCheckStatus } from "./health"; export const WindowActions = () => { const actionButtonBase = "w-3 h-3 bg-gray-500 rounded-full m-0 p-0 block"; @@ -41,23 +42,26 @@ export const WindowActions = () => { -
- - {/* */} +
+ +
+ + {/* */} +
diff --git a/apps/desktop/src/components/health.tsx b/apps/desktop/src/components/health.tsx new file mode 100644 index 000000000..fc5bdc296 --- /dev/null +++ b/apps/desktop/src/components/health.tsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react'; +import { useHealthCheck } from '../utils/hooks/useHealthCheck'; +import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogFooter } from '@cap/ui'; + +export const HealthCheckStatus: React.FC = () => { + const { isHealthy, message } = useHealthCheck(); + const [showMessage, setShowMessage] = useState(false); + + const handleClick = () => { + setShowMessage(true); + }; + + return ( +
+ + + + +
+ ); +}; \ No newline at end of file diff --git a/apps/desktop/src/components/upload-speed.tsx b/apps/desktop/src/components/upload-speed.tsx new file mode 100644 index 000000000..1ab25419d --- /dev/null +++ b/apps/desktop/src/components/upload-speed.tsx @@ -0,0 +1,74 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react' +import { useUploadSpeed } from '../utils/hooks/useUploadSpeed' +import { Button, Dialog, DialogContent, DialogDescription, DialogTitle, DialogFooter } from '@cap/ui' + +export const UploadSpeed: React.FC = () => { + const { uploadSpeed, message } = useUploadSpeed() + const [showMessage, setShowMessage] = useState(false) + + const status = useMemo(() => { + if (uploadSpeed === null) return { isHealthy: false, isPoor: false, text: 'Fail' } + const isHealthy = uploadSpeed >= 1 + const isPoor = uploadSpeed < 1 + return { + isHealthy, + isPoor, + text: isHealthy ? 'Good' : isPoor ? 'Poor' : 'Fail' + } + }, [uploadSpeed]) + + const statusColor = useMemo(() => { + if (status.isHealthy) return 'text-green-500' + if (status.isPoor) return 'text-yellow-600' + return 'text-red-500' + }, [status.isHealthy, status.isPoor]) + + return ( +
+
setShowMessage(true)} className="flex items-center"> + Upload Speed: + + {uploadSpeed !== null ? `${uploadSpeed.toFixed(2)} Mbps` : '❌'} + + + ({status.text}) + +
+ + + + Upload Speed Status + +
+

{message.text}

+ {!status.isHealthy && ( +
+

+ If you are experiencing poor upload speeds or failed to get it, try the following: +

+
    +
  • Check your internet connection
  • +
  • Close unnecessary applications
  • +
  • Try uploading at a different time
  • +
+

+ If the issue persists, please contact our{' '} + + support team + . +

+
+ )} +
+
+ + + +
+
+
+ ) +} \ No newline at end of file diff --git a/apps/desktop/src/utils/commands.ts b/apps/desktop/src/utils/commands.ts index 9ff7949d4..39d091ef7 100644 --- a/apps/desktop/src/utils/commands.ts +++ b/apps/desktop/src/utils/commands.ts @@ -68,6 +68,12 @@ async makeWebviewTransparent(label: string) : Promise> { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } +}, +async getHealthCheckStatus() : Promise { + return await TAURI_INVOKE("get_health_check_status"); +}, +async getUploadSpeed() : Promise { + return await TAURI_INVOKE("get_upload_speed"); } } diff --git a/apps/desktop/src/utils/hooks/useHealthCheck.ts b/apps/desktop/src/utils/hooks/useHealthCheck.ts new file mode 100644 index 000000000..3fe698c16 --- /dev/null +++ b/apps/desktop/src/utils/hooks/useHealthCheck.ts @@ -0,0 +1,41 @@ + + + + + +import { useState, useEffect } from 'react'; +import { invoke as TAURI_INVOKE } from "@tauri-apps/api/core"; + +export const useHealthCheck = () => { + const [isHealthy, setIsHealthy] = useState(true); + const [message, setMessage] = useState(` + Upload issue detected. Please check your internet connection. + If you still having an issue, please contact our support. + `); + + useEffect(() => { + const checkHealth = async () => { + try { + const health = await TAURI_INVOKE('get_health_check_status'); + setIsHealthy(health as boolean); + + if (!health) { + setMessage(` + Upload issue detected. Please check your internet connection. + `); + } else { + setMessage('Looks good. Proceed with recording and uploading.'); + } + } catch (error) { + console.error('Failed to get health check status:', error); + setIsHealthy(false); + setMessage('Failed to perform health check'); + } + }; + + checkHealth(); + }, []); + + return { isHealthy, message }; +}; + diff --git a/apps/desktop/src/utils/hooks/useUploadSpeed.ts b/apps/desktop/src/utils/hooks/useUploadSpeed.ts new file mode 100644 index 000000000..128b2a5e5 --- /dev/null +++ b/apps/desktop/src/utils/hooks/useUploadSpeed.ts @@ -0,0 +1,40 @@ +import { useState, useEffect } from 'react'; +import { invoke as TAURI_INVOKE } from "@tauri-apps/api/core"; + +export const useUploadSpeed = () => { + const [uploadSpeed, setUploadSpeed] = useState(null); + const [message, setMessage] = useState({ text: 'Checking upload speed...', color: 'black' }); + + useEffect(() => { + const getUploadSpeed = async () => { + try { + const speed = await TAURI_INVOKE('get_upload_speed'); + setUploadSpeed(speed); + + if (speed < 1) { + setMessage({ + text: `Upload Speed: ${speed.toFixed(2)} Mbps. + Slow upload speed detected. This may affect your ability to upload files.`, + color: 'red' + }); + } else { + setMessage({ + text: `Upload speed is Good (${speed.toFixed(2)} Mbps).`, + color: 'green' + }); + } + } catch (error) { + console.error('Failed to get upload speed:', error); + setUploadSpeed(null); + setMessage({ + text: 'Failed to measure upload speed', + color: 'red' + }); + } + }; + + getUploadSpeed(); + }, []); + + return { uploadSpeed, message }; +}; diff --git a/apps/desktop/src/utils/recording/utils.ts b/apps/desktop/src/utils/recording/utils.ts index 78e0da91d..ba33a9f0e 100644 --- a/apps/desktop/src/utils/recording/utils.ts +++ b/apps/desktop/src/utils/recording/utils.ts @@ -86,7 +86,7 @@ export const initializeCameraWindow = async () => { shadow: false, focus: false, }).once("tauri://window-created", () => { - commands.makeWebviewTransparent("camera"); + //commands.makeWebviewTransparent("camera"); }); } } diff --git a/apps/web/app/api/health-check/route.ts b/apps/web/app/api/health-check/route.ts new file mode 100644 index 000000000..b2d8f058f --- /dev/null +++ b/apps/web/app/api/health-check/route.ts @@ -0,0 +1,4 @@ +import { NextRequest } from 'next/server'; +export async function POST(req: NextRequest) { + return Response.json({ message: "upload success" }); +} \ No newline at end of file diff --git a/package.json b/package.json index 70165af99..bdbd24f79 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "scripts": { "build": "dotenv -e .env -- turbo run build", - "dev": "(cd apps/web && docker-compose up -d && cd ../..) && dotenv -e .env -- pnpm --dir packages/database db:generate && dotenv -e .env -- pnpm --dir packages/database db:push && dotenv -e .env -- turbo run dev --no-cache --concurrency 11", + "dev": "(cd apps/web && sudo ./../../docker-compose up -d && cd ../..) && dotenv -e .env -- pnpm --dir packages/database db:generate && dotenv -e .env -- pnpm --dir packages/database db:push && dotenv -e .env -- turbo run dev --no-cache --concurrency 11", "dev:manual": "dotenv -e .env -- turbo run dev --no-cache --concurrency 1", "lint": "turbo run lint", "format": "prettier --write \"**/*.{ts,tsx,md}\"",