diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index feeb386635..e2cbe9aca7 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -149,6 +149,8 @@ pub struct GeneralSettingsStore { pub camera_window_position: Option, #[serde(default)] pub camera_window_positions_by_monitor_name: BTreeMap, + #[serde(default = "default_true")] + pub has_completed_onboarding: bool, } fn default_enable_native_camera_preview() -> bool { @@ -229,6 +231,7 @@ impl Default for GeneralSettingsStore { main_window_position: None, camera_window_position: None, camera_window_positions_by_monitor_name: BTreeMap::new(), + has_completed_onboarding: false, } } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index a2138e14c0..8660425ab9 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -638,6 +638,15 @@ async fn set_camera_input( drop(app); if id == current_id && camera_in_use { + if id.is_some() && !skip_camera_window.unwrap_or(false) { + let show_result = ShowCapWindow::Camera { centered: false } + .show(&app_handle) + .await; + show_result + .map_err(|err| error!("Failed to show camera preview window: {err}")) + .ok(); + } + return Ok(()); } @@ -2693,16 +2702,19 @@ async fn reset_camera_permissions(_app: AppHandle) -> Result<(), String> { #[tauri::command] #[specta::specta] -#[instrument(skip(app))] -async fn reset_microphone_permissions(app: AppHandle) -> Result<(), ()> { - let bundle_id = app.config().identifier.clone(); +#[instrument(skip(_app))] +async fn reset_microphone_permissions(_app: AppHandle) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let bundle_id = _app.config().identifier.clone(); - Command::new("tccutil") - .arg("reset") - .arg("Microphone") - .arg(bundle_id) - .output() - .expect("Failed to reset microphone permissions"); + Command::new("tccutil") + .arg("reset") + .arg("Microphone") + .arg(bundle_id) + .output() + .map_err(|_| "Failed to reset microphone permissions".to_string())?; + } Ok(()) } @@ -3336,7 +3348,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { flags }) .with_denylist(&[ - CapWindowId::Setup.label().as_str(), + CapWindowId::Onboarding.label().as_str(), CapWindowId::Main.label().as_str(), "window-capture-occluder", "target-select-overlay", @@ -3501,18 +3513,24 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { tokio::spawn({ let app = app.clone(); async move { - if !permissions.screen_recording.permitted() - || !permissions.accessibility.permitted() - || GeneralSettingsStore::get(&app) - .ok() - .flatten() - .map(|s| !s.has_completed_startup) - .unwrap_or(false) + let settings = GeneralSettingsStore::get(&app).ok().flatten(); + let startup_completed = settings + .as_ref() + .map(|s| s.has_completed_startup) + .unwrap_or(false); + let onboarding_completed = settings + .as_ref() + .map(|s| s.has_completed_onboarding) + .unwrap_or(false); + + if !startup_completed + || !onboarding_completed + || !permissions.necessary_granted() { - let _ = ShowCapWindow::Setup.show(&app).await; + println!("Showing onboarding"); + let _ = ShowCapWindow::Onboarding.show(&app).await; } else { - println!("Permissions granted, showing main window"); - + println!("Showing main window"); let _ = ShowCapWindow::Main { init_target_mode: None, } @@ -3864,12 +3882,18 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { .run(move |_handle, event| match event { #[cfg(target_os = "macos")] tauri::RunEvent::Reopen { .. } => { + if let Some(onboarding) = CapWindowId::Onboarding.get(_handle) { + onboarding.show().ok(); + onboarding.set_focus().ok(); + return; + } + let has_window = _handle.webview_windows().iter().any(|(label, _)| { label.starts_with("editor-") || label.starts_with("screenshot-editor-") || label.as_str() == "settings" || label.as_str() == "signin" - || label.as_str() == "setup" + || label.as_str() == "onboarding" }); if has_window { @@ -3881,7 +3905,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { || label.starts_with("screenshot-editor-") || label.as_str() == "settings" || label.as_str() == "signin" - || label.as_str() == "setup" + || label.as_str() == "onboarding" }) .map(|(_, window)| window.clone()) { diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index e3d1687340..cd8dee0733 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -330,8 +330,8 @@ fn get_current_mode(app: &AppHandle) -> RecordingMode { .unwrap_or_default() } -fn is_setup_window_open(app: &AppHandle) -> bool { - app.webview_windows().contains_key("setup") +fn is_onboarding_window_open(app: &AppHandle) -> bool { + app.webview_windows().contains_key("onboarding") } fn create_mode_submenu(app: &AppHandle) -> tauri::Result> { @@ -365,7 +365,7 @@ fn create_mode_submenu(app: &AppHandle) -> tauri::Result> { } fn build_tray_menu(app: &AppHandle, cache: &PreviousItemsCache) -> tauri::Result> { - if is_setup_window_open(app) { + if is_onboarding_window_open(app) { return Menu::with_items( app, &[ @@ -809,7 +809,7 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { Ok(TrayItem::RequestPermissions) => { let app = app.clone(); tokio::spawn(async move { - let _ = ShowCapWindow::Setup.show(&app).await; + let _ = ShowCapWindow::Onboarding.show(&app).await; }); } _ => {} diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 5e0b2beed9..0ed6d3e800 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -379,8 +379,6 @@ fn is_position_on_any_screen(pos_x: f64, pos_y: f64) -> bool { #[derive(Clone, Deserialize, Type)] pub enum CapWindowId { - // Contains onboarding + permissions - Setup, Main, Settings, Editor { id: u32 }, @@ -394,6 +392,7 @@ pub enum CapWindowId { ModeSelect, Debug, ScreenshotEditor { id: u32 }, + Onboarding, } impl FromStr for CapWindowId { @@ -401,7 +400,6 @@ impl FromStr for CapWindowId { fn from_str(s: &str) -> Result { Ok(match s { - "setup" => Self::Setup, "main" => Self::Main, "settings" => Self::Settings, "camera" => Self::Camera, @@ -412,6 +410,7 @@ impl FromStr for CapWindowId { "upgrade" => Self::Upgrade, "mode-select" => Self::ModeSelect, "debug" => Self::Debug, + "onboarding" => Self::Onboarding, s if s.starts_with("editor-") => Self::Editor { id: s .replace("editor-", "") @@ -444,7 +443,6 @@ impl FromStr for CapWindowId { impl std::fmt::Display for CapWindowId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Setup => write!(f, "setup"), Self::Main => write!(f, "main"), Self::Settings => write!(f, "settings"), Self::Camera => write!(f, "camera"), @@ -462,6 +460,7 @@ impl std::fmt::Display for CapWindowId { Self::Editor { id } => write!(f, "editor-{id}"), Self::Debug => write!(f, "debug"), Self::ScreenshotEditor { id } => write!(f, "screenshot-editor-{id}"), + Self::Onboarding => write!(f, "onboarding"), } } } @@ -473,7 +472,6 @@ impl CapWindowId { pub fn title(&self) -> String { match self { - Self::Setup => "Cap Setup".to_string(), Self::Settings => "Cap Settings".to_string(), Self::WindowCaptureOccluder { .. } => "Cap Window Capture Occluder".to_string(), Self::CaptureArea => "Cap Capture Area".to_string(), @@ -481,6 +479,7 @@ impl CapWindowId { Self::Editor { .. } => "Cap Editor".to_string(), Self::ScreenshotEditor { .. } => "Cap Screenshot Editor".to_string(), Self::ModeSelect => "Cap Mode Selection".to_string(), + Self::Onboarding => "Welcome to Cap".to_string(), Self::Camera => "Cap Camera".to_string(), Self::RecordingsOverlay => "Cap Recordings Overlay".to_string(), Self::TargetSelectOverlay { .. } => "Cap Target Select".to_string(), @@ -491,13 +490,13 @@ impl CapWindowId { pub fn activates_dock(&self) -> bool { matches!( self, - Self::Setup - | Self::Main + Self::Main | Self::Editor { .. } | Self::ScreenshotEditor { .. } | Self::Settings | Self::Upgrade | Self::ModeSelect + | Self::Onboarding ) } @@ -538,7 +537,6 @@ impl CapWindowId { pub fn min_size(&self) -> Option<(f64, f64)> { Some(match self { - Self::Setup => (600.0, 600.0), Self::Main => (330.0, 395.0), Self::Editor { .. } => (1275.0, 800.0), Self::ScreenshotEditor { .. } => (800.0, 600.0), @@ -546,6 +544,7 @@ impl CapWindowId { Self::Camera => (200.0, 200.0), Self::Upgrade => (950.0, 850.0), Self::ModeSelect => (580.0, 340.0), + Self::Onboarding => (860.0, 680.0), _ => return None, }) } @@ -553,7 +552,6 @@ impl CapWindowId { #[derive(Debug, Clone, Type, Deserialize)] pub enum ShowCapWindow { - Setup, Main { init_target_mode: Option, }, @@ -585,6 +583,7 @@ pub enum ShowCapWindow { ScreenshotEditor { path: PathBuf, }, + Onboarding, } impl ShowCapWindow { @@ -923,6 +922,12 @@ impl ShowCapWindow { if !matches!(self, Self::Camera { .. } | Self::InProgressRecording { .. }) && let Some(window) = self.id(app).get(app) { + if matches!(self, Self::Main { .. }) + && !permissions::do_permissions_check(false).necessary_granted() + { + return Box::pin(Self::Onboarding.show(app)).await; + } + let cursor_display_id = if let Self::Main { init_target_mode } = self { if init_target_mode.is_some() { Display::get_containing_cursor() @@ -966,37 +971,9 @@ impl ShowCapWindow { let cursor_monitor = CursorMonitorInfo::get(); let window = match self { - Self::Setup => { - let window = self - .window_builder(app, "/setup") - .inner_size(600.0, 600.0) - .min_inner_size(600.0, 600.0) - .resizable(false) - .maximized(false) - .focused(true) - .maximizable(false) - .shadow(true) - .build()?; - - let (pos_x, pos_y) = cursor_monitor.center_position(600.0, 600.0); - let _ = window.set_position(tauri::LogicalPosition::new(pos_x, pos_y)); - - #[cfg(windows)] - { - use tauri::LogicalSize; - if let Err(e) = window.set_size(LogicalSize::new(600.0, 600.0)) { - warn!("Failed to set Setup window size on Windows: {}", e); - } - if let Err(e) = window.set_position(tauri::LogicalPosition::new(pos_x, pos_y)) { - warn!("Failed to position Setup window on Windows: {}", e); - } - } - - window - } Self::Main { init_target_mode } => { if !permissions::do_permissions_check(false).necessary_granted() { - return Box::pin(Self::Setup.show(app)).await; + return Box::pin(Self::Onboarding.show(app)).await; } let title = CapWindowId::Main.title(); @@ -1277,6 +1254,7 @@ impl ShowCapWindow { .min_inner_size(800.0, 580.0) .resizable(true) .maximized(false) + .focused(true) .build()?; let (pos_x, pos_y) = cursor_monitor.center_position(800.0, 580.0); @@ -1293,6 +1271,9 @@ impl ShowCapWindow { } } + window.show().ok(); + window.set_focus().ok(); + window } Self::Editor { .. } => { @@ -1361,6 +1342,9 @@ impl ShowCapWindow { } } + window.show().ok(); + window.set_focus().ok(); + window } Self::Upgrade => { @@ -1393,6 +1377,9 @@ impl ShowCapWindow { } } + window.show().ok(); + window.set_focus().ok(); + window } Self::ModeSelect => { @@ -1425,6 +1412,47 @@ impl ShowCapWindow { } } + window.show().ok(); + window.set_focus().ok(); + + window + } + Self::Onboarding => { + if let Some(main) = CapWindowId::Main.get(app) { + let _ = main.hide(); + } + + let width = (cursor_monitor.width * 0.58).clamp(860.0, 1080.0); + let height = (width * 0.72).clamp(680.0, 780.0); + + let window = self + .window_builder(app, "/onboarding") + .inner_size(width, height) + .min_inner_size(860.0, 680.0) + .resizable(false) + .maximized(false) + .maximizable(false) + .focused(true) + .shadow(true) + .build()?; + + let (pos_x, pos_y) = cursor_monitor.center_position(width, height); + let _ = window.set_position(tauri::LogicalPosition::new(pos_x, pos_y)); + + #[cfg(windows)] + { + use tauri::LogicalSize; + if let Err(e) = window.set_size(LogicalSize::new(width, height)) { + warn!("Failed to set Onboarding window size on Windows: {}", e); + } + if let Err(e) = window.set_position(tauri::LogicalPosition::new(pos_x, pos_y)) { + warn!("Failed to position Onboarding window on Windows: {}", e); + } + } + + window.show().ok(); + window.set_focus().ok(); + window } Self::Camera { centered } => { @@ -2094,7 +2122,6 @@ impl ShowCapWindow { pub fn id(&self, app: &AppHandle) -> CapWindowId { match self { - ShowCapWindow::Setup => CapWindowId::Setup, ShowCapWindow::Main { .. } => CapWindowId::Main, ShowCapWindow::Settings { .. } => CapWindowId::Settings, ShowCapWindow::Editor { project_path } => { @@ -2119,6 +2146,7 @@ impl ShowCapWindow { ShowCapWindow::InProgressRecording { .. } => CapWindowId::RecordingControls, ShowCapWindow::Upgrade => CapWindowId::Upgrade, ShowCapWindow::ModeSelect => CapWindowId::ModeSelect, + ShowCapWindow::Onboarding => CapWindowId::Onboarding, ShowCapWindow::ScreenshotEditor { path } => { let state = app.state::(); let s = state.ids.lock().unwrap(); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 5fef765e3e..b021e7cbf0 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -20,7 +20,6 @@ import titlebar from "./utils/titlebar-state"; const WindowChromeLayout = lazy(() => import("./routes/(window-chrome)")); const NewMainPage = lazy(() => import("./routes/(window-chrome)/new-main")); -const SetupPage = lazy(() => import("./routes/(window-chrome)/setup")); const SettingsLayout = lazy(() => import("./routes/(window-chrome)/settings")); const SettingsGeneralPage = lazy( () => import("./routes/(window-chrome)/settings/general"), @@ -55,6 +54,9 @@ const SettingsIntegrationsPage = lazy( const SettingsS3ConfigPage = lazy( () => import("./routes/(window-chrome)/settings/integrations/s3-config"), ); +const OnboardingPage = lazy( + () => import("./routes/(window-chrome)/onboarding"), +); const UpgradePage = lazy(() => import("./routes/(window-chrome)/upgrade")); const UpdatePage = lazy(() => import("./routes/(window-chrome)/update")); const CameraPage = lazy(() => import("./routes/camera")); @@ -145,7 +147,6 @@ function Inner() { > - @@ -172,6 +173,7 @@ function Inner() { component={SettingsS3ConfigPage} /> + diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index bfd7e51126..d6f7ea983a 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -72,6 +72,7 @@ import IconCapSettings from "~icons/cap/settings"; import IconLucideAppWindowMac from "~icons/lucide/app-window-mac"; import IconLucideArrowLeft from "~icons/lucide/arrow-left"; import IconLucideBug from "~icons/lucide/bug"; +import IconLucideCircleHelp from "~icons/lucide/circle-help"; import IconLucideImage from "~icons/lucide/image"; import IconLucideImport from "~icons/lucide/import"; import IconLucideSearch from "~icons/lucide/search"; @@ -1743,6 +1744,17 @@ function Page() { + Help & Tour}> + + {import.meta.env.DEV && ( + + +
+ + {(_, index) => ( +
index() + ? "w-1.5 h-1.5 bg-gray-8" + : "w-1.5 h-1.5 bg-gray-5", + )} + /> + )} + +
+
+
+ + + + +
+
+
+ + Press Enter ↵ or use ← → arrow keys + + + ); +} + +function StepPanel(props: { + active: boolean; + index: number; + currentStep: number; + children: JSX.Element; +}) { + return ( +
+ {props.children} +
+ ); +} + +function ModesOverviewStep(props: { active: boolean }) { + const [visible, setVisible] = createSignal(false); + + createEffect(() => { + if (props.active) { + setVisible(false); + const t = setTimeout(() => setVisible(true), 100); + onCleanup(() => clearTimeout(t)); + } else { + setVisible(false); + } + }); + + return ( +
+
+

+ One app, every workflow +

+

+ Whether you need speed, studio quality, or a quick screenshot — Cap + has a mode for it. +

+
+ +
+ + {(mode, index) => ( +
+
+ +
+
+
+ {mode.title} +
+
+ {mode.tagline} +
+
+
+ )} +
+
+
+ ); +} + +function ModeDetailStep(props: { + mode: ModeDetail; + active: boolean; + children: JSX.Element; +}) { + const [visible, setVisible] = createSignal(false); + + createEffect(() => { + if (props.active) { + setVisible(false); + const t = setTimeout(() => setVisible(true), 80); + onCleanup(() => clearTimeout(t)); + } else { + setVisible(false); + } + }); + + return ( +
+
+
+
+
+ +
+
+

{props.mode.title}

+

+ {props.mode.tagline} +

+
+
+ +

+ {props.mode.description} +

+ +
+ + {(feature, index) => ( +
+
+ +
+ {feature} +
+ )} +
+
+
+
+ +
+
+ {props.children} +
+
+
+ ); +} + +function ToggleStep(props: { active: boolean }) { + const [visible, setVisible] = createSignal(false); + const [activeMode, setActiveMode] = createSignal(0); + const [userClicked, setUserClicked] = createSignal(false); + + const CIRCLE = 80; + const GAP = 24; + const PAD = 16; + + createEffect(() => { + if (props.active) { + setVisible(false); + setActiveMode(0); + setUserClicked(false); + const t = setTimeout(() => setVisible(true), 100); + const interval = setInterval(() => { + if (!userClicked()) setActiveMode((prev) => (prev + 1) % 3); + }, 2500); + onCleanup(() => { + clearTimeout(t); + clearInterval(interval); + }); + } else { + setVisible(false); + } + }); + + const ringLeft = () => PAD + activeMode() * (CIRCLE + GAP); + + const handleModeClick = (index: number) => { + setUserClicked(true); + setActiveMode(index); + commands.setRecordingMode(modes[index].id); + }; + + return ( +
+
+

+ Switch modes anytime +

+

+ Toggle between modes with a single click from the main Cap window. +

+
+ +
+
+
+
+
+ + {(mode, index) => ( +
handleModeClick(index())} + > + +
+ )} +
+
+
+ +
+ + {(mode, index) => ( + handleModeClick(index())} + > + {mode.title} + + )} + +
+
+
+ ); +} + +function ShortcutsStep(props: { active: boolean }) { + const [visible, setVisible] = createSignal(false); + + createEffect(() => { + if (props.active) { + setVisible(false); + const t = setTimeout(() => setVisible(true), 100); + onCleanup(() => clearTimeout(t)); + } else { + setVisible(false); + } + }); + + const settingsAreas = [ + { + title: "Keyboard Shortcuts", + desc: "Global hotkeys for recording, screenshots, and switching modes", + }, + { + title: "Custom S3 Storage", + desc: "Connect your own S3-compatible bucket for full control over your recordings", + }, + { + title: "Custom Domain", + desc: "Use your own domain for shareable links instead of cap.link", + }, + { + title: "Recording Preferences", + desc: "FPS, quality, countdown timer, cursor effects, and more", + }, + ]; + + return ( +
+
+
+ +
+

+ Make Cap yours +

+

+ Customize everything from keyboard shortcuts to storage. Cap adapts to + your workflow. +

+
+ +
+ + {(area, index) => ( +
+ + {area.title} + + + {area.desc} + +
+ )} +
+
+ +

+ Change any of these at any time in Settings +

+
+ ); +} + +function FaqStep(props: { active: boolean }) { + const [visible, setVisible] = createSignal(false); + + createEffect(() => { + if (props.active) { + setVisible(false); + const t = setTimeout(() => setVisible(true), 100); + onCleanup(() => clearTimeout(t)); + } else { + setVisible(false); + } + }); + + return ( +
+
+

+ Frequently Asked Questions +

+

+ Everything you need to know to get started. +

+
+ +
+ +

+ Cap is free for personal use. For teams and commercial use, check + out our{" "} + + . +

+
+ +

+ Instant mode uploads as you record — stop recording and you'll have + a shareable link immediately. Studio mode records locally in full + quality, letting you edit with backgrounds, effects, and more before + sharing. +

+
+ +

+ All recordings are stored locally on your computer. In Instant mode, + they're also uploaded to Cap's cloud for easy sharing. You can + manage storage in Settings. +

+
+ +

+ Head to Settings → Shortcuts at any time to customize all your + keyboard shortcuts. +

+
+ +

+ In Instant mode, you get a shareable link automatically when you + stop recording. In Studio mode, export your edited video and share + via Cap's cloud or save locally. +

+
+
+ + +
+ ); +} + +function FaqItem(props: { question: string; children: JSX.Element }) { + const [open, setOpen] = createSignal(false); + + return ( +
+ +
+
{props.children}
+
+
+ ); +} + +function MockupStepBar(props: { steps: string[]; activeStep: number }) { + return ( +
+ + {(label, index) => ( + <> + 0}> +
+ +
index() + ? "text-gray-10 bg-white dark:bg-gray-3 border border-gray-4" + : "text-gray-8 border border-transparent", + )} + > + {index() + 1} + {label} +
+ + )} + +
+ ); +} + +function StartRecordingClickMock(props: { + active: boolean; + mode: "instant" | "studio"; +}) { + const [cursorStage, setCursorStage] = createSignal(0); + + const cursorMoveMs = 1450; + + createEffect(() => { + if (!props.active) { + setCursorStage(0); + return; + } + setCursorStage(0); + const settleFrameMs = 40; + const pauseAfterArriveMs = 280; + const t1 = setTimeout(() => setCursorStage(1), settleFrameMs); + const t2 = setTimeout( + () => setCursorStage(2), + settleFrameMs + cursorMoveMs + pauseAfterArriveMs, + ); + onCleanup(() => { + clearTimeout(t1); + clearTimeout(t2); + }); + }); + + const modeLabel = () => + props.mode === "studio" ? "Studio Mode" : "Instant Mode"; + + const cursorW = () => (ostype() === "windows" ? 24 : 22); + const cursorH = () => (ostype() === "windows" ? 34 : 32); + + return ( +
+
+
+
+ } + > + + +
+ + Start Recording + + + {modeLabel()} + +
+
+
+ +
+
+
+
+ } + > + + +
+
+
+
+ ); +} + +function RecordingBar(props: { + time: string; + stopped?: boolean; + class?: string; +}) { + const actionIconWrap = + "h-8 w-8 flex shrink-0 items-center justify-center rounded-lg p-[0.25rem] text-gray-11"; + + return ( +
+
+
+
+ +
+ Stopped +
+ } + > + +
+
+
+ +
+
+
+
+ + + + +
+
+
+ +
+
+ ); +} + +function InstantMockup(props: { active: boolean }) { + const phase = createLoopingPhase( + () => props.active, + [300, 2350, 3350, 4350, 5350, 6350, 7350, 8350], + 9550, + ); + + const activeStep = () => { + const p = phase(); + if (p <= 5) return 0; + if (p <= 6) return 1; + return 2; + }; + + const recordingTime = () => { + const p = phase(); + if (p >= 5) return "0:03"; + if (p >= 4) return "0:02"; + if (p >= 3) return "0:01"; + if (p >= 2) return "0:00"; + return "0:00"; + }; + + return ( +
+ +
+
= 1 && phase() < 7 + ? "opacity-100" + : "pointer-events-none opacity-0", + )} + > +
+
+ +
+
= 2 && phase() < 7 + ? "relative z-[2] translate-y-0 scale-100 opacity-100" + : "pointer-events-none absolute inset-0 z-[1] flex items-center justify-center opacity-0 scale-[0.94] translate-y-3", + )} + > +
+ = 6} /> +
+
+
+
+
= 7 + ? "opacity-100 translate-y-0 scale-100" + : "opacity-0 translate-y-4 scale-[0.97] pointer-events-none", + )} + > +
+
+
+
+ +
+ + Link ready to share! + +
+
+
+ + cap.link/m4k92x + +
+
= 8 + ? "bg-green-50 border-green-200 text-green-700 scale-95" + : "bg-white dark:bg-gray-3 border-gray-5 text-gray-11", + )} + > + = 8} + fallback={ + <> + + Copy + + } + > + + Copied! + +
+
+
+
+
+
+
+ ); +} + +function StudioMockup(props: { active: boolean }) { + const phase = createLoopingPhase( + () => props.active, + [300, 2350, 3350, 4350, 5350, 6350, 7350, 8150, 9150, 10150, 11150], + 12250, + ); + + const activeStep = () => { + const p = phase(); + if (p < 7) return 0; + if (p < 9) return 1; + return 2; + }; + + const showRecording = () => phase() < 7; + const showEditor = () => phase() >= 7; + const showExporting = () => phase() >= 9; + + const studioRecordingTime = () => { + const p = phase(); + if (p >= 5) return "0:03"; + if (p >= 4) return "0:02"; + if (p >= 3) return "0:01"; + if (p >= 2) return "0:00"; + return "0:00"; + }; + + const exportPercent = () => { + const p = phase(); + if (p >= 11) return 100; + if (p >= 10) return 75; + if (p >= 9) return 25; + return 0; + }; + + return ( +
+ +
+
+
+
+ +
+
= 2 && phase() < 7 + ? "relative z-[2] translate-y-0 scale-100 opacity-100" + : "pointer-events-none absolute inset-0 z-[1] flex items-center justify-center opacity-0 scale-[0.94] translate-y-3", + )} + > +
+ = 6} + /> +
+
+
+
+ +
+
+
+
+
+
+
+
+ + Cap Editor + +
+
= 8 + ? "scale-105 ring-2 ring-blue-9/50 ring-offset-2 ring-offset-white dark:ring-offset-gray-1" + : "scale-100 ring-0 ring-offset-0", + )} + > + Export +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ Style +
+
+
+ Background +
+
+
+
+
+
+
+ + +
+
+ +
+ +
+ + Export complete! + +
+ } + > + + Exporting... + + +
+
= 11 ? "800ms" : "600ms", + }} + /> +
+ + {exportPercent()}% + +
+
+
+
+ +
+
+
+
+
+ + 0:12 + +
+
+
+
+
+ ); +} + +function StartupOverlay(props: { + isExiting: boolean; + onGetStarted: () => void; +}) { + const [audioState, setAudioState] = makePersisted( + createStore({ isMuted: false }), + { name: "audioSettings" }, + ); + + let audioEl: HTMLAudioElement | undefined; + let cloud1Animation: Animation | undefined; + let cloud2Animation: Animation | undefined; + let cloud3Animation: Animation | undefined; + + const [isLogoAnimating, setIsLogoAnimating] = createSignal(false); + + const handleLogoClick = () => { + if (!isLogoAnimating()) { + setIsLogoAnimating(true); + setTimeout(() => setIsLogoAnimating(false), 1000); + } + }; + + const bindCloud1 = (el: HTMLDivElement | null) => { + cloud1Animation?.cancel(); + cloud1Animation = undefined; + if (!el) return; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + cloud1Animation = el.animate( + [ + { transform: "translate(0, 0)" }, + { transform: "translate(-20px, 10px)" }, + { transform: "translate(0, 0)" }, + ], + { duration: 30000, iterations: Infinity, easing: "linear" }, + ); + }); + }); + }; + + const bindCloud2 = (el: HTMLDivElement | null) => { + cloud2Animation?.cancel(); + cloud2Animation = undefined; + if (!el) return; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + cloud2Animation = el.animate( + [ + { transform: "translate(0, 0)" }, + { transform: "translate(20px, 10px)" }, + { transform: "translate(0, 0)" }, + ], + { duration: 35000, iterations: Infinity, easing: "linear" }, + ); + }); + }); + }; + + const bindCloud3Inner = (el: HTMLDivElement | null) => { + cloud3Animation?.cancel(); + cloud3Animation = undefined; + if (!el) return; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + cloud3Animation = el.animate( + [ + { transform: "translate(0, 20px)" }, + { transform: "translate(2%, 0)" }, + { transform: "translate(0, 0)" }, + ], + { + duration: 60000, + iterations: 1, + easing: "cubic-bezier(0.4, 0, 0.2, 1)", + fill: "forwards", + }, + ); + }); + }); + }; + + onMount(() => { + audioEl = new Audio(startupAudio); + audioEl.preload = "auto"; + audioEl.loop = false; + audioEl.muted = audioState.isMuted; + + const tryPlay = () => { + if (!audioEl || audioEl.muted) return; + void audioEl.play().catch(() => {}); + }; + + tryPlay(); + const resumeAudio = () => tryPlay(); + window.addEventListener("pointerdown", resumeAudio, { passive: true }); + + onCleanup(() => { + window.removeEventListener("pointerdown", resumeAudio); + cloud1Animation?.cancel(); + cloud2Animation?.cancel(); + cloud3Animation?.cancel(); + audioEl?.pause(); + audioEl = undefined; + }); + }); + + const toggleMute = () => { + const next = !audioState.isMuted; + setAudioState("isMuted", next); + if (audioEl) { + audioEl.muted = next; + if (!next) void audioEl.play().catch(() => {}); + } + }; + + const handleGetStarted = () => { + cloud1Animation?.cancel(); + cloud2Animation?.cancel(); + cloud3Animation?.cancel(); + props.onGetStarted(); + }; + + createEffect(() => { + const exiting = props.isExiting; + const onKeyDown = (e: KeyboardEvent) => { + if (exiting) return; + if (e.key !== " " && e.code !== "Space") return; + e.preventDefault(); + handleGetStarted(); + }; + window.addEventListener("keydown", onKeyDown); + onCleanup(() => window.removeEventListener("keydown", onKeyDown)); + }); + + return ( +
+
+ +
+ +
+ +
+ +
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+

+ Welcome to Cap +

+

+ Beautiful screen recordings, owned by you. +

+
+ + +
+
+ ); +} + +function PermissionsStep(props: { + active: boolean; + onPermissionsChanged: (allRequired: boolean) => void; +}) { + const [visible, setVisible] = createSignal(false); + const [initialCheck, setInitialCheck] = createSignal(true); + const [check, setCheck] = createSignal< + Record | undefined + >(undefined); + + const fetchPermissions = async () => { + const result = await commands.doPermissionsCheck(initialCheck()); + setCheck(result as unknown as Record); + }; + + onMount(() => { + fetchPermissions(); + }); + + createEffect(() => { + if (props.active) { + setVisible(false); + const t = setTimeout(() => setVisible(true), 100); + onCleanup(() => clearTimeout(t)); + } else { + setVisible(false); + } + }); + + createEffect(() => { + if (props.active && !initialCheck()) { + const interval = setInterval(fetchPermissions, 250); + onCleanup(() => clearInterval(interval)); + } + }); + + createEffect(() => { + const c = check(); + if (!c) return; + const allRequired = setupPermissions + .filter((p) => !p.optional) + .every((p) => isPermitted(c[p.key])); + props.onPermissionsChanged(allRequired); + }); + + const requestPermission = async (permission: OSPermission) => { + try { + await commands.requestPermission(permission); + } catch (err) { + console.error(`Error requesting permission: ${err}`); + } + setInitialCheck(false); + fetchPermissions(); + }; + + const openSettings = async (permission: OSPermission) => { + await commands.openPermissionSettings(permission); + if (permission === "screenRecording") { + const shouldRestart = await ask( + "After adding Cap in System Settings, you'll need to restart the app for the permission to take effect.", + { + title: "Restart Required", + kind: "info", + okLabel: "Restart, I've granted permission", + cancelLabel: "No, I still need to add it", + }, + ); + if (shouldRestart) { + await relaunch(); + } + } + setInitialCheck(false); + fetchPermissions(); + }; + + return ( +
+
+
+ +
+

+ Permissions Required +

+

+ Cap needs a few permissions to record your screen and capture audio. +

+
+ +
+ + {(permission, index) => { + const permStatus = () => + check()?.[permission.key] as OSPermissionStatus | undefined; + + return ( + +
+
+
+ + {permission.name} + + + + Optional + + +
+ + {permission.description} + +
+ + + Granted +
+ } + > + +
+
+ + ); + }} + +
+
+ ); +} + +function ScreenshotMockup(props: { active: boolean }) { + const phase = createLoopingPhase( + () => props.active, + [200, 700, 1400, 2600, 3400, 3900, 4900, 5900, 6700], + 8000, + ); + + const activeStep = () => { + const p = phase(); + if (p <= 5) return 0; + if (p <= 8) return 1; + return 2; + }; + + const showEditor = () => phase() >= 6; + + return ( +
+ +
+
+
= 1 + ? "opacity-100 translate-y-0 scale-100" + : "opacity-0 translate-y-4 scale-95", + )} + > +
+
+
+
+
+
+
+
+
+ +
= 2 ? "bg-black/45" : "bg-transparent", + )} + /> + + = 2 && phase() < 6}> +
= 3 ? "calc(88% - 4px)" : "12%", + left: phase() >= 3 ? "calc(90% - 4px)" : "8%", + width: `${ostype() === "windows" ? 24 : 22}px`, + height: `${ostype() === "windows" ? 34 : 32}px`, + "transition-duration": "1200ms", + }} + > + } + > + + +
+
+ +
= 3 ? "10%" : "94%", + bottom: phase() >= 3 ? "12%" : "90%", + "border-color": + phase() >= 3 ? "rgba(255,255,255,0.6)" : "transparent", + opacity: phase() >= 3 ? 1 : 0, + transition: + "right 1200ms cubic-bezier(0.22, 0.82, 0.28, 1), bottom 1200ms cubic-bezier(0.22, 0.82, 0.28, 1), border-color 200ms ease, opacity 200ms ease", + }} + > + = 4}> + + {(pos) => ( + + + + )} + +
+ 640 × 480 +
+
+
+ + +
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + Copy +
+
+ + Save +
+
+
+ +
+
+
= 7 ? 1 : 0, + }} + /> + +
= 8 ? "8%" : "0", + left: phase() >= 8 ? "8%" : "0", + right: phase() >= 8 ? "8%" : "0", + bottom: phase() >= 8 ? "8%" : "0", + }} + > +
= 8 ? "8px" : "0px", + "box-shadow": + phase() >= 8 ? "0 4px 20px rgba(0,0,0,0.2)" : "none", + }} + > +
+
+
+
+
+
+
+
+
+
+
+ +
= 9 ? 1 : 0, + transform: phase() >= 9 ? "translateY(0)" : "translateY(4px)", + }} + > +
+ + Copied to clipboard +
+
+
+
+
+
+ ); +} diff --git a/apps/desktop/src/routes/(window-chrome)/setup.tsx b/apps/desktop/src/routes/(window-chrome)/setup.tsx deleted file mode 100644 index 8570df084d..0000000000 --- a/apps/desktop/src/routes/(window-chrome)/setup.tsx +++ /dev/null @@ -1,567 +0,0 @@ -import { Button } from "@cap/ui-solid"; -import { makePersisted } from "@solid-primitives/storage"; -import { createTimer } from "@solid-primitives/timer"; -import { getCurrentWindow } from "@tauri-apps/api/window"; -import { ask } from "@tauri-apps/plugin-dialog"; -import { relaunch } from "@tauri-apps/plugin-process"; -import { - createEffect, - createResource, - createSignal, - For, - Match, - onCleanup, - onMount, - Show, - Switch, - startTransition, -} from "solid-js"; -import { createStore } from "solid-js/store"; -import ModeSelect from "~/components/ModeSelect"; -import { - commands, - type OSPermission, - type OSPermissionStatus, -} from "~/utils/tauri"; -import IconLucideVolumeX from "~icons/lucide/volume-x"; - -function isPermitted(status?: OSPermissionStatus): boolean { - return status === "granted" || status === "notNeeded"; -} - -type SetupPermission = { - name: string; - key: OSPermission; - description: string; - requiresManualGrant: boolean; - optional?: boolean; -}; - -const permissions: readonly SetupPermission[] = [ - { - name: "Screen Recording", - key: "screenRecording" as const, - description: - "Add Cap in System Settings, then restart the app for changes to take effect.", - requiresManualGrant: true, - }, - { - name: "Accessibility", - key: "accessibility" as const, - description: - "During recording, Cap collects mouse activity locally to generate automatic zoom in segments.", - requiresManualGrant: false, - }, - { - name: "Microphone", - key: "microphone" as const, - description: "This permission is required to record audio in your Caps.", - requiresManualGrant: false, - optional: true, - }, - { - name: "Camera", - key: "camera" as const, - description: - "This permission is required to record your camera in your Caps.", - requiresManualGrant: false, - optional: true, - }, -]; - -export default function () { - const [initialCheck, setInitialCheck] = createSignal(true); - const [check, checkActions] = createResource(() => - commands.doPermissionsCheck(initialCheck()), - ); - const [currentStep, setCurrentStep] = createSignal<"permissions" | "mode">( - "permissions", - ); - - createEffect(() => { - if (!initialCheck()) { - createTimer( - () => startTransition(() => checkActions.refetch()), - 250, - setInterval, - ); - } - }); - - const requestPermission = async (permission: OSPermission) => { - try { - await commands.requestPermission(permission); - } catch (err) { - console.error(`Error occurred while requesting permission: ${err}`); - } - setInitialCheck(false); - }; - - const openSettings = async (permission: OSPermission) => { - await commands.openPermissionSettings(permission); - if (permission === "screenRecording") { - const shouldRestart = await ask( - "After adding Cap in System Settings, you'll need to restart the app for the permission to take effect.", - { - title: "Restart Required", - kind: "info", - okLabel: "Restart, I've granted permission", - cancelLabel: "No, I still need to add it", - }, - ); - if (shouldRestart) { - await relaunch(); - } - } - setInitialCheck(false); - }; - - const [showStartup, showStartupActions] = createResource(() => - generalSettingsStore.get().then((s) => { - if (s === undefined) return true; - return !s.hasCompletedStartup; - }), - ); - - const handleContinue = () => { - commands.showWindow({ Main: { init_target_mode: null } }).then(() => { - getCurrentWindow().close(); - }); - }; - - return ( -
- {showStartup() && ( - { - showStartupActions.mutate(false); - }} - /> - )} - - -
- -

- Permissions Required -

-

Cap needs permissions to run properly.

-
- -
    - - {(permission) => { - const permissionCheck = () => check()?.[permission.key]; - - return ( - -
  • -
    - - {permission.name} Permission - - - {permission.description} - -
    - -
  • -
    - ); - }} -
    -
- - -
- - -
- -

- Select Recording Mode -

-

- Choose how you want to record with Cap. You can change this later. -

-
- -
- -
- - -
-
- ); -} - -import { type as ostype } from "@tauri-apps/plugin-os"; -import { cx } from "cva"; -import { Portal } from "solid-js/web"; -import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11"; -import { generalSettingsStore } from "~/store"; -import cloud1 from "../../assets/illustrations/cloud-1.png"; -import cloud2 from "../../assets/illustrations/cloud-2.png"; -import cloud3 from "../../assets/illustrations/cloud-3.png"; -import startupAudio from "../../assets/tears-and-fireflies-adi-goldstein.mp3"; - -function Startup(props: { onClose: () => void }) { - const [audioState, setAudioState] = makePersisted( - createStore({ isMuted: false }), - { name: "audioSettings" }, - ); - - const [isExiting, setIsExiting] = createSignal(false); - - const audio = new Audio(startupAudio); - if (!audioState.isMuted) audio.play(); - - // Add refs to store animation objects - let cloud1Animation: Animation | undefined; - let cloud2Animation: Animation | undefined; - let cloud3Animation: Animation | undefined; - - const [isLogoAnimating, setIsLogoAnimating] = createSignal(false); - - const handleLogoClick = () => { - if (!isLogoAnimating()) { - setIsLogoAnimating(true); - setTimeout(() => setIsLogoAnimating(false), 1000); - } - }; - - const handleStartupCompleted = () => - generalSettingsStore.set({ - hasCompletedStartup: true, - }); - - const handleGetStarted = async () => { - setIsExiting(true); - - // Cancel ongoing cloud animations - cloud1Animation?.cancel(); - cloud2Animation?.cancel(); - cloud3Animation?.cancel(); - - await handleStartupCompleted(); - - // Wait for animation to complete before showing new window and closing - setTimeout(async () => { - props.onClose(); - }, 600); - }; - - onCleanup(() => audio.pause()); - - onMount(() => { - const cloud1El = document.getElementById("cloud-1"); - const cloud2El = document.getElementById("cloud-2"); - const cloud3El = document.getElementById("cloud-3"); - - // Top right cloud - gentle diagonal movement - cloud1Animation = cloud1El?.animate( - [ - { transform: "translate(0, 0)" }, - { transform: "translate(-20px, 10px)" }, - { transform: "translate(0, 0)" }, - ], - { - duration: 30000, - iterations: Infinity, - easing: "linear", - }, - ); - - // Top left cloud - gentle diagonal movement - cloud2Animation = cloud2El?.animate( - [ - { transform: "translate(0, 0)" }, - { transform: "translate(20px, 10px)" }, - { transform: "translate(0, 0)" }, - ], - { - duration: 35000, - iterations: Infinity, - easing: "linear", - }, - ); - - // Bottom cloud - slow rise up with subtle horizontal movement - cloud3Animation = cloud3El?.animate( - [ - { transform: "translate(-50%, 20px)" }, - { transform: "translate(-48%, 0)" }, - { transform: "translate(-50%, 0)" }, - ], - { - duration: 60000, - iterations: 1, - easing: "cubic-bezier(0.4, 0, 0.2, 1)", - fill: "forwards", - }, - ); - }); - - const toggleMute = async () => { - setAudioState("isMuted", (m) => !m); - - audio.muted = audioState.isMuted; - }; - - return ( - -
-
-
- - {ostype() === "windows" && } -
-
- - {/* Add the fade overlay */} -
-
-
- - {/* Floating clouds */} -
- Cloud One -
-
- Cloud Two -
-
- Cloud Three -
- - {/* Main content */} -
-
-
- -
-

- Welcome to Cap -

-

- Beautiful screen recordings, owned by you. -

-
- - - - - - - - - -
-
-
- - ); -} diff --git a/apps/desktop/src/routes/debug.tsx b/apps/desktop/src/routes/debug.tsx index 0a6c7d6566..9598426a3f 100644 --- a/apps/desktop/src/routes/debug.tsx +++ b/apps/desktop/src/routes/debug.tsx @@ -62,9 +62,9 @@ export default function Debug() {