diff --git a/Cargo.lock b/Cargo.lock index 97e16667c..8a835f07c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7280,6 +7280,7 @@ dependencies = [ "serde", "serde_json", "tauri", + "tauri-plugin-deep-link", "thiserror 2.0.6", "tracing", "windows-sys 0.59.0", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 42a79c8dc..677ba076f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -34,7 +34,6 @@ "@solidjs/start": "^1.0.6", "@tanstack/solid-query": "^5.51.21", "@tauri-apps/api": "^2.1.1", - "@tauri-apps/plugin-deep-link": "^2.2.0", "@tauri-apps/plugin-dialog": "2.0.1", "@tauri-apps/plugin-fs": "2.0.3", "@tauri-apps/plugin-http": "^2.0.1", diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index cfa329c41..7c7dcebb3 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -31,7 +31,6 @@ "oauth:allow-start", "updater:default", "notification:default", - "deep-link:default", { "identifier": "http:default", "allow": [ diff --git a/apps/desktop/src-tauri/src/auth.rs b/apps/desktop/src-tauri/src/auth.rs index 1ec7f8be6..0a93ac572 100644 --- a/apps/desktop/src-tauri/src/auth.rs +++ b/apps/desktop/src-tauri/src/auth.rs @@ -1,14 +1,22 @@ +use std::sync::Arc; + use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; use tauri::{AppHandle, Runtime}; use tauri_plugin_store::StoreExt; +use tauri_specta::Event; use web_api::ManagerExt; use crate::web_api; #[derive(Serialize, Deserialize, Type, Debug)] +pub enum AuthState { + Listening, +} + +#[derive(Serialize, Deserialize, Type, Clone, Debug)] pub struct AuthStore { pub token: String, pub user_id: Option, @@ -16,7 +24,7 @@ pub struct AuthStore { pub plan: Option, } -#[derive(Serialize, Deserialize, Type, Debug)] +#[derive(Serialize, Deserialize, Type, Clone, Debug)] pub struct Plan { pub upgraded: bool, pub manual: bool, @@ -112,9 +120,18 @@ impl AuthStore { }); store.set("auth", json!(value)); + if let Some(auth) = value { + if let Err(e) = Authenticated::emit(&Authenticated(auth), app) { + eprintln!("Error while emitting Authenticated: {}", e.to_string()); + }; + } + store.save().map_err(|e| e.to_string()) } } #[derive(specta::Type, serde::Serialize, tauri_specta::Event, Debug, Clone, serde::Deserialize)] pub struct AuthenticationInvalid; + +#[derive(specta::Type, serde::Serialize, tauri_specta::Event, Debug, Clone, serde::Deserialize)] +pub struct Authenticated(AuthStore); diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs new file mode 100644 index 000000000..b2f0f6b40 --- /dev/null +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -0,0 +1,152 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager, Url}; +use tokio::sync::RwLock; + +use crate::{ + auth::{AuthState, AuthStore}, + windows::ShowCapWindow, + App, +}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CaptureMode { + Screen(String), + Window(String), +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DeepLinkAction { + SignIn(AuthStore), + StartRecording { + mode: CaptureMode, + camera_label: Option, + audio_input_name: Option, + fps: Option, + output_resolution: Option, + }, + StopRecording, + OpenEditor(String), + OpenSettings, +} + +pub fn handle(app_handle: &AppHandle, urls: Vec) { + #[cfg(debug_assertions)] + println!("Handling deep actions for: {:?}", &urls); + + let actions: Vec<_> = urls + .into_iter() + .filter(|url| !url.as_str().is_empty()) + .filter_map(|url| { + DeepLinkAction::try_from(&url) + .map_err(|e| { + eprintln!("Failed to parse deep link \"{}\": {}", &url, e); + }) + .ok() + }) + .collect(); + + if actions.is_empty() { + return; + } + + let app_handle = app_handle.clone(); + tauri::async_runtime::spawn(async move { + for action in actions { + if let Err(e) = action.execute(&app_handle).await { + eprintln!("Failed to handle deep link action: {}", e); + } + } + }); +} + +impl TryFrom<&Url> for DeepLinkAction { + type Error = String; + + fn try_from(url: &Url) -> Result { + if !url.domain().is_some_and(|v| v == "action") { + return Err("Invalid format".into()); + } + + let params = url + .query_pairs() + .collect::>(); + let json_value = params.get("value").ok_or("No value")?; + let action: Self = serde_json::from_str(json_value).map_err(|e| { + format!( + "Failed to parse deep-link action json value: {}", + e.to_string() + ) + })?; + Ok(action) + } +} + +impl DeepLinkAction { + pub async fn execute(self, app: &AppHandle) -> Result<(), String> { + match self { + Self::SignIn(auth) => { + let app_state = app.state::>>(); + let reader_guard = app_state.read().await; + + match &reader_guard.auth_state { + Some(AuthState::Listening) => Ok(AuthStore::set(app, Some(auth))?), + _ => Err("Not listening for OAuth events".into()), + } + } + DeepLinkAction::StartRecording { + mode, + camera_label, + audio_input_name, + fps, + output_resolution, + } => { + use cap_media::sources::ScreenCaptureTarget; + let capture_target: ScreenCaptureTarget = match mode { + CaptureMode::Screen(name) => cap_media::sources::list_screens() + .into_iter() + .find(|(s, _)| s.name == name) + .map(|(s, _)| ScreenCaptureTarget::Screen(s)) + .ok_or(format!("No screen with name \"{}\"", &name))?, + CaptureMode::Window(name) => cap_media::sources::list_windows() + .into_iter() + .find(|(w, _)| w.name == name) + .map(|(w, _)| ScreenCaptureTarget::Window(w)) + .ok_or(format!("No window with name \"{}\"", &name))?, + }; + + let state = app.state::>>(); + crate::set_recording_options( + app.clone().to_owned(), + state, + cap_recording::RecordingOptions { + capture_target, + camera_label, + audio_input_name, + fps: fps.unwrap_or_default(), + output_resolution, + }, + ) + .await?; + + crate::recording::start_recording(app.clone(), app.state()).await + } + DeepLinkAction::StopRecording => { + crate::recording::stop_recording(app.clone(), app.state()).await + } + DeepLinkAction::OpenEditor(id) => { + crate::open_editor(app.clone(), id); + Ok(()) + } + DeepLinkAction::OpenSettings => { + _ = ShowCapWindow::Settings { page: None } + .show(app) + .map_err(|e| format!("Failed to open settings window: {}", e))?; + Ok(()) + } + } + } +} diff --git a/apps/desktop/src-tauri/src/events.rs b/apps/desktop/src-tauri/src/events.rs deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c6975daa7..0142766cb 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ mod platform; mod recording; // mod resource; mod audio_meter; +mod deeplink_actions; mod export; mod fake_window; mod tray; @@ -18,6 +19,7 @@ mod web_api; mod windows; use audio::AppSounds; +use auth::Authenticated; use auth::{AuthStore, AuthenticationInvalid, Plan}; use camera::create_camera_preview_ws; use cap_editor::EditorInstance; @@ -55,7 +57,7 @@ use std::{ sync::Arc, time::Duration, }; -use tauri::{AppHandle, Emitter, Manager, Runtime, State, WindowEvent}; +use tauri::{AppHandle, Manager, Runtime, State, WindowEvent}; use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_dialog::DialogExt; use tauri_plugin_notification::{NotificationExt, PermissionState}; @@ -85,6 +87,8 @@ pub struct App { current_recording: Option, #[serde(skip)] pre_created_video: Option, + #[serde(skip)] + auth_state: Option, } #[derive(specta::Type, Serialize, Deserialize, Clone, Debug)] @@ -1791,6 +1795,17 @@ fn open_external_link(app: tauri::AppHandle, url: String) -> Result<(), String> Ok(()) } +#[tauri::command] +#[specta::specta] +async fn set_oauth_listening_state( + state: MutableState<'_, App>, + auth_state: Option, +) -> Result<(), String> { + let mut writer_guard = state.write().await; + writer_guard.auth_state = auth_state; + Ok(()) +} + #[tauri::command] #[specta::specta] async fn delete_auth_open_signin(app: AppHandle) -> Result<(), String> { @@ -1960,6 +1975,7 @@ pub async fn run() { check_upgraded_and_update, open_external_link, hotkeys::set_hotkey, + set_oauth_listening_state, delete_auth_open_signin, reset_camera_permissions, reset_microphone_permissions, @@ -1990,6 +2006,7 @@ pub async fn run() { RequestOpenSettings, NewNotification, AuthenticationInvalid, + Authenticated, audio_meter::AudioInputLevelChange, UploadProgress, ]) @@ -2107,6 +2124,7 @@ pub async fn run() { }, current_recording: None, pre_created_video: None, + auth_state: None, }))); app.manage(Arc::new(RwLock::new( @@ -2205,6 +2223,14 @@ pub async fn run() { delete_auth_open_signin(app).await.ok(); }); + // Registering deep links at runtime is not possible on macOS, + // so deep links can only be tested on the bundled application, + // which must be installed in the /Applications directory. + let app_handle = app.clone(); + app.deep_link().on_open_url(move |event| { + deeplink_actions::handle(&app_handle, event.urls()); + }); + Ok(()) }) .on_window_event(|window, event| { diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 84e4fecdd..5e08592c8 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -32,13 +32,7 @@ "deep-link": { "desktop": { "schemes": ["cap-desktop"] - }, - "mobile": [ - { - "host": "cap.so", - "pathPrefix": ["/signin"] - } - ] + } } }, "bundle": { diff --git a/apps/desktop/src/routes/(window-chrome)/signin.tsx b/apps/desktop/src/routes/(window-chrome)/signin.tsx index f5e2e9d3e..4f8c360f8 100644 --- a/apps/desktop/src/routes/(window-chrome)/signin.tsx +++ b/apps/desktop/src/routes/(window-chrome)/signin.tsx @@ -7,18 +7,26 @@ import { redirect, useAction, useSubmission, - useNavigate, } from "@solidjs/router"; import { onMount, onCleanup, createSignal } from "solid-js"; -import { onOpenUrl } from "@tauri-apps/plugin-deep-link"; import callbackTemplate from "./callback.template"; import { authStore } from "~/store"; import { clientEnv } from "~/utils/env"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import { commands } from "~/utils/tauri"; -import { Window } from "@tauri-apps/api/window"; +import { commands, events } from "~/utils/tauri"; + const signInAction = action(async () => { + // Only use deeplinks for OAuth on production. + if (import.meta.env.VITE_ENVIRONMENT !== "development") { + console.log("Starting listening to oauth signin command..."); + commands.setOauthListeningState("Listening"); + await shell.open(`${clientEnv.VITE_SERVER_URL}/api/desktop/session/request?platform=desktop`); + return; + } + + console.log("Starting oauth listener server..."); + let res: (url: URL) => void; try { @@ -70,11 +78,6 @@ const signInAction = action(async () => { }); stopListening(); - const isDevMode = import.meta.env.VITE_ENVIRONMENT === "development"; - if (!isDevMode) { - return; - } - const token = url.searchParams.get("token"); const user_id = url.searchParams.get("user_id"); const expires = Number(url.searchParams.get("expires")); @@ -94,29 +97,8 @@ const signInAction = action(async () => { }, }); - const currentWindow = await Window.getByLabel("signin"); - await commands.openMainWindow(); - - // Add a small delay to ensure window is ready - await new Promise((resolve) => setTimeout(resolve, 500)); - - const mainWindow = await Window.getByLabel("main"); - console.log("Main window reference:", mainWindow ? "found" : "not found"); - - if (mainWindow) { - try { - await mainWindow.setFocus(); - console.log("Successfully set focus on main window"); - } catch (e) { - console.error("Failed to focus main window:", e); - } - } - - if (currentWindow) { - await currentWindow.close(); - } - - return redirect("/"); + await commands.showWindow("Main"); + getCurrentWindow().close(); } catch (error) { console.error("Sign in failed:", error); await authStore.set(); @@ -127,94 +109,19 @@ const signInAction = action(async () => { export default function Page() { const signIn = useAction(signInAction); const submission = useSubmission(signInAction); - const navigate = useNavigate(); const [isSignedIn, setIsSignedIn] = createSignal(false); - // Listen for auth changes and redirect to signin if auth is cleared onMount(async () => { - let unsubscribe: (() => void) | undefined; - - try { - unsubscribe = await authStore.listen((auth) => { - if (!auth) { - // Replace the current route with signin - navigate("/signin", { replace: true }); - } - }); - } catch (error) { - console.error("Failed to set up auth listener:", error); - } - - // Clean up OAuth server on component unmount - onCleanup(async () => { - try { - await invoke("plugin:oauth|stop"); - } catch (e) { - // Ignore errors if no server is running - } - unsubscribe?.(); - }); - - const unsubscribeDeepLink = await onOpenUrl(async (urls) => { - const isDevMode = import.meta.env.VITE_ENVIRONMENT === "development"; - if (isDevMode) { - return; - } - - for (const url of urls) { - if (!url.includes("token=")) return; - - const urlObject = new URL(url); - const token = urlObject.searchParams.get("token"); - const user_id = urlObject.searchParams.get("user_id"); - const expires = Number(urlObject.searchParams.get("expires")); - - if (!token || !expires || !user_id) { - throw new Error("Invalid signin params"); - } - - const existingAuth = await authStore.get(); - await authStore.set({ - token, - user_id, - expires, - plan: { - upgraded: false, - last_checked: 0, - manual: existingAuth?.plan?.manual ?? false, - }, - }); - setIsSignedIn(true); - const currentWindow = await Window.getByLabel("signin"); - await commands.openMainWindow(); - - // Add a small delay to ensure window is ready - await new Promise((resolve) => setTimeout(resolve, 500)); - - const mainWindow = await Window.getByLabel("main"); - console.log( - "Main window reference:", - mainWindow ? "found" : "not found" - ); - - if (mainWindow) { - try { - await mainWindow.setFocus(); - console.log("Successfully set focus on main window"); - } catch (e) { - console.error("Failed to focus main window:", e); - } - } - - if (currentWindow) { - await currentWindow.close(); - } - } + const unlisten = await events.authenticated.listen(async (e) => { + console.log(`Signed in: ${e.payload.user_id}`); + commands.setOauthListeningState(null); + setIsSignedIn(true); + alert("Successfully signed in to Cap!"); + await commands.showWindow("Main"); + getCurrentWindow().close(); }); - onCleanup(() => { - unsubscribeDeepLink(); - }); + onCleanup(() => unlisten()); }); return ( diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 0b166cfbc..ad2b24c98 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -131,6 +131,9 @@ async openExternalLink(url: string) : Promise { async setHotkey(action: HotkeyAction, hotkey: Hotkey | null) : Promise { return await TAURI_INVOKE("set_hotkey", { action, hotkey }); }, +async setOauthListeningState(authState: AuthState | null) : Promise { + return await TAURI_INVOKE("set_oauth_listening_state", { authState }); +}, async deleteAuthOpenSignin() : Promise { return await TAURI_INVOKE("delete_auth_open_signin"); }, @@ -174,6 +177,7 @@ async getEditorTotalFrames(videoId: string, fps: number) : Promise { export const events = __makeEvents__<{ audioInputLevelChange: AudioInputLevelChange, +authenticated: Authenticated, authenticationInvalid: AuthenticationInvalid, currentRecordingChanged: CurrentRecordingChanged, editorStateChanged: EditorStateChanged, @@ -193,6 +197,7 @@ requestStopRecording: RequestStopRecording, uploadProgress: UploadProgress }>({ audioInputLevelChange: "audio-input-level-change", +authenticated: "authenticated", authenticationInvalid: "authentication-invalid", currentRecordingChanged: "current-recording-changed", editorStateChanged: "editor-state-changed", @@ -224,7 +229,9 @@ export type Audio = { duration: number; sample_rate: number; channels: number } export type AudioConfiguration = { mute: boolean; improve: boolean } export type AudioInputLevelChange = number export type AudioMeta = { path: string } +export type AuthState = "Listening" export type AuthStore = { token: string; user_id: string | null; expires: number; plan: Plan | null } +export type Authenticated = AuthStore export type AuthenticationInvalid = null export type BackgroundConfiguration = { source: BackgroundSource; blur: number; padding: number; rounding: number; inset: number; crop: Crop | null } export type BackgroundSource = { type: "wallpaper"; id: number } | { type: "image"; path: string | null } | { type: "color"; value: [number, number, number] } | { type: "gradient"; from: [number, number, number]; to: [number, number, number]; angle?: number } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 500f47bae..3c20513a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,9 +106,6 @@ importers: '@tauri-apps/api': specifier: ^2.1.1 version: 2.1.1 - '@tauri-apps/plugin-deep-link': - specifier: ^2.2.0 - version: 2.2.0 '@tauri-apps/plugin-dialog': specifier: 2.0.1 version: 2.0.1 @@ -4544,9 +4541,6 @@ packages: engines: {node: '>= 10'} hasBin: true - '@tauri-apps/plugin-deep-link@2.2.0': - resolution: {integrity: sha512-H6mkxr2KZ3XJcKL44tiq6cOjCw9DL8OgU1xjn3j26Qsn+H/roPFiyhR7CHuB8Ar+sQFj4YVlfmJwtBajK2FETQ==} - '@tauri-apps/plugin-dialog@2.0.1': resolution: {integrity: sha512-fnUrNr6EfvTqdls/ufusU7h6UbNFzLKvHk/zTuOiBq01R3dTODqwctZlzakdbfSp/7pNwTKvgKTAgl/NAP/Z0Q==} @@ -15277,10 +15271,6 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.1.0 '@tauri-apps/cli-win32-x64-msvc': 2.1.0 - '@tauri-apps/plugin-deep-link@2.2.0': - dependencies: - '@tauri-apps/api': 2.1.1 - '@tauri-apps/plugin-dialog@2.0.1': dependencies: '@tauri-apps/api': 2.1.1 @@ -17690,7 +17680,7 @@ snapshots: '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.7.2) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.1) eslint-plugin-import: 2.30.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.0(eslint@8.57.1) eslint-plugin-react: 7.37.0(eslint@8.57.1) @@ -17719,25 +17709,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(eslint@8.57.1))(eslint@8.57.1): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.3.7(supports-color@5.5.0) - enhanced-resolve: 5.17.1 - eslint: 8.57.1 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) - fast-glob: 3.3.2 - get-tsconfig: 4.8.1 - is-bun-module: 1.2.1 - is-glob: 4.0.3 - optionalDependencies: - eslint-plugin-import: 2.30.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) - transitivePeerDependencies: - - '@typescript-eslint/parser' - - eslint-import-resolver-node - - eslint-import-resolver-webpack - - supports-color - eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -17776,17 +17747,6 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.7.2) - eslint: 8.57.1 - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(eslint@8.57.1))(eslint@8.57.1) - transitivePeerDependencies: - - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): dependencies: debug: 3.2.7