diff --git a/apps/desktop/src-tauri/src/auth.rs b/apps/desktop/src-tauri/src/auth.rs index 698c41f10b..e904d5e1d8 100644 --- a/apps/desktop/src-tauri/src/auth.rs +++ b/apps/desktop/src-tauri/src/auth.rs @@ -14,6 +14,16 @@ pub struct AuthStore { pub user_id: Option, pub plan: Option, pub intercom_hash: Option, + #[serde(default)] + pub organizations: Vec, +} + +#[derive(Serialize, Deserialize, Type, Debug, Clone)] +pub struct Organization { + pub id: String, + pub name: String, + #[serde(rename = "ownerId")] + pub owner_id: String, } #[derive(Serialize, Deserialize, Type, Debug)] @@ -97,6 +107,31 @@ impl AuthStore { }); auth.intercom_hash = Some(plan_response.intercom_hash.unwrap_or_default()); + // Fetch organizations + println!("Fetching organizations for user"); + match app + .authed_api_request("/api/desktop/organizations", |client, url| client.get(url)) + .await + { + Ok(response) if response.status().is_success() => { + match response.json::>().await { + Ok(orgs) => { + println!("Fetched {} organizations", orgs.len()); + auth.organizations = orgs; + } + Err(e) => { + println!("Failed to parse organizations: {e}"); + } + } + } + Err(e) => { + println!("Failed to fetch organizations: {e}"); + } + Ok(response) => { + println!("Failed to fetch organizations: status {}", response.status()); + } + } + Self::set(app, Some(auth))?; Ok(()) diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index 7372da2b9b..8f21d1b761 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -136,6 +136,7 @@ impl DeepLinkAction { capture_target, capture_system_audio, mode, + organization_id: None, }; crate::recording::start_recording(app.clone(), state, inputs) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 05289bd2d9..d1a4251800 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -86,7 +86,7 @@ use tauri_specta::Event; #[cfg(target_os = "macos")] use tokio::sync::Mutex; use tokio::sync::{RwLock, oneshot}; -use tracing::{error, trace, warn}; +use tracing::{error, info, trace, warn}; use upload::{create_or_get_video, upload_image, upload_video}; use web_api::AuthedApiError; use web_api::ManagerExt as WebManagerExt; @@ -1063,6 +1063,7 @@ async fn upload_exported_video( path: PathBuf, mode: UploadMode, channel: Channel, + organization_id: Option, ) -> Result { let Ok(Some(auth)) = AuthStore::get(&app) else { AuthStore::set(&app, None).map_err(|e| e.to_string())?; @@ -1109,6 +1110,7 @@ async fn upload_exported_video( video_id, Some(meta.pretty_name.clone()), Some(metadata.clone()), + organization_id, ) .await } @@ -1631,6 +1633,7 @@ async fn check_upgraded_and_update(app: AppHandle) -> Result { manual: auth.plan.map(|p| p.manual).unwrap_or(false), last_checked: chrono::Utc::now().timestamp() as i32, }), + organizations: auth.organizations, }; println!("Updating auth store with new pro status"); AuthStore::set(&app, Some(updated_auth)).map_err(|e| e.to_string())?; @@ -1886,6 +1889,13 @@ async fn update_auth_plan(app: AppHandle) { AuthStore::update_auth_plan(&app).await.ok(); } +#[tauri::command] +#[specta::specta] +async fn refresh_organizations(app: AppHandle) -> Result<(), String> { + info!("Manually refreshing organizations"); + AuthStore::update_auth_plan(&app).await +} + pub type FilteredRegistry = tracing_subscriber::layer::Layered< tracing_subscriber::filter::FilterFn bool>, tracing_subscriber::Registry, @@ -1962,12 +1972,14 @@ pub async fn run(recording_logging_handle: LoggingHandle) { windows::position_traffic_lights, windows::set_theme, global_message_dialog, + log_message, show_window, write_clipboard_string, platform::perform_haptic_feedback, list_fails, set_fail, update_auth_plan, + refresh_organizations, set_window_transparent, get_editor_meta, set_pretty_name, @@ -2062,8 +2074,8 @@ pub async fn run(recording_logging_handle: LoggingHandle) { tauri::async_runtime::set(tokio::runtime::Handle::current()); #[allow(unused_mut)] - let mut builder = - tauri::Builder::default().plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { + let mut builder = tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { trace!("Single instance invoked with args {args:?}"); // This is also handled as a deeplink on some platforms (eg macOS), see deeplink_actions @@ -2172,6 +2184,19 @@ pub async fn run(recording_logging_handle: LoggingHandle) { ..Default::default() })); }); + + // Fetch organizations if missing (for existing users) + if auth.organizations.is_empty() { + info!("User is logged in but organizations not cached, fetching..."); + let app_clone = app.clone(); + tokio::spawn(async move { + if let Err(e) = AuthStore::update_auth_plan(&app_clone).await { + error!("Failed to fetch organizations on startup: {}", e); + } else { + info!("Organizations fetched successfully on startup"); + } + }); + } } tokio::spawn({ @@ -2274,6 +2299,7 @@ pub async fn run(recording_logging_handle: LoggingHandle) { }), capture_system_audio: settings.system_audio, mode: event.mode, + organization_id: settings.organization_id, }, ) .await; @@ -2651,6 +2677,17 @@ fn global_message_dialog(app: AppHandle, message: String) { app.dialog().message(message).show(|_| {}); } +#[tauri::command] +#[specta::specta] +fn log_message(level: String, message: String) { + match level.as_str() { + "info" => info!("{}", message), + "warn" => warn!("{}", message), + "error" => error!("{}", message), + _ => trace!("{}", message), + } +} + #[tauri::command] #[specta::specta] async fn write_clipboard_string( diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index c6d212540c..16c076e147 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -237,6 +237,8 @@ pub struct StartRecordingInputs { #[serde(default)] pub capture_system_audio: bool, pub mode: RecordingMode, + #[serde(default)] + pub organization_id: Option, } #[derive(tauri_specta::Event, specta::Type, Clone, Debug, serde::Serialize)] @@ -313,6 +315,7 @@ pub async fn start_recording( chrono::Local::now().format("%Y-%m-%d %H:%M:%S") )), None, + inputs.organization_id.clone(), ) .await { diff --git a/apps/desktop/src-tauri/src/recording_settings.rs b/apps/desktop/src-tauri/src/recording_settings.rs index a611f25698..9d33e1dcb2 100644 --- a/apps/desktop/src-tauri/src/recording_settings.rs +++ b/apps/desktop/src-tauri/src/recording_settings.rs @@ -21,6 +21,7 @@ pub struct RecordingSettingsStore { pub camera_id: Option, pub mode: Option, pub system_audio: bool, + pub organization_id: Option, } impl RecordingSettingsStore { diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 556dfc2a78..58b7e1bcf7 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -195,7 +195,7 @@ pub async fn upload_image( .ok_or("Invalid file path")? .to_string(); - let s3_config = create_or_get_video(app, true, None, None, None).await?; + let s3_config = create_or_get_video(app, true, None, None, None, None).await?; let (stream, total_size) = file_reader_stream(file_path).await?; singlepart_uploader( @@ -223,6 +223,7 @@ pub async fn create_or_get_video( video_id: Option, name: Option, meta: Option, + organization_id: Option, ) -> Result { let mut s3_config_url = if let Some(id) = video_id { format!("/api/desktop/video/create?recordingMode=desktopMP4&videoId={id}") @@ -245,6 +246,10 @@ pub async fn create_or_get_video( } } + if let Some(org_id) = organization_id { + s3_config_url.push_str(&format!("&orgId={}", org_id)); + } + let response = app .authed_api_request(s3_config_url, |client, url| client.get(url)) .await?; diff --git a/apps/desktop/src-tauri/src/upload_legacy.rs b/apps/desktop/src-tauri/src/upload_legacy.rs index 223b8573b3..c8a7e953cf 100644 --- a/apps/desktop/src-tauri/src/upload_legacy.rs +++ b/apps/desktop/src-tauri/src/upload_legacy.rs @@ -216,7 +216,7 @@ pub async fn upload_video( let client = reqwest::Client::new(); let s3_config = match existing_config { Some(config) => config, - None => create_or_get_video(app, false, Some(video_id.clone()), None, meta).await?, + None => create_or_get_video(app, false, Some(video_id.clone()), None, meta, None).await?, }; let presigned_put = presigned_s3_put( @@ -331,7 +331,7 @@ pub async fn upload_image(app: &AppHandle, file_path: PathBuf) -> Result, name: Option, meta: Option, + organization_id: Option, ) -> Result { let mut s3_config_url = if let Some(id) = video_id { format!("/api/desktop/video/create?recordingMode=desktopMP4&videoId={id}") @@ -407,6 +408,10 @@ pub async fn create_or_get_video( } } + if let Some(org_id) = organization_id { + s3_config_url.push_str(&format!("&orgId={}", org_id)); + } + let response = app .authed_api_request(s3_config_url, |client, url| client.get(url)) .await 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 aeed303171..c4a9a9808e 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -435,6 +435,11 @@ function Page() { currentWindow.setSize(new LogicalSize(size.width, size.height)); }); + // Refresh organizations when main window loads + try { + await commands.refreshOrganizations(); + } catch {} + onCleanup(async () => { (await unlistenFocus)?.(); (await unlistenResize)?.(); diff --git a/apps/desktop/src/routes/editor/ExportDialog.tsx b/apps/desktop/src/routes/editor/ExportDialog.tsx index 40d6a75c62..6da342c38a 100644 --- a/apps/desktop/src/routes/editor/ExportDialog.tsx +++ b/apps/desktop/src/routes/editor/ExportDialog.tsx @@ -1,5 +1,6 @@ import { Button } from "@cap/ui-solid"; import { Select as KSelect } from "@kobalte/core/select"; +import { Tabs as KTabs } from "@kobalte/core/tabs"; import { makePersisted } from "@solid-primitives/storage"; import { createMutation, @@ -11,6 +12,7 @@ import { save as saveDialog } from "@tauri-apps/plugin-dialog"; import { cx } from "cva"; import { createEffect, + createMemo, createRoot, createSignal, For, @@ -28,6 +30,7 @@ import { authStore } from "~/store"; import { trackEvent } from "~/utils/analytics"; import { createSignInMutation } from "~/utils/auth"; import { exportVideo } from "~/utils/export"; +import { createOrganizationsQuery } from "~/utils/queries"; import { commands, type ExportCompression, @@ -77,17 +80,17 @@ export const EXPORT_TO_OPTIONS = [ { label: "File", value: "file", - icon: , + icon: , }, { label: "Clipboard", value: "clipboard", - icon: , + icon: , }, { label: "Shareable link", value: "link", - icon: , + icon: , }, ] as const; @@ -106,6 +109,7 @@ interface Settings { exportTo: ExportToOption; resolution: { label: string; value: string; width: number; height: number }; compression: ExportCompression; + organizationId?: string | null; } export function ExportDialog() { const { @@ -119,6 +123,7 @@ export function ExportDialog() { } = useEditorContext(); const auth = authStore.createQuery(); + const organizations = createOrganizationsQuery(); const [settings, setSettings] = makePersisted( createStore({ @@ -127,12 +132,21 @@ export function ExportDialog() { exportTo: "file", resolution: { label: "720p", value: "720p", width: 1280, height: 720 }, compression: "Minimal", + organizationId: null, }), { name: "export_settings" }, ); if (!["Mp4", "Gif"].includes(settings.format)) setSettings("format", "Mp4"); + // Auto-select first organization if none selected and user is authenticated + createEffect(() => { + const orgs = organizations(); + if (!settings.organizationId && orgs.length > 0 && auth.data) { + setSettings("organizationId", orgs[0].id); + } + }); + const exportWithSettings = (onProgress: (progress: FramesRendered) => void) => exportVideo( projectPath, @@ -350,6 +364,7 @@ export function ExportDialog() { projectPath, "Reupload", uploadChannel, + settings.organizationId ?? null, ) : await commands.uploadExportedVideo( projectPath, @@ -357,6 +372,7 @@ export function ExportDialog() { Initial: { pre_created_video: null }, }, uploadChannel, + settings.organizationId ?? null, ); if (result === "NotAuthenticated") @@ -402,7 +418,7 @@ export function ExportDialog() { ) : ( + )} - - - - {/* Format */} -
-
-

Format

-
- - {(option) => ( - +
+ + {props.item.rawValue.name} + + {/* Show ownership indicator */} + + + Owner + + +
+ )} -
-
-
-
- {/* Frame rate */} -
-
-

Frame rate

- - options={ - settings.format === "Gif" ? GIF_FPS_OPTIONS : FPS_OPTIONS - } - optionValue="value" - optionTextValue="label" - placeholder="Select FPS" - value={(settings.format === "Gif" - ? GIF_FPS_OPTIONS - : FPS_OPTIONS - ).find((opt) => opt.value === settings.fps)} - onChange={(option) => { - const value = - option?.value ?? (settings.format === "Gif" ? 10 : 30); - trackEvent("export_fps_changed", { - fps: value, - }); - setSettings("fps", value); - }} - itemComponent={(props) => ( - - as={KSelect.Item} - item={props.item} - > - - {props.item.rawValue.label} - - - )} - > - - class="flex-1 text-sm text-left truncate tabular-nums text-[--gray-500]"> - {(state) => {state.selectedOption()?.label}} - - - as={(props) => ( - - )} - /> - - - - as={KSelect.Content} - class={cx(topSlideAnimateClasses, "z-50")} - > - - class="max-h-32 custom-scroll" - as={KSelect.Listbox} + > + + class="flex-1 text-[11px] text-left truncate text-gray-12"> + {(state) => ( + + {state.selectedOption()?.name ?? + "Select organization"} + + )} + + + as={(props) => ( + + )} /> - - - -
+ + + + as={KSelect.Content} + class={cx(topSlideAnimateClasses, "z-50")} + > + + class="max-h-32 custom-scroll" + as={KSelect.Listbox} + /> + + + +
+ - {/* Compression */} -
-
-

Compression

-
- - {(option) => ( - + + {props.item.rawValue.label} + + )} - + > + + class="flex-1 text-xs text-left truncate tabular-nums text-gray-12"> + {(state) => ( + {state.selectedOption()?.label} + )} + + + as={(props) => ( + + )} + /> + + + + as={KSelect.Content} + class={cx(topSlideAnimateClasses, "z-50")} + > + + class="max-h-32 custom-scroll" + as={KSelect.Listbox} + /> + + +
-
- {/* Resolution */} -
-
-

Resolution

-
- + + { + const option = ( settings.format === "Gif" ? [RESOLUTION_OPTIONS._720p, RESOLUTION_OPTIONS._1080p] : [ @@ -663,22 +759,69 @@ export function ExportDialog() { RESOLUTION_OPTIONS._1080p, RESOLUTION_OPTIONS._4k, ] - } - > - {(option) => ( - - )} - -
+ ).find((opt) => opt.value === v); + if (option) setSettings("resolution", option); + }} + > + + + {(option) => ( + + {option.label} + + )} + + +
+ + + +
+ + {/* Quality Row */} +
+ + { + setSettings("compression", v as ExportCompression); + }} + > + + + {(option) => ( + + {option.label} + + )} + + +
+ + +
diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index e0c44bf295..dfad481f99 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -5,9 +5,9 @@ import { } from "@solid-primitives/event-listener"; import { useSearchParams } from "@solidjs/router"; import { createQuery } from "@tanstack/solid-query"; +import { invoke } from "@tauri-apps/api/core"; import { emit } from "@tauri-apps/api/event"; import { CheckMenuItem, Menu, Submenu } from "@tauri-apps/api/menu"; -import * as dialog from "@tauri-apps/plugin-dialog"; import { cx } from "cva"; import { type ComponentProps, @@ -24,12 +24,12 @@ import { import { createStore, reconcile } from "solid-js/store"; import ModeSelect from "~/components/ModeSelect"; import { authStore, generalSettingsStore } from "~/store"; -import { createOptionsQuery } from "~/utils/queries"; -import { handleRecordingResult } from "~/utils/recording"; +import { createOptionsQuery, createOrganizationsQuery } from "~/utils/queries"; import { commands, type DisplayId, events, + Organization, type ScreenCaptureTarget, type TargetUnderCursor, } from "~/utils/tauri"; @@ -54,6 +54,7 @@ function Inner() { const [params] = useSearchParams<{ displayId: DisplayId }>(); const { rawOptions, setOptions } = createOptionsQuery(); const [toggleModeSelect, setToggleModeSelect] = createSignal(false); + const organizations = createOrganizationsQuery(); const [targetUnderCursor, setTargetUnderCursor] = createStore({ @@ -168,6 +169,30 @@ function Inner() { // Eg. on Windows Ctrl+P would open the print dialog without this createEventListener(document, "keydown", (e) => e.preventDefault()); + // Auto-select first organization if none is selected + const auth = authStore.createQuery(); + + invoke("log_message", { + level: "info", + message: `Organizations: ${JSON.stringify(auth)}`, + }).catch(console.error); + + createEffect(() => { + const orgs = organizations(); + if (!rawOptions.organizationId && orgs && orgs.length > 0 && auth.data) { + invoke("log_message", { + level: "info", + message: `Auto-selecting organization: ${orgs[0].id}`, + }).catch(console.error); + setOptions("organizationId", orgs[0].id); + } + }); + + invoke("log_message", { + level: "info", + message: `Target select overlay mounted`, + }).catch(console.error); + return ( @@ -209,7 +234,8 @@ function Inner() {
@@ -265,6 +291,7 @@ function Inner() { variant: "window", id: windowUnderCursor.id, }} + organizations={organizations()} /> + + + {/* Backdrop to close dropdown when clicking outside */} +
setIsOpen(false)} /> + {/* Dropdown menu */} +
+ {props.options.map((option) => ( + + ))} +
+ +
+ ); +} + function RecordingControls(props: { target: ScreenCaptureTarget; setToggleModeSelect?: (value: boolean) => void; + organizations: Array; }) { const auth = authStore.createQuery(); const { setOptions, rawOptions } = useRecordingOptions(); @@ -797,68 +880,100 @@ function RecordingControls(props: { return ( <> -
-
setOptions("targetMode", null)} - class="flex justify-center items-center rounded-full transition-opacity bg-gray-12 size-9 hover:opacity-80" - > - -
-
{ - if (rawOptions.mode === "instant" && !auth.data) { - emit("start-sign-in"); - return; - } +
+
+
setOptions("targetMode", null)} + class="flex justify-center items-center rounded-full transition-opacity bg-gray-12 size-9 hover:opacity-80" + > + +
+
{ + if (rawOptions.mode === "instant" && !auth.data) { + emit("start-sign-in"); + return; + } - handleRecordingResult( + invoke("log_message", { + level: "info", + message: `Starting recording with: target=${JSON.stringify(props.target)}, mode=${rawOptions.mode}, systemAudio=${rawOptions.captureSystemAudio}, organizationId=${rawOptions.organizationId ?? null}`, + }).catch(console.error); commands.startRecording({ capture_target: props.target, mode: rawOptions.mode, capture_system_audio: rawOptions.captureSystemAudio, - }), - setOptions, - ); - }} - > -
- {rawOptions.mode === "studio" ? ( - - ) : ( - - )} -
- - {rawOptions.mode === "instant" && !auth.data - ? "Sign In To Use" - : "Start Recording"} - - - {`${capitalize(rawOptions.mode)} Mode`} - + organization_id: rawOptions.organizationId ?? null, + }); + }} + > +
+ {rawOptions.mode === "studio" ? ( + + ) : ( + + )} +
+ + {rawOptions.mode === "instant" && !auth.data + ? "Sign In To Use" + : "Start Recording"} + + + {`${capitalize(rawOptions.mode)} Mode`} + +
+
+
{ + e.stopPropagation(); + menuModes().then((menu) => menu.popup()); + }} + > +
{ e.stopPropagation(); - menuModes().then((menu) => menu.popup()); + preRecordingMenu().then((menu) => menu.popup()); }} + class="flex justify-center items-center rounded-full border transition-opacity bg-gray-6 text-gray-12 size-9 hover:opacity-80" > - +
-
{ - e.stopPropagation(); - preRecordingMenu().then((menu) => menu.popup()); - }} - class="flex justify-center items-center rounded-full border transition-opacity bg-gray-6 text-gray-12 size-9 hover:opacity-80" + {/* Organization selector - appears when instant mode is selected and user has organizations */} + 1 + } > - -
+
+
+ + + setOptions("organizationId", value || null) + } + options={props.organizations.map((org) => ({ + value: org.id, + label: `${org.name}${org.ownerId === auth.data?.user_id ? " (Owner)" : ""}`, + }))} + disabled={props.organizations.length === 1} + /> +
+
+
props.setToggleModeSelect?.(true)} @@ -910,9 +1025,3 @@ function ResizeHandle( /> ); } - -function getDisplayId(displayId: string | undefined) { - const id = Number(displayId); - if (Number.isNaN(id)) return 0; - return id; -} diff --git a/apps/desktop/src/utils/queries.ts b/apps/desktop/src/utils/queries.ts index 93ad12177a..8a1caf8b9d 100644 --- a/apps/desktop/src/utils/queries.ts +++ b/apps/desktop/src/utils/queries.ts @@ -23,7 +23,7 @@ import { type RecordingMode, type ScreenCaptureTarget, } from "./tauri"; -import { orgCustomDomainClient, protectedHeaders } from "./web-api"; +import { apiClient, orgCustomDomainClient, protectedHeaders } from "./web-api"; export const listWindows = queryOptions({ queryKey: ["capture", "windows"] as const, @@ -121,6 +121,7 @@ export function createOptionsQuery() { captureSystemAudio?: boolean; targetMode?: "display" | "window" | "area" | null; cameraID?: DeviceOrModelID | null; + organizationId?: string | null; /** @deprecated */ cameraLabel: string | null; }>({ @@ -128,6 +129,7 @@ export function createOptionsQuery() { micName: null, cameraLabel: null, mode: "studio", + organizationId: null, }); createEventListener(window, "storage", (e) => { @@ -141,6 +143,7 @@ export function createOptionsQuery() { cameraId: _state.cameraID, mode: _state.mode, systemAudio: _state.captureSystemAudio, + organizationId: _state.organizationId, }); }); @@ -238,3 +241,16 @@ export function createCustomDomainQuery() { refetchOnWindowFocus: true, })); } + +export function createOrganizationsQuery() { + const auth = authStore.createQuery(); + + // Refresh organizations if they're missing + createEffect(() => { + if (auth.data?.user_id && (!auth.data?.organizations || auth.data.organizations.length === 0)) { + commands.refreshOrganizations().catch(console.error); + } + }); + + return createMemo(() => auth.data?.organizations ?? []); +} \ No newline at end of file diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index da55ae0c0e..5c34f43112 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -125,8 +125,8 @@ async doPermissionsCheck(initialCheck: boolean) : Promise { async requestPermission(permission: OSPermission) : Promise { await TAURI_INVOKE("request_permission", { permission }); }, -async uploadExportedVideo(path: string, mode: UploadMode, channel: TAURI_CHANNEL) : Promise { - return await TAURI_INVOKE("upload_exported_video", { path, mode, channel }); +async uploadExportedVideo(path: string, mode: UploadMode, channel: TAURI_CHANNEL, organizationId: string | null) : Promise { + return await TAURI_INVOKE("upload_exported_video", { path, mode, channel, organizationId }); }, async uploadScreenshot(screenshotPath: string) : Promise { return await TAURI_INVOKE("upload_screenshot", { screenshotPath }); @@ -173,6 +173,9 @@ async setTheme(theme: AppTheme) : Promise { async globalMessageDialog(message: string) : Promise { await TAURI_INVOKE("global_message_dialog", { message }); }, +async logMessage(level: string, message: string) : Promise { + await TAURI_INVOKE("log_message", { level, message }); +}, async showWindow(window: ShowCapWindow) : Promise { return await TAURI_INVOKE("show_window", { window }); }, @@ -191,6 +194,9 @@ async setFail(name: string, value: boolean) : Promise { async updateAuthPlan() : Promise { await TAURI_INVOKE("update_auth_plan"); }, +async refreshOrganizations() : Promise { + return await TAURI_INVOKE("refresh_organizations"); +}, async setWindowTransparent(value: boolean) : Promise { await TAURI_INVOKE("set_window_transparent", { value }); }, @@ -349,7 +355,7 @@ export type AudioMeta = { path: string; */ start_time?: number | null } export type AuthSecret = { api_key: string } | { token: string; expires: number } -export type AuthStore = { secret: AuthSecret; user_id: string | null; plan: Plan | null; intercom_hash: string | null } +export type AuthStore = { secret: AuthSecret; user_id: string | null; plan: Plan | null; intercom_hash: string | null; organizations?: Organization[] } export type BackgroundConfiguration = { source: BackgroundSource; blur: number; padding: number; rounding: number; inset: number; crop: Crop | null; shadow?: number; advancedShadow?: ShadowConfiguration | null; border?: BorderConfiguration | null } export type BackgroundSource = { type: "wallpaper"; path: string | null } | { type: "image"; path: string | null } | { type: "color"; value: [number, number, number] } | { type: "gradient"; from: [number, number, number]; to: [number, number, number]; angle?: number } export type BorderConfiguration = { enabled: boolean; width: number; color: [number, number, number]; opacity: number } @@ -427,6 +433,7 @@ export type OSPermission = "screenRecording" | "camera" | "microphone" | "access export type OSPermissionStatus = "notNeeded" | "empty" | "granted" | "denied" export type OSPermissionsCheck = { screenRecording: OSPermissionStatus; microphone: OSPermissionStatus; camera: OSPermissionStatus; accessibility: OSPermissionStatus } export type OnEscapePress = null +export type Organization = { id: string; name: string; ownerId: string } export type PhysicalSize = { width: number; height: number } export type Plan = { upgraded: boolean; manual: boolean; last_checked: number } export type Platform = "MacOS" | "Windows" @@ -443,7 +450,7 @@ export type RecordingMeta = (StudioRecordingMeta | InstantRecordingMeta) & { pla export type RecordingMetaWithMetadata = ((StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadMeta | null }) & { mode: RecordingMode; status: StudioRecordingStatus } export type RecordingMode = "studio" | "instant" export type RecordingOptionsChanged = null -export type RecordingSettingsStore = { target: ScreenCaptureTarget | null; micName: string | null; cameraId: DeviceOrModelID | null; mode: RecordingMode | null; systemAudio: boolean } +export type RecordingSettingsStore = { target: ScreenCaptureTarget | null; micName: string | null; cameraId: DeviceOrModelID | null; mode: RecordingMode | null; systemAudio: boolean; organizationId: string | null } export type RecordingStarted = null export type RecordingStopped = null export type RecordingTargetMode = "display" | "window" | "area" @@ -463,7 +470,7 @@ export type ShadowConfiguration = { size: number; opacity: number; blur: number export type SharingMeta = { id: string; link: string } export type ShowCapWindow = "Setup" | { Main: { init_target_mode: RecordingTargetMode | null } } | { Settings: { page: string | null } } | { Editor: { project_path: string } } | "RecordingsOverlay" | { WindowCaptureOccluder: { screen_id: DisplayId } } | { TargetSelectOverlay: { display_id: DisplayId } } | { CaptureArea: { screen_id: DisplayId } } | "Camera" | { InProgressRecording: { countdown: number | null } } | "Upgrade" | "ModeSelect" export type SingleSegment = { display: VideoMeta; camera?: VideoMeta | null; audio?: AudioMeta | null; cursor?: string | null } -export type StartRecordingInputs = { capture_target: ScreenCaptureTarget; capture_system_audio?: boolean; mode: RecordingMode } +export type StartRecordingInputs = { capture_target: ScreenCaptureTarget; capture_system_audio?: boolean; mode: RecordingMode; organization_id?: string | null } export type StereoMode = "stereo" | "monoL" | "monoR" export type StudioRecordingMeta = { segment: SingleSegment } | { inner: MultipleSegments } export type StudioRecordingStatus = { status: "InProgress" } | { status: "Failed"; error: string } | { status: "Complete" } diff --git a/apps/tasks/package.json b/apps/tasks/package.json index 2010d34899..7cb7c9e775 100644 --- a/apps/tasks/package.json +++ b/apps/tasks/package.json @@ -41,6 +41,6 @@ "ts-jest": "^29.1.2" }, "engines": { - "node": "20" + "node": ">=20" } -} +} \ No newline at end of file diff --git a/apps/web/app/api/desktop/[...route]/root.ts b/apps/web/app/api/desktop/[...route]/root.ts index 2b87779bc3..8b6b5cc177 100644 --- a/apps/web/app/api/desktop/[...route]/root.ts +++ b/apps/web/app/api/desktop/[...route]/root.ts @@ -1,10 +1,14 @@ import * as crypto from "node:crypto"; import { db } from "@cap/database"; -import { organizations, users } from "@cap/database/schema"; +import { + organizationMembers, + organizations, + users, +} from "@cap/database/schema"; import { buildEnv, serverEnv } from "@cap/env"; import { stripe, userIsPro } from "@cap/utils"; import { zValidator } from "@hono/zod-validator"; -import { eq } from "drizzle-orm"; +import { eq, or } from "drizzle-orm"; import { Hono } from "hono"; import { PostHog } from "posthog-node"; import type Stripe from "stripe"; @@ -136,6 +140,31 @@ app.get("/plan", async (c) => { }); }); +app.get("/organizations", async (c) => { + const user = c.get("user"); + + const orgs = await db() + .select({ + id: organizations.id, + name: organizations.name, + ownerId: organizations.ownerId, + }) + .from(organizations) + .leftJoin( + organizationMembers, + eq(organizations.id, organizationMembers.organizationId), + ) + .where( + or( + eq(organizations.ownerId, user.id), + eq(organizationMembers.userId, user.id), + ), + ) + .groupBy(organizations.id); + + return c.json(orgs); +}); + app.post( "/subscribe", zValidator("json", z.object({ priceId: z.string() })), diff --git a/apps/web/package.json b/apps/web/package.json index f2f594873e..303a7443af 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -151,6 +151,6 @@ "typescript": "^5.8.3" }, "engines": { - "node": "20" + "node": ">=20" } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 5f201da2f1..c058f05467 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,6 @@ "packageManager": "pnpm@10.5.2", "name": "cap", "engines": { - "node": "24" + "node": ">=20" } -} +} \ No newline at end of file diff --git a/packages/database/package.json b/packages/database/package.json index 67f007e373..4dfd055f4d 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -52,7 +52,7 @@ "typescript": "^5.8.3" }, "engines": { - "node": "20" + "node": ">=20" }, "exports": { ".": "./index.ts", @@ -75,4 +75,4 @@ "./helpers": "./dist/helpers.js" } } -} +} \ No newline at end of file diff --git a/packages/local-docker/package.json b/packages/local-docker/package.json index f4a274a680..ef83958db8 100644 --- a/packages/local-docker/package.json +++ b/packages/local-docker/package.json @@ -10,6 +10,6 @@ "docker:clean": "docker compose down -v" }, "engines": { - "node": "20" + "node": ">=20" } -} +} \ No newline at end of file diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index 6c370f546c..0eb6223950 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -65,6 +65,7 @@ declare global { const IconLucideAppWindowMac: typeof import('~icons/lucide/app-window-mac.jsx')['default'] const IconLucideBell: typeof import('~icons/lucide/bell.jsx')['default'] const IconLucideBug: typeof import('~icons/lucide/bug.jsx')['default'] + const IconLucideBuilding2: typeof import('~icons/lucide/building2.jsx')['default'] const IconLucideCheck: typeof import('~icons/lucide/check.jsx')['default'] const IconLucideClock: typeof import('~icons/lucide/clock.jsx')['default'] const IconLucideDatabase: typeof import('~icons/lucide/database.jsx')['default'] diff --git a/packages/ui/package.json b/packages/ui/package.json index 83dd10de52..3f54b001c5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -55,6 +55,6 @@ "zod": "^3" }, "engines": { - "node": "20" + "node": ">=20" } -} +} \ No newline at end of file diff --git a/packages/utils/package.json b/packages/utils/package.json index 4f201dfe51..b0acbe39dc 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -27,6 +27,6 @@ "zod": "^3" }, "engines": { - "node": "20" + "node": ">=20" } -} +} \ No newline at end of file diff --git a/packages/web-api-contract/src/desktop.ts b/packages/web-api-contract/src/desktop.ts index c0942de1d3..e85f2e7ca5 100644 --- a/packages/web-api-contract/src/desktop.ts +++ b/packages/web-api-contract/src/desktop.ts @@ -140,6 +140,19 @@ const protectedContract = c.router( query: z.object({ videoId: z.string() }), responses: { 200: z.unknown() }, }, + getOrganizations: { + method: "GET", + path: "/desktop/organizations", + responses: { + 200: z.array( + z.object({ + id: z.string(), + name: z.string(), + ownerId: z.string(), + }), + ), + }, + }, }, { baseHeaders: z.object({ authorization: z.string() }),