mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-24 23:49:55 +02:00
feat: batch profile launch/stop for paid users
This commit is contained in:
@@ -244,6 +244,52 @@ struct ImportCookiesResponse {
|
||||
errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct BatchRunRequest {
|
||||
/// Profile IDs to launch.
|
||||
profile_ids: Vec<String>,
|
||||
/// Optional URL to open in every launched profile.
|
||||
url: Option<String>,
|
||||
/// Launch headless. Defaults to false.
|
||||
headless: Option<bool>,
|
||||
}
|
||||
|
||||
#[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<u16>,
|
||||
/// Failure reason if not launched, otherwise null.
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct BatchRunResponse {
|
||||
results: Vec<BatchRunResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct BatchStopRequest {
|
||||
/// Profile IDs to stop.
|
||||
profile_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct BatchStopResponse {
|
||||
results: Vec<BatchStopResult>,
|
||||
}
|
||||
|
||||
#[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<ApiServerState>,
|
||||
Json(request): Json<BatchRunRequest>,
|
||||
) -> Result<Json<BatchRunResponse>, 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<ApiServerState>,
|
||||
Json(request): Json<BatchStopRequest>,
|
||||
) -> Result<Json<BatchStopResponse>, 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",
|
||||
|
||||
@@ -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<serde_json::Value, McpError> {
|
||||
Self::require_capability(
|
||||
"Batch launching profiles",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let profile_ids: Vec<String> = 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<String> = 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<serde_json::Value, McpError> {
|
||||
Self::require_capability(
|
||||
"Batch stopping profiles",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let profile_ids: Vec<String> = 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<String> = 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,
|
||||
|
||||
Reference in New Issue
Block a user