From 4007dedcf06fbcc30b91e2e6b66cecada443cce4 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:07:43 +0400 Subject: [PATCH] feat: batch profile launch/stop for paid users --- src-tauri/src/api_server.rs | 220 ++++++++++++++++++ src-tauri/src/mcp_server.rs | 217 +++++++++++++++++ src/app/page.tsx | 119 ++++++++++ src/components/delete-confirmation-dialog.tsx | 9 +- src/components/profile-data-table.tsx | 45 ++++ src/i18n/locales/en.json | 17 +- src/i18n/locales/es.json | 17 +- src/i18n/locales/fr.json | 17 +- src/i18n/locales/ja.json | 17 +- src/i18n/locales/ko.json | 17 +- src/i18n/locales/pt.json | 17 +- src/i18n/locales/ru.json | 17 +- src/i18n/locales/vi.json | 17 +- src/i18n/locales/zh.json | 17 +- 14 files changed, 753 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index b0b1db8..5c7379f 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -244,6 +244,52 @@ struct ImportCookiesResponse { errors: Vec, } +#[derive(Debug, Deserialize, ToSchema)] +struct BatchRunRequest { + /// Profile IDs to launch. + profile_ids: Vec, + /// Optional URL to open in every launched profile. + url: Option, + /// Launch headless. Defaults to false. + headless: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +struct BatchRunResult { + profile_id: String, + /// Whether this profile launched successfully. + ok: bool, + /// Remote debugging port if launched, otherwise null. + remote_debugging_port: Option, + /// Failure reason if not launched, otherwise null. + error: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +struct BatchRunResponse { + results: Vec, +} + +#[derive(Debug, Deserialize, ToSchema)] +struct BatchStopRequest { + /// Profile IDs to stop. + profile_ids: Vec, +} + +#[derive(Debug, Serialize, ToSchema)] +struct BatchStopResult { + profile_id: String, + /// Whether this profile was stopped successfully. + ok: bool, + /// Failure reason if not stopped, otherwise null. + error: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +struct BatchStopResponse { + results: Vec, +} + #[derive(OpenApi)] #[openapi( paths( @@ -255,6 +301,8 @@ struct ImportCookiesResponse { run_profile, open_url_in_profile, kill_profile, + batch_run_profiles, + batch_stop_profiles, import_profile_cookies, get_groups, get_group, @@ -297,6 +345,12 @@ struct ImportCookiesResponse { DownloadBrowserResponse, RunProfileResponse, RunProfileRequest, + BatchRunRequest, + BatchRunResult, + BatchRunResponse, + BatchStopRequest, + BatchStopResult, + BatchStopResponse, OpenUrlRequest, ImportCookiesRequest, ImportCookiesResponse, @@ -396,6 +450,8 @@ impl ApiServer { .routes(routes!(run_profile)) .routes(routes!(open_url_in_profile)) .routes(routes!(kill_profile)) + .routes(routes!(batch_run_profiles)) + .routes(routes!(batch_stop_profiles)) .routes(routes!(import_profile_cookies)) .routes(routes!(get_groups, create_group)) .routes(routes!(get_group, update_group, delete_group)) @@ -1951,6 +2007,170 @@ async fn kill_profile( Ok(StatusCode::NO_CONTENT) } +// API Handler - Batch run profiles (paid: browser automation). Mirrors the +// single `/run` gate; never breaks the batch on a single profile's failure — +// each profile gets its own result entry. +#[utoipa::path( + post, + path = "/v1/profiles/batch/run", + request_body = BatchRunRequest, + responses( + (status = 200, description = "Batch launch completed; inspect per-profile results", body = BatchRunResponse), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Active paid plan with browser automation required"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "profiles" +)] +async fn batch_run_profiles( + State(state): State, + Json(request): Json, +) -> Result, StatusCode> { + if !crate::cloud_auth::CLOUD_AUTH + .can_use_browser_automation() + .await + { + return Err(StatusCode::PAYMENT_REQUIRED); + } + + let headless = request.headless.unwrap_or(false); + let profile_manager = ProfileManager::instance(); + let profiles = profile_manager + .list_profiles() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let mut results = Vec::with_capacity(request.profile_ids.len()); + for profile_id in &request.profile_ids { + let fail = |error: &str| BatchRunResult { + profile_id: profile_id.clone(), + ok: false, + remote_debugging_port: None, + error: Some(error.to_string()), + }; + + let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else { + results.push(fail("profile not found")); + continue; + }; + if profile.is_cross_os() { + results.push(fail("cross-OS profiles cannot be launched")); + continue; + } + if crate::team_lock::acquire_team_lock_if_needed(profile) + .await + .is_err() + { + results.push(fail("profile is locked by another team member")); + continue; + } + + let port = match tokio::net::TcpListener::bind("127.0.0.1:0").await { + Ok(listener) => match listener.local_addr() { + Ok(addr) => addr.port(), + Err(_) => { + results.push(fail("failed to allocate debugging port")); + continue; + } + }, + Err(_) => { + results.push(fail("failed to allocate debugging port")); + continue; + } + }; + + match crate::browser_runner::launch_browser_profile_impl( + state.app_handle.clone(), + profile.clone(), + request.url.clone(), + Some(port), + headless, + true, + ) + .await + { + Ok(_) => results.push(BatchRunResult { + profile_id: profile_id.clone(), + ok: true, + remote_debugging_port: Some(port), + error: None, + }), + Err(e) => results.push(fail(&format!("launch failed: {e}"))), + } + } + + Ok(Json(BatchRunResponse { results })) +} + +// API Handler - Batch stop profiles (paid: browser automation). +#[utoipa::path( + post, + path = "/v1/profiles/batch/stop", + request_body = BatchStopRequest, + responses( + (status = 200, description = "Batch stop completed; inspect per-profile results", body = BatchStopResponse), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Active paid plan with browser automation required"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "profiles" +)] +async fn batch_stop_profiles( + State(state): State, + Json(request): Json, +) -> Result, StatusCode> { + if !crate::cloud_auth::CLOUD_AUTH + .can_use_browser_automation() + .await + { + return Err(StatusCode::PAYMENT_REQUIRED); + } + + let profile_manager = ProfileManager::instance(); + let profiles = profile_manager + .list_profiles() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let browser_runner = crate::browser_runner::BrowserRunner::instance(); + + let mut results = Vec::with_capacity(request.profile_ids.len()); + for profile_id in &request.profile_ids { + let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else { + results.push(BatchStopResult { + profile_id: profile_id.clone(), + ok: false, + error: Some("profile not found".to_string()), + }); + continue; + }; + + match browser_runner + .kill_browser_process(state.app_handle.clone(), profile) + .await + { + Ok(_) => { + crate::team_lock::release_team_lock_if_needed(profile).await; + results.push(BatchStopResult { + profile_id: profile_id.clone(), + ok: true, + error: None, + }); + } + Err(e) => results.push(BatchStopResult { + profile_id: profile_id.clone(), + ok: false, + error: Some(format!("stop failed: {e}")), + }), + } + } + + Ok(Json(BatchStopResponse { results })) +} + #[utoipa::path( post, path = "/v1/profiles/{id}/cookies/import", diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index b7de4ea..e3c1958 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -564,6 +564,44 @@ impl McpServer { "required": ["profile_id"] }), }, + McpTool { + name: "batch_run_profiles".to_string(), + description: "Launch multiple browser profiles at once with an optional URL. Requires an active Pro subscription.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "UUIDs of the profiles to launch" + }, + "url": { + "type": "string", + "description": "Optional URL to open in every launched profile" + }, + "headless": { + "type": "boolean", + "description": "Run the browsers in headless mode" + } + }, + "required": ["profile_ids"] + }), + }, + McpTool { + name: "batch_stop_profiles".to_string(), + description: "Stop multiple running browser profiles at once. Requires an active Pro subscription.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "UUIDs of the profiles to stop" + } + }, + "required": ["profile_ids"] + }), + }, McpTool { name: "create_profile".to_string(), description: "Create a new browser profile".to_string(), @@ -1676,6 +1714,22 @@ impl McpServer { .await?; self.handle_kill_profile(arguments).await } + "batch_run_profiles" => { + Self::require_capability( + "Browser automation", + CLOUD_AUTH.can_use_browser_automation().await, + ) + .await?; + self.handle_batch_run_profiles(arguments).await + } + "batch_stop_profiles" => { + Self::require_capability( + "Browser automation", + CLOUD_AUTH.can_use_browser_automation().await, + ) + .await?; + self.handle_batch_stop_profiles(arguments).await + } "create_profile" => self.handle_create_profile(arguments).await, "update_profile" => self.handle_update_profile(arguments).await, "delete_profile" => self.handle_delete_profile(arguments).await, @@ -2062,6 +2116,169 @@ impl McpServer { })) } + async fn handle_batch_run_profiles( + &self, + arguments: &serde_json::Value, + ) -> Result { + Self::require_capability( + "Batch launching profiles", + CLOUD_AUTH.can_use_browser_automation().await, + ) + .await?; + + let profile_ids: Vec = arguments + .get("profile_ids") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing profile_ids array".to_string(), + })?; + + let url = arguments.get("url").and_then(|v| v.as_str()); + let headless = arguments + .get("headless") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let profiles = ProfileManager::instance() + .list_profiles() + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to list profiles: {e}"), + })?; + + // Clone the app handle and release the lock before the launch loop so we + // never hold the inner mutex across the per-profile awaits. + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + })? + .clone() + }; + + let mut launched = 0usize; + let mut lines: Vec = Vec::with_capacity(profile_ids.len()); + for profile_id in &profile_ids { + let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else { + lines.push(format!("{profile_id}: not found")); + continue; + }; + if profile.browser != "wayfern" && profile.browser != "camoufox" { + lines.push(format!( + "{profile_id}: unsupported browser (MCP supports Wayfern/Camoufox)" + )); + continue; + } + if let Err(e) = crate::team_lock::acquire_team_lock_if_needed(profile).await { + lines.push(format!("{profile_id}: {e}")); + continue; + } + match crate::browser_runner::launch_browser_profile_impl( + app_handle.clone(), + profile.clone(), + url.map(|s| s.to_string()), + None, + headless, + true, + ) + .await + { + Ok(_) => { + launched += 1; + lines.push(format!("{}: launched", profile.name)); + } + Err(e) => lines.push(format!("{}: launch failed: {e}", profile.name)), + } + } + + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": format!("Launched {}/{} profile(s):\n{}", launched, profile_ids.len(), lines.join("\n")) + }] + })) + } + + async fn handle_batch_stop_profiles( + &self, + arguments: &serde_json::Value, + ) -> Result { + Self::require_capability( + "Batch stopping profiles", + CLOUD_AUTH.can_use_browser_automation().await, + ) + .await?; + + let profile_ids: Vec = arguments + .get("profile_ids") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing profile_ids array".to_string(), + })?; + + let profiles = ProfileManager::instance() + .list_profiles() + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to list profiles: {e}"), + })?; + + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + })? + .clone() + }; + + let mut stopped = 0usize; + let mut lines: Vec = Vec::with_capacity(profile_ids.len()); + for profile_id in &profile_ids { + let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else { + lines.push(format!("{profile_id}: not found")); + continue; + }; + match crate::browser_runner::BrowserRunner::instance() + .kill_browser_process(app_handle.clone(), profile) + .await + { + Ok(_) => { + crate::team_lock::release_team_lock_if_needed(profile).await; + stopped += 1; + lines.push(format!("{}: stopped", profile.name)); + } + Err(e) => lines.push(format!("{}: stop failed: {e}", profile.name)), + } + } + + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": format!("Stopped {}/{} profile(s):\n{}", stopped, profile_ids.len(), lines.join("\n")) + }] + })) + } + async fn handle_create_profile( &self, arguments: &serde_json::Value, diff --git a/src/app/page.tsx b/src/app/page.tsx index f84d454..6538976 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -228,6 +228,10 @@ export default function Home() { // Cloud auth for cross-OS unlock const { user: cloudUser } = useCloudAuth(); const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints; + // Bulk run/stop is a paid (browser automation) feature, matching the + // /v1/profiles/batch/run API gate. Free/starter users see the bulk Run/Stop + // actions disabled with a Pro badge. + const automationUnlocked = getEntitlements(cloudUser).browserAutomation; const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] = useState(false); @@ -1128,6 +1132,75 @@ export default function Home() { setCookieCopyDialogOpen(true); }, [selectedProfiles, profiles, t]); + const [pendingBulkAction, setPendingBulkAction] = useState<{ + action: "run" | "stop"; + profiles: BrowserProfile[]; + } | null>(null); + const [isBulkActing, setIsBulkActing] = useState(false); + + const executeBulkRun = useCallback( + async (targets: BrowserProfile[]) => { + setIsBulkActing(true); + try { + await Promise.allSettled(targets.map((p) => launchProfile(p))); + setSelectedProfiles([]); + } finally { + setIsBulkActing(false); + setPendingBulkAction(null); + } + }, + [launchProfile], + ); + + const executeBulkStop = useCallback( + async (targets: BrowserProfile[]) => { + setIsBulkActing(true); + try { + await Promise.allSettled(targets.map((p) => handleKillProfile(p))); + setSelectedProfiles([]); + } finally { + setIsBulkActing(false); + setPendingBulkAction(null); + } + }, + [handleKillProfile], + ); + + // Bulk run/stop only touch eligible profiles (run: not already running; + // stop: currently running). An empty result shows a toast instead of a silent + // no-op (guard), and 10+ targets require confirmation before launching/stopping. + const handleBulkRun = useCallback(() => { + if (selectedProfiles.length === 0) return; + const targets = profiles.filter( + (p) => selectedProfiles.includes(p.id) && !runningProfiles.has(p.id), + ); + if (targets.length === 0) { + showErrorToast(t("profiles.bulkRun.noneToRun")); + return; + } + if (targets.length >= 10) { + setPendingBulkAction({ action: "run", profiles: targets }); + return; + } + void executeBulkRun(targets); + }, [selectedProfiles, profiles, runningProfiles, executeBulkRun, t]); + + const handleBulkStop = useCallback(() => { + if (selectedProfiles.length === 0) return; + const targets = profiles.filter( + (p) => selectedProfiles.includes(p.id) && runningProfiles.has(p.id), + ); + if (targets.length === 0) { + showErrorToast(t("profiles.bulkStop.noneToStop")); + return; + } + if (targets.length >= 10) { + setPendingBulkAction({ action: "stop", profiles: targets }); + return; + } + void executeBulkStop(targets); + }, [selectedProfiles, profiles, runningProfiles, executeBulkStop, t]); + const handleCopyCookiesToProfile = useCallback((profile: BrowserProfile) => { setSelectedProfilesForCookies([profile.id]); setCookieCopyDialogOpen(true); @@ -1569,6 +1642,9 @@ export default function Home() { onBulkGroupAssignment={handleBulkGroupAssignment} onBulkProxyAssignment={handleBulkProxyAssignment} onBulkCopyCookies={handleBulkCopyCookies} + onBulkRun={handleBulkRun} + onBulkStop={handleBulkStop} + bulkActionsUnlocked={automationUnlocked} onBulkExtensionGroupAssignment={ handleBulkExtensionGroupAssignment } @@ -1868,6 +1944,49 @@ export default function Home() { profile={currentProfileForCookieManagement} /> + { + setPendingBulkAction(null); + }} + onConfirm={() => { + if (!pendingBulkAction) return; + if (pendingBulkAction.action === "run") { + void executeBulkRun(pendingBulkAction.profiles); + } else { + void executeBulkStop(pendingBulkAction.profiles); + } + }} + title={ + pendingBulkAction?.action === "stop" + ? t("profiles.bulkStop.confirmTitle", { + count: pendingBulkAction?.profiles.length ?? 0, + }) + : t("profiles.bulkRun.confirmTitle", { + count: pendingBulkAction?.profiles.length ?? 0, + }) + } + description={ + pendingBulkAction?.action === "stop" + ? t("profiles.bulkStop.confirmDescription", { + count: pendingBulkAction?.profiles.length ?? 0, + }) + : t("profiles.bulkRun.confirmDescription", { + count: pendingBulkAction?.profiles.length ?? 0, + }) + } + confirmButtonText={ + pendingBulkAction?.action === "stop" + ? t("profiles.bulkStop.confirmButton", { + count: pendingBulkAction?.profiles.length ?? 0, + }) + : t("profiles.bulkRun.confirmButton", { + count: pendingBulkAction?.profiles.length ?? 0, + }) + } + confirmButtonVariant="default" + isLoading={isBulkActing} + /> { diff --git a/src/components/delete-confirmation-dialog.tsx b/src/components/delete-confirmation-dialog.tsx index db50e66..098b256 100644 --- a/src/components/delete-confirmation-dialog.tsx +++ b/src/components/delete-confirmation-dialog.tsx @@ -19,6 +19,12 @@ interface DeleteConfirmationDialogProps { title: string; description: string; confirmButtonText?: string; + confirmButtonVariant?: + | "default" + | "destructive" + | "outline" + | "secondary" + | "ghost"; isLoading?: boolean; profileIds?: string[]; profiles?: { id: string; name: string }[]; @@ -31,6 +37,7 @@ export function DeleteConfirmationDialog({ title, description, confirmButtonText, + confirmButtonVariant = "destructive", isLoading = false, profileIds, profiles = [], @@ -79,7 +86,7 @@ export function DeleteConfirmationDialog({ {t("common.buttons.cancel")} void handleConfirm()} isLoading={isLoading} > diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 94837ef..00521bb 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -56,6 +56,7 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { ProBadge } from "@/components/ui/pro-badge"; import { Table, TableBody, @@ -1134,6 +1135,9 @@ interface ProfilesDataTableProps { onBulkGroupAssignment?: () => void; onBulkProxyAssignment?: () => void; onBulkCopyCookies?: () => void; + onBulkRun?: () => void; + onBulkStop?: () => void; + bulkActionsUnlocked?: boolean; onBulkExtensionGroupAssignment?: () => void; onAssignExtensionGroup?: (profileIds: string[]) => void; onOpenProfileSyncDialog?: (profile: BrowserProfile) => void; @@ -1179,6 +1183,9 @@ export function ProfilesDataTable({ onBulkGroupAssignment, onBulkProxyAssignment, onBulkCopyCookies, + onBulkRun, + onBulkStop, + bulkActionsUnlocked = false, onBulkExtensionGroupAssignment, onAssignExtensionGroup, onOpenProfileSyncDialog, @@ -3223,6 +3230,44 @@ export function ProfilesDataTable({ })()} + {onBulkRun && ( + + + + + {!bulkActionsUnlocked && ( + + )} + + )} + {onBulkStop && ( + + + + + {!bulkActionsUnlocked && ( + + )} + + )} {onBulkGroupAssignment && (