mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-09 12:46:13 +02:00
feat: batch profile launch/stop for paid users
This commit is contained in:
@@ -244,6 +244,52 @@ struct ImportCookiesResponse {
|
|||||||
errors: Vec<String>,
|
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)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(
|
paths(
|
||||||
@@ -255,6 +301,8 @@ struct ImportCookiesResponse {
|
|||||||
run_profile,
|
run_profile,
|
||||||
open_url_in_profile,
|
open_url_in_profile,
|
||||||
kill_profile,
|
kill_profile,
|
||||||
|
batch_run_profiles,
|
||||||
|
batch_stop_profiles,
|
||||||
import_profile_cookies,
|
import_profile_cookies,
|
||||||
get_groups,
|
get_groups,
|
||||||
get_group,
|
get_group,
|
||||||
@@ -297,6 +345,12 @@ struct ImportCookiesResponse {
|
|||||||
DownloadBrowserResponse,
|
DownloadBrowserResponse,
|
||||||
RunProfileResponse,
|
RunProfileResponse,
|
||||||
RunProfileRequest,
|
RunProfileRequest,
|
||||||
|
BatchRunRequest,
|
||||||
|
BatchRunResult,
|
||||||
|
BatchRunResponse,
|
||||||
|
BatchStopRequest,
|
||||||
|
BatchStopResult,
|
||||||
|
BatchStopResponse,
|
||||||
OpenUrlRequest,
|
OpenUrlRequest,
|
||||||
ImportCookiesRequest,
|
ImportCookiesRequest,
|
||||||
ImportCookiesResponse,
|
ImportCookiesResponse,
|
||||||
@@ -396,6 +450,8 @@ impl ApiServer {
|
|||||||
.routes(routes!(run_profile))
|
.routes(routes!(run_profile))
|
||||||
.routes(routes!(open_url_in_profile))
|
.routes(routes!(open_url_in_profile))
|
||||||
.routes(routes!(kill_profile))
|
.routes(routes!(kill_profile))
|
||||||
|
.routes(routes!(batch_run_profiles))
|
||||||
|
.routes(routes!(batch_stop_profiles))
|
||||||
.routes(routes!(import_profile_cookies))
|
.routes(routes!(import_profile_cookies))
|
||||||
.routes(routes!(get_groups, create_group))
|
.routes(routes!(get_groups, create_group))
|
||||||
.routes(routes!(get_group, update_group, delete_group))
|
.routes(routes!(get_group, update_group, delete_group))
|
||||||
@@ -1951,6 +2007,170 @@ async fn kill_profile(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
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(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/v1/profiles/{id}/cookies/import",
|
path = "/v1/profiles/{id}/cookies/import",
|
||||||
|
|||||||
@@ -564,6 +564,44 @@ impl McpServer {
|
|||||||
"required": ["profile_id"]
|
"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 {
|
McpTool {
|
||||||
name: "create_profile".to_string(),
|
name: "create_profile".to_string(),
|
||||||
description: "Create a new browser profile".to_string(),
|
description: "Create a new browser profile".to_string(),
|
||||||
@@ -1676,6 +1714,22 @@ impl McpServer {
|
|||||||
.await?;
|
.await?;
|
||||||
self.handle_kill_profile(arguments).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,
|
"create_profile" => self.handle_create_profile(arguments).await,
|
||||||
"update_profile" => self.handle_update_profile(arguments).await,
|
"update_profile" => self.handle_update_profile(arguments).await,
|
||||||
"delete_profile" => self.handle_delete_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(
|
async fn handle_create_profile(
|
||||||
&self,
|
&self,
|
||||||
arguments: &serde_json::Value,
|
arguments: &serde_json::Value,
|
||||||
|
|||||||
@@ -228,6 +228,10 @@ export default function Home() {
|
|||||||
// Cloud auth for cross-OS unlock
|
// Cloud auth for cross-OS unlock
|
||||||
const { user: cloudUser } = useCloudAuth();
|
const { user: cloudUser } = useCloudAuth();
|
||||||
const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints;
|
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] =
|
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@@ -1128,6 +1132,75 @@ export default function Home() {
|
|||||||
setCookieCopyDialogOpen(true);
|
setCookieCopyDialogOpen(true);
|
||||||
}, [selectedProfiles, profiles, t]);
|
}, [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) => {
|
const handleCopyCookiesToProfile = useCallback((profile: BrowserProfile) => {
|
||||||
setSelectedProfilesForCookies([profile.id]);
|
setSelectedProfilesForCookies([profile.id]);
|
||||||
setCookieCopyDialogOpen(true);
|
setCookieCopyDialogOpen(true);
|
||||||
@@ -1569,6 +1642,9 @@ export default function Home() {
|
|||||||
onBulkGroupAssignment={handleBulkGroupAssignment}
|
onBulkGroupAssignment={handleBulkGroupAssignment}
|
||||||
onBulkProxyAssignment={handleBulkProxyAssignment}
|
onBulkProxyAssignment={handleBulkProxyAssignment}
|
||||||
onBulkCopyCookies={handleBulkCopyCookies}
|
onBulkCopyCookies={handleBulkCopyCookies}
|
||||||
|
onBulkRun={handleBulkRun}
|
||||||
|
onBulkStop={handleBulkStop}
|
||||||
|
bulkActionsUnlocked={automationUnlocked}
|
||||||
onBulkExtensionGroupAssignment={
|
onBulkExtensionGroupAssignment={
|
||||||
handleBulkExtensionGroupAssignment
|
handleBulkExtensionGroupAssignment
|
||||||
}
|
}
|
||||||
@@ -1868,6 +1944,49 @@ export default function Home() {
|
|||||||
profile={currentProfileForCookieManagement}
|
profile={currentProfileForCookieManagement}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeleteConfirmationDialog
|
||||||
|
isOpen={pendingBulkAction !== null}
|
||||||
|
onClose={() => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
<DeleteConfirmationDialog
|
<DeleteConfirmationDialog
|
||||||
isOpen={showBulkDeleteConfirmation}
|
isOpen={showBulkDeleteConfirmation}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ interface DeleteConfirmationDialogProps {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
confirmButtonText?: string;
|
confirmButtonText?: string;
|
||||||
|
confirmButtonVariant?:
|
||||||
|
| "default"
|
||||||
|
| "destructive"
|
||||||
|
| "outline"
|
||||||
|
| "secondary"
|
||||||
|
| "ghost";
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
profileIds?: string[];
|
profileIds?: string[];
|
||||||
profiles?: { id: string; name: string }[];
|
profiles?: { id: string; name: string }[];
|
||||||
@@ -31,6 +37,7 @@ export function DeleteConfirmationDialog({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
confirmButtonText,
|
confirmButtonText,
|
||||||
|
confirmButtonVariant = "destructive",
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
profileIds,
|
profileIds,
|
||||||
profiles = [],
|
profiles = [],
|
||||||
@@ -79,7 +86,7 @@ export function DeleteConfirmationDialog({
|
|||||||
{t("common.buttons.cancel")}
|
{t("common.buttons.cancel")}
|
||||||
</RippleButton>
|
</RippleButton>
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
variant="destructive"
|
variant={confirmButtonVariant}
|
||||||
onClick={() => void handleConfirm()}
|
onClick={() => void handleConfirm()}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
|
import { ProBadge } from "@/components/ui/pro-badge";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -1134,6 +1135,9 @@ interface ProfilesDataTableProps {
|
|||||||
onBulkGroupAssignment?: () => void;
|
onBulkGroupAssignment?: () => void;
|
||||||
onBulkProxyAssignment?: () => void;
|
onBulkProxyAssignment?: () => void;
|
||||||
onBulkCopyCookies?: () => void;
|
onBulkCopyCookies?: () => void;
|
||||||
|
onBulkRun?: () => void;
|
||||||
|
onBulkStop?: () => void;
|
||||||
|
bulkActionsUnlocked?: boolean;
|
||||||
onBulkExtensionGroupAssignment?: () => void;
|
onBulkExtensionGroupAssignment?: () => void;
|
||||||
onAssignExtensionGroup?: (profileIds: string[]) => void;
|
onAssignExtensionGroup?: (profileIds: string[]) => void;
|
||||||
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
|
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
|
||||||
@@ -1179,6 +1183,9 @@ export function ProfilesDataTable({
|
|||||||
onBulkGroupAssignment,
|
onBulkGroupAssignment,
|
||||||
onBulkProxyAssignment,
|
onBulkProxyAssignment,
|
||||||
onBulkCopyCookies,
|
onBulkCopyCookies,
|
||||||
|
onBulkRun,
|
||||||
|
onBulkStop,
|
||||||
|
bulkActionsUnlocked = false,
|
||||||
onBulkExtensionGroupAssignment,
|
onBulkExtensionGroupAssignment,
|
||||||
onAssignExtensionGroup,
|
onAssignExtensionGroup,
|
||||||
onOpenProfileSyncDialog,
|
onOpenProfileSyncDialog,
|
||||||
@@ -3223,6 +3230,44 @@ export function ProfilesDataTable({
|
|||||||
})()}
|
})()}
|
||||||
<DataTableActionBar table={table}>
|
<DataTableActionBar table={table}>
|
||||||
<DataTableActionBarSelection table={table} />
|
<DataTableActionBarSelection table={table} />
|
||||||
|
{onBulkRun && (
|
||||||
|
<span className="relative inline-flex">
|
||||||
|
<DataTableActionBarAction
|
||||||
|
tooltip={
|
||||||
|
bulkActionsUnlocked
|
||||||
|
? t("profiles.actionBar.runSelected")
|
||||||
|
: t("profiles.actionBar.proRequired")
|
||||||
|
}
|
||||||
|
onClick={bulkActionsUnlocked ? onBulkRun : undefined}
|
||||||
|
disabled={!bulkActionsUnlocked}
|
||||||
|
size="icon"
|
||||||
|
>
|
||||||
|
<LuPlay className="fill-current" />
|
||||||
|
</DataTableActionBarAction>
|
||||||
|
{!bulkActionsUnlocked && (
|
||||||
|
<ProBadge className="absolute -top-2 -right-2 pointer-events-none" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{onBulkStop && (
|
||||||
|
<span className="relative inline-flex">
|
||||||
|
<DataTableActionBarAction
|
||||||
|
tooltip={
|
||||||
|
bulkActionsUnlocked
|
||||||
|
? t("profiles.actionBar.stopSelected")
|
||||||
|
: t("profiles.actionBar.proRequired")
|
||||||
|
}
|
||||||
|
onClick={bulkActionsUnlocked ? onBulkStop : undefined}
|
||||||
|
disabled={!bulkActionsUnlocked}
|
||||||
|
size="icon"
|
||||||
|
>
|
||||||
|
<LuSquare className="fill-current" />
|
||||||
|
</DataTableActionBarAction>
|
||||||
|
{!bulkActionsUnlocked && (
|
||||||
|
<ProBadge className="absolute -top-2 -right-2 pointer-events-none" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{onBulkGroupAssignment && (
|
{onBulkGroupAssignment && (
|
||||||
<DataTableActionBarAction
|
<DataTableActionBarAction
|
||||||
tooltip={t("profiles.actionBar.assignToGroup")}
|
tooltip={t("profiles.actionBar.assignToGroup")}
|
||||||
|
|||||||
@@ -305,11 +305,26 @@
|
|||||||
"assignToGroup": "Assign to Group",
|
"assignToGroup": "Assign to Group",
|
||||||
"assignProxy": "Assign Proxy",
|
"assignProxy": "Assign Proxy",
|
||||||
"assignExtensionGroup": "Assign Extension Group",
|
"assignExtensionGroup": "Assign Extension Group",
|
||||||
"copyCookies": "Copy Cookies"
|
"copyCookies": "Copy Cookies",
|
||||||
|
"runSelected": "Run selected",
|
||||||
|
"stopSelected": "Stop selected",
|
||||||
|
"proRequired": "Pro plan required for bulk run/stop"
|
||||||
},
|
},
|
||||||
"passwordProtectedBadge": "Password Protected",
|
"passwordProtectedBadge": "Password Protected",
|
||||||
"launchHook": {
|
"launchHook": {
|
||||||
"placeholder": "https://example.com/track-launch"
|
"placeholder": "https://example.com/track-launch"
|
||||||
|
},
|
||||||
|
"bulkRun": {
|
||||||
|
"confirmTitle": "Run {{count}} profiles?",
|
||||||
|
"confirmDescription": "Launching {{count}} profiles at once can use a lot of system resources. Continue?",
|
||||||
|
"confirmButton": "Run {{count}}",
|
||||||
|
"noneToRun": "No selected profiles to run."
|
||||||
|
},
|
||||||
|
"bulkStop": {
|
||||||
|
"confirmTitle": "Stop {{count}} profiles?",
|
||||||
|
"confirmDescription": "Stop {{count}} running profiles?",
|
||||||
|
"confirmButton": "Stop {{count}}",
|
||||||
|
"noneToStop": "No selected profiles to stop."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"createProfile": {
|
"createProfile": {
|
||||||
|
|||||||
@@ -305,11 +305,26 @@
|
|||||||
"assignToGroup": "Asignar a grupo",
|
"assignToGroup": "Asignar a grupo",
|
||||||
"assignProxy": "Asignar proxy",
|
"assignProxy": "Asignar proxy",
|
||||||
"assignExtensionGroup": "Asignar grupo de extensiones",
|
"assignExtensionGroup": "Asignar grupo de extensiones",
|
||||||
"copyCookies": "Copiar cookies"
|
"copyCookies": "Copiar cookies",
|
||||||
|
"runSelected": "Ejecutar seleccionados",
|
||||||
|
"stopSelected": "Detener seleccionados",
|
||||||
|
"proRequired": "Se requiere el plan Pro para ejecución/parada masiva"
|
||||||
},
|
},
|
||||||
"passwordProtectedBadge": "Protegido por Contraseña",
|
"passwordProtectedBadge": "Protegido por Contraseña",
|
||||||
"launchHook": {
|
"launchHook": {
|
||||||
"placeholder": "https://example.com/track-launch"
|
"placeholder": "https://example.com/track-launch"
|
||||||
|
},
|
||||||
|
"bulkRun": {
|
||||||
|
"confirmTitle": "¿Ejecutar {{count}} perfiles?",
|
||||||
|
"confirmDescription": "Iniciar {{count}} perfiles a la vez puede consumir muchos recursos del sistema. ¿Continuar?",
|
||||||
|
"confirmButton": "Ejecutar {{count}}",
|
||||||
|
"noneToRun": "No hay perfiles seleccionados para ejecutar."
|
||||||
|
},
|
||||||
|
"bulkStop": {
|
||||||
|
"confirmTitle": "¿Detener {{count}} perfiles?",
|
||||||
|
"confirmDescription": "¿Detener {{count}} perfiles en ejecución?",
|
||||||
|
"confirmButton": "Detener {{count}}",
|
||||||
|
"noneToStop": "No hay perfiles seleccionados para detener."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"createProfile": {
|
"createProfile": {
|
||||||
|
|||||||
@@ -305,11 +305,26 @@
|
|||||||
"assignToGroup": "Assigner à un groupe",
|
"assignToGroup": "Assigner à un groupe",
|
||||||
"assignProxy": "Assigner un proxy",
|
"assignProxy": "Assigner un proxy",
|
||||||
"assignExtensionGroup": "Assigner un groupe d’extensions",
|
"assignExtensionGroup": "Assigner un groupe d’extensions",
|
||||||
"copyCookies": "Copier les cookies"
|
"copyCookies": "Copier les cookies",
|
||||||
|
"runSelected": "Lancer la sélection",
|
||||||
|
"stopSelected": "Arrêter la sélection",
|
||||||
|
"proRequired": "Plan Pro requis pour le lancement/arrêt groupé"
|
||||||
},
|
},
|
||||||
"passwordProtectedBadge": "Protégé par mot de passe",
|
"passwordProtectedBadge": "Protégé par mot de passe",
|
||||||
"launchHook": {
|
"launchHook": {
|
||||||
"placeholder": "https://example.com/track-launch"
|
"placeholder": "https://example.com/track-launch"
|
||||||
|
},
|
||||||
|
"bulkRun": {
|
||||||
|
"confirmTitle": "Lancer {{count}} profils ?",
|
||||||
|
"confirmDescription": "Lancer {{count}} profils à la fois peut consommer beaucoup de ressources système. Continuer ?",
|
||||||
|
"confirmButton": "Lancer {{count}}",
|
||||||
|
"noneToRun": "Aucun profil sélectionné à lancer."
|
||||||
|
},
|
||||||
|
"bulkStop": {
|
||||||
|
"confirmTitle": "Arrêter {{count}} profils ?",
|
||||||
|
"confirmDescription": "Arrêter {{count}} profils en cours d’exécution ?",
|
||||||
|
"confirmButton": "Arrêter {{count}}",
|
||||||
|
"noneToStop": "Aucun profil sélectionné à arrêter."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"createProfile": {
|
"createProfile": {
|
||||||
|
|||||||
@@ -305,11 +305,26 @@
|
|||||||
"assignToGroup": "グループに割り当て",
|
"assignToGroup": "グループに割り当て",
|
||||||
"assignProxy": "プロキシを割り当て",
|
"assignProxy": "プロキシを割り当て",
|
||||||
"assignExtensionGroup": "拡張機能グループを割り当て",
|
"assignExtensionGroup": "拡張機能グループを割り当て",
|
||||||
"copyCookies": "Cookieをコピー"
|
"copyCookies": "Cookieをコピー",
|
||||||
|
"runSelected": "選択を実行",
|
||||||
|
"stopSelected": "選択を停止",
|
||||||
|
"proRequired": "一括実行・停止には Pro プランが必要です"
|
||||||
},
|
},
|
||||||
"passwordProtectedBadge": "パスワード保護",
|
"passwordProtectedBadge": "パスワード保護",
|
||||||
"launchHook": {
|
"launchHook": {
|
||||||
"placeholder": "https://example.com/track-launch"
|
"placeholder": "https://example.com/track-launch"
|
||||||
|
},
|
||||||
|
"bulkRun": {
|
||||||
|
"confirmTitle": "{{count}} 個のプロファイルを実行しますか?",
|
||||||
|
"confirmDescription": "{{count}} 個のプロファイルを一度に起動すると、システムリソースを大量に消費する可能性があります。続行しますか?",
|
||||||
|
"confirmButton": "{{count}} 個を実行",
|
||||||
|
"noneToRun": "実行する選択中のプロファイルがありません。"
|
||||||
|
},
|
||||||
|
"bulkStop": {
|
||||||
|
"confirmTitle": "{{count}} 個のプロファイルを停止しますか?",
|
||||||
|
"confirmDescription": "実行中の {{count}} 個のプロファイルを停止しますか?",
|
||||||
|
"confirmButton": "{{count}} 個を停止",
|
||||||
|
"noneToStop": "停止する選択中のプロファイルがありません。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"createProfile": {
|
"createProfile": {
|
||||||
|
|||||||
@@ -305,11 +305,26 @@
|
|||||||
"assignToGroup": "그룹에 할당",
|
"assignToGroup": "그룹에 할당",
|
||||||
"assignProxy": "프록시 할당",
|
"assignProxy": "프록시 할당",
|
||||||
"assignExtensionGroup": "확장 프로그램 그룹 할당",
|
"assignExtensionGroup": "확장 프로그램 그룹 할당",
|
||||||
"copyCookies": "쿠키 복사"
|
"copyCookies": "쿠키 복사",
|
||||||
|
"runSelected": "선택 실행",
|
||||||
|
"stopSelected": "선택 중지",
|
||||||
|
"proRequired": "대량 실행/중지하려면 Pro 플랜이 필요합니다"
|
||||||
},
|
},
|
||||||
"passwordProtectedBadge": "비밀번호 보호됨",
|
"passwordProtectedBadge": "비밀번호 보호됨",
|
||||||
"launchHook": {
|
"launchHook": {
|
||||||
"placeholder": "https://example.com/track-launch"
|
"placeholder": "https://example.com/track-launch"
|
||||||
|
},
|
||||||
|
"bulkRun": {
|
||||||
|
"confirmTitle": "{{count}}개의 프로필을 실행할까요?",
|
||||||
|
"confirmDescription": "{{count}}개의 프로필을 한 번에 실행하면 시스템 리소스를 많이 사용할 수 있습니다. 계속할까요?",
|
||||||
|
"confirmButton": "{{count}}개 실행",
|
||||||
|
"noneToRun": "실행할 선택된 프로필이 없습니다."
|
||||||
|
},
|
||||||
|
"bulkStop": {
|
||||||
|
"confirmTitle": "{{count}}개의 프로필을 중지할까요?",
|
||||||
|
"confirmDescription": "실행 중인 {{count}}개의 프로필을 중지할까요?",
|
||||||
|
"confirmButton": "{{count}}개 중지",
|
||||||
|
"noneToStop": "중지할 선택된 프로필이 없습니다."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"createProfile": {
|
"createProfile": {
|
||||||
|
|||||||
@@ -305,11 +305,26 @@
|
|||||||
"assignToGroup": "Atribuir a grupo",
|
"assignToGroup": "Atribuir a grupo",
|
||||||
"assignProxy": "Atribuir proxy",
|
"assignProxy": "Atribuir proxy",
|
||||||
"assignExtensionGroup": "Atribuir grupo de extensões",
|
"assignExtensionGroup": "Atribuir grupo de extensões",
|
||||||
"copyCookies": "Copiar cookies"
|
"copyCookies": "Copiar cookies",
|
||||||
|
"runSelected": "Executar selecionados",
|
||||||
|
"stopSelected": "Parar selecionados",
|
||||||
|
"proRequired": "Plano Pro necessário para execução/parada em massa"
|
||||||
},
|
},
|
||||||
"passwordProtectedBadge": "Protegido por Senha",
|
"passwordProtectedBadge": "Protegido por Senha",
|
||||||
"launchHook": {
|
"launchHook": {
|
||||||
"placeholder": "https://example.com/track-launch"
|
"placeholder": "https://example.com/track-launch"
|
||||||
|
},
|
||||||
|
"bulkRun": {
|
||||||
|
"confirmTitle": "Executar {{count}} perfis?",
|
||||||
|
"confirmDescription": "Iniciar {{count}} perfis de uma vez pode consumir muitos recursos do sistema. Continuar?",
|
||||||
|
"confirmButton": "Executar {{count}}",
|
||||||
|
"noneToRun": "Nenhum perfil selecionado para executar."
|
||||||
|
},
|
||||||
|
"bulkStop": {
|
||||||
|
"confirmTitle": "Parar {{count}} perfis?",
|
||||||
|
"confirmDescription": "Parar {{count}} perfis em execução?",
|
||||||
|
"confirmButton": "Parar {{count}}",
|
||||||
|
"noneToStop": "Nenhum perfil selecionado para parar."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"createProfile": {
|
"createProfile": {
|
||||||
|
|||||||
@@ -305,11 +305,26 @@
|
|||||||
"assignToGroup": "Назначить группе",
|
"assignToGroup": "Назначить группе",
|
||||||
"assignProxy": "Назначить прокси",
|
"assignProxy": "Назначить прокси",
|
||||||
"assignExtensionGroup": "Назначить группу расширений",
|
"assignExtensionGroup": "Назначить группу расширений",
|
||||||
"copyCookies": "Копировать cookies"
|
"copyCookies": "Копировать cookies",
|
||||||
|
"runSelected": "Запустить выбранные",
|
||||||
|
"stopSelected": "Остановить выбранные",
|
||||||
|
"proRequired": "Для массового запуска/остановки требуется план Pro"
|
||||||
},
|
},
|
||||||
"passwordProtectedBadge": "Защищено паролем",
|
"passwordProtectedBadge": "Защищено паролем",
|
||||||
"launchHook": {
|
"launchHook": {
|
||||||
"placeholder": "https://example.com/track-launch"
|
"placeholder": "https://example.com/track-launch"
|
||||||
|
},
|
||||||
|
"bulkRun": {
|
||||||
|
"confirmTitle": "Запустить {{count}} профилей?",
|
||||||
|
"confirmDescription": "Одновременный запуск {{count}} профилей может потребовать много системных ресурсов. Продолжить?",
|
||||||
|
"confirmButton": "Запустить {{count}}",
|
||||||
|
"noneToRun": "Нет выбранных профилей для запуска."
|
||||||
|
},
|
||||||
|
"bulkStop": {
|
||||||
|
"confirmTitle": "Остановить {{count}} профилей?",
|
||||||
|
"confirmDescription": "Остановить {{count}} запущенных профилей?",
|
||||||
|
"confirmButton": "Остановить {{count}}",
|
||||||
|
"noneToStop": "Нет выбранных профилей для остановки."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"createProfile": {
|
"createProfile": {
|
||||||
|
|||||||
@@ -305,11 +305,26 @@
|
|||||||
"assignToGroup": "Gán vào nhóm",
|
"assignToGroup": "Gán vào nhóm",
|
||||||
"assignProxy": "Gán Proxy",
|
"assignProxy": "Gán Proxy",
|
||||||
"assignExtensionGroup": "Gán nhóm tiện ích",
|
"assignExtensionGroup": "Gán nhóm tiện ích",
|
||||||
"copyCookies": "Sao chép Cookie"
|
"copyCookies": "Sao chép Cookie",
|
||||||
|
"runSelected": "Chạy mục đã chọn",
|
||||||
|
"stopSelected": "Dừng mục đã chọn",
|
||||||
|
"proRequired": "Cần gói Pro để chạy/dừng hàng loạt"
|
||||||
},
|
},
|
||||||
"passwordProtectedBadge": "Được bảo vệ bằng mật khẩu",
|
"passwordProtectedBadge": "Được bảo vệ bằng mật khẩu",
|
||||||
"launchHook": {
|
"launchHook": {
|
||||||
"placeholder": "https://example.com/track-launch"
|
"placeholder": "https://example.com/track-launch"
|
||||||
|
},
|
||||||
|
"bulkRun": {
|
||||||
|
"confirmTitle": "Chạy {{count}} hồ sơ?",
|
||||||
|
"confirmDescription": "Khởi chạy {{count}} hồ sơ cùng lúc có thể tiêu tốn nhiều tài nguyên hệ thống. Tiếp tục?",
|
||||||
|
"confirmButton": "Chạy {{count}}",
|
||||||
|
"noneToRun": "Không có hồ sơ nào được chọn để chạy."
|
||||||
|
},
|
||||||
|
"bulkStop": {
|
||||||
|
"confirmTitle": "Dừng {{count}} hồ sơ?",
|
||||||
|
"confirmDescription": "Dừng {{count}} hồ sơ đang chạy?",
|
||||||
|
"confirmButton": "Dừng {{count}}",
|
||||||
|
"noneToStop": "Không có hồ sơ nào được chọn để dừng."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"createProfile": {
|
"createProfile": {
|
||||||
|
|||||||
@@ -305,11 +305,26 @@
|
|||||||
"assignToGroup": "分配到分组",
|
"assignToGroup": "分配到分组",
|
||||||
"assignProxy": "分配代理",
|
"assignProxy": "分配代理",
|
||||||
"assignExtensionGroup": "分配扩展分组",
|
"assignExtensionGroup": "分配扩展分组",
|
||||||
"copyCookies": "复制 Cookie"
|
"copyCookies": "复制 Cookie",
|
||||||
|
"runSelected": "运行所选",
|
||||||
|
"stopSelected": "停止所选",
|
||||||
|
"proRequired": "批量运行/停止需要 Pro 套餐"
|
||||||
},
|
},
|
||||||
"passwordProtectedBadge": "密码保护",
|
"passwordProtectedBadge": "密码保护",
|
||||||
"launchHook": {
|
"launchHook": {
|
||||||
"placeholder": "https://example.com/track-launch"
|
"placeholder": "https://example.com/track-launch"
|
||||||
|
},
|
||||||
|
"bulkRun": {
|
||||||
|
"confirmTitle": "运行 {{count}} 个配置文件?",
|
||||||
|
"confirmDescription": "一次启动 {{count}} 个配置文件可能会占用大量系统资源。是否继续?",
|
||||||
|
"confirmButton": "运行 {{count}} 个",
|
||||||
|
"noneToRun": "没有选中可运行的配置文件。"
|
||||||
|
},
|
||||||
|
"bulkStop": {
|
||||||
|
"confirmTitle": "停止 {{count}} 个配置文件?",
|
||||||
|
"confirmDescription": "停止 {{count}} 个正在运行的配置文件?",
|
||||||
|
"confirmButton": "停止 {{count}} 个",
|
||||||
|
"noneToStop": "没有选中可停止的配置文件。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"createProfile": {
|
"createProfile": {
|
||||||
|
|||||||
Reference in New Issue
Block a user