diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index 1bb0529c..1940a236 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -63,6 +63,13 @@ export const commandCoverage = { "profile_statuses::update_profile_status", "profile_statuses::delete_profile_status", "profile_statuses::set_profiles_status", + "profile_templates::list_profile_templates", + "profile_templates::create_profile_template", + "profile_templates::update_profile_template", + "profile_templates::delete_profile_template", + "profile_templates::save_profile_template_from_profile", + "profile_templates::bulk_create_profiles", + "profile_templates::cancel_bulk_create_profiles", "totp::generate_totp_code", "profile_totp::list_profile_totp", "profile_totp::add_profile_totp", diff --git a/e2e/tests/browser.test.mjs b/e2e/tests/browser.test.mjs index a3252f5c..b9ca2532 100644 --- a/e2e/tests/browser.test.mjs +++ b/e2e/tests/browser.test.mjs @@ -1854,6 +1854,74 @@ test("start pages open on a launch from the app, and a given URL replaces them", } }); +test("a batch from a template makes numbered profiles, each with its own device", async () => { + assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required"); + const app = appFromEnvironment("browser-template-batch", { + seedVersionCache: + cachedFixtureVersion(process.env.DONUT_E2E_PROJECT_ROOT) ?? false, + wayfernTermsAccepted: false, + }); + try { + const prepared = await prepareWayfern( + app, + process.env.DONUT_E2E_PROJECT_ROOT, + ); + if (!app.session) await app.start(); + await app.invoke("accept_wayfern_terms"); + // A profile that already holds the name, so numbering continues after it. + await createRealProfile(app, prepared.version, "Real Batch 1"); + const template = await app.invoke("create_profile_template", { + name: "Real", + settings: { + tags: ["batch"], + start_urls: ["shop.example"], + wayfern_config: { + geoip: false, + randomize_fingerprint_on_launch: false, + }, + }, + }); + + const result = await app.invoke("bulk_create_profiles", { + settings: template.settings, + name: "Real Batch", + count: 2, + }); + assert.equal(result.error, null); + assert.equal(result.cancelled, false); + assert.deepEqual( + result.created.map((p) => p.name), + ["Real Batch 2", "Real Batch 3"], + ); + const stored = (await app.invoke("list_browser_profiles")).filter((p) => + result.created.some((made) => made.id === p.id), + ); + assert.equal(stored.length, 2); + for (const profile of stored) { + assert.deepEqual(profile.tags, ["batch"]); + assert.deepEqual(profile.start_urls, ["https://shop.example/"]); + assert.equal(profile.version, prepared.version); + } + const devices = stored.map( + (p) => p.wayfern_config?.identity_id ?? p.wayfern_config?.fingerprint, + ); + assert.ok(devices.every(Boolean), "every profile got a device"); + assert.notEqual(devices[0], devices[1], "no two profiles share a device"); + + for (const profile of await app.invoke("list_browser_profiles")) { + await app.invoke("delete_profile", { + profileId: profile.id, + permanent: true, + }); + } + } catch (error) { + await app.capture("failure"); + throw error; + } finally { + await app.close(); + } +}); + test("custom launch arguments reach only their Wayfern profile", async () => { assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required"); const app = appFromEnvironment("browser-launch-args", { diff --git a/e2e/tests/entities.test.mjs b/e2e/tests/entities.test.mjs index 1c97c0f1..45b0d7e3 100644 --- a/e2e/tests/entities.test.mjs +++ b/e2e/tests/entities.test.mjs @@ -1569,6 +1569,168 @@ test("bulk edit changes only the ticked settings, checks every value first, and }); }); +test("templates keep settings but never a device, and a batch stops at the hourly allowance", async () => { + await withApp( + "entities-templates", + async (app) => { + const status = await app.invoke("create_profile_status", { + name: "Fresh", + color: "#22c55e", + }); + const profile = await createProfile(app, "Template Source"); + await app.invoke("update_profile_note", { + profileId: profile.id, + note: "login for one shop", + }); + await app.invoke("bulk_update_profiles", { + profileIds: [profile.id], + patch: { + status_id: status.id, + tags_add: ["shop"], + start_urls: ["shop.example"], + block_images: true, + }, + }); + + const saved = await app.invoke("save_profile_template_from_profile", { + profileId: profile.id, + name: " Shop ", + }); + assert.equal(saved.name, "Shop"); + assert.equal(saved.settings.status_id, status.id); + assert.deepEqual(saved.settings.tags, ["shop"]); + assert.deepEqual(saved.settings.start_urls, ["https://shop.example/"]); + assert.equal(saved.settings.note, null, "a note belongs to its profile"); + assert.equal(saved.settings.wayfern_config.block_images, true); + assert.equal( + saved.settings.wayfern_config.fingerprint ?? null, + null, + "a template never carries the source profile's device", + ); + assert.equal(saved.settings.wayfern_config.identity_id ?? null, null); + + assert.match( + await app.invokeError("save_profile_template_from_profile", { + profileId: profile.id, + name: "shop", + }), + /TEMPLATE_EXISTS/, + ); + for (const [settings, code] of [ + [{ proxy_id: "p", vpn_id: "v" }, /PROXY_AND_VPN_MUTUALLY_EXCLUSIVE/], + [{ status_id: "missing" }, /PROFILE_STATUS_NOT_FOUND/], + [{ start_urls: ["file:///etc/passwd"] }, /START_URL_INVALID/], + ]) { + assert.match( + await app.invokeError("create_profile_template", { + name: "Broken", + settings, + }), + code, + ); + } + assert.match( + await app.invokeError("create_profile_template", { + name: "x".repeat(61), + settings: {}, + }), + /TEMPLATE_NAME_TOO_LONG/, + ); + + const mail = await app.invoke("create_profile_template", { + name: "Mail", + settings: { + name_prefix: " Mail ", + tags: ["mail", " mail "], + ephemeral: true, + clear_on_close: true, + wayfern_config: { fingerprint: "{}", os: "linux" }, + }, + }); + assert.equal(mail.settings.name_prefix, "Mail"); + assert.deepEqual(mail.settings.tags, ["mail"]); + assert.equal(mail.settings.clear_on_close, false); + assert.equal(mail.settings.wayfern_config.fingerprint ?? null, null); + assert.deepEqual( + (await app.invoke("list_profile_templates")).map((t) => t.name), + ["Shop", "Mail"], + ); + + const renamed = await app.invoke("update_profile_template", { + templateId: mail.id, + name: "Webmail", + settings: { ...mail.settings, tags: ["webmail"] }, + }); + assert.equal(renamed.name, "Webmail"); + assert.deepEqual(renamed.settings.tags, ["webmail"]); + assert.ok(renamed.updated_at >= mail.updated_at); + assert.match( + await app.invokeError("update_profile_template", { + templateId: mail.id, + name: "SHOP", + settings: {}, + }), + /TEMPLATE_EXISTS/, + ); + + for (const count of [0, 101]) { + assert.match( + await app.invokeError("bulk_create_profiles", { + settings: saved.settings, + name: "Batch", + count, + }), + /BULK_CREATE_COUNT_INVALID/, + ); + } + // Nothing to stop: cancelling with no batch running is harmless. + await app.invoke("cancel_bulk_create_profiles"); + + // The signed-out allowance is eight an hour. With eight used, one more + // manual profile would only warn, but a batch never goes past it. The + // window is read on its first use, and nothing above used it, so the + // file is read without a restart (a restart would also drop the seeded + // browser, which has no binary). + const now = Math.floor(Date.now() / 1000); + await writeFile( + path.join(app.dataRoot, "data", "settings", "profile_generations.json"), + JSON.stringify({ + generations: { + local: Array.from({ length: 8 }, (_, i) => now - 600 + i), + }, + }), + ); + const before = (await app.invoke("list_browser_profiles")).length; + assert.match( + await app.invokeError("bulk_create_profiles", { + settings: saved.settings, + name: "Batch", + count: 3, + }), + /PROFILE_GENERATION_LIMIT_REACHED/, + ); + assert.equal( + (await app.invoke("list_browser_profiles")).length, + before, + "a refused batch makes nothing", + ); + + await app.invoke("delete_profile_template", { templateId: mail.id }); + assert.match( + await app.invokeError("delete_profile_template", { + templateId: mail.id, + }), + /TEMPLATE_NOT_FOUND/, + ); + assert.deepEqual( + (await app.invoke("list_profile_templates")).map((t) => t.id), + [saved.id], + ); + }, + { seedDownloadedBrowser: true }, + ); +}); + test("deleted profiles land in the trash and come back intact on restore", async () => { await withApp("entities-trash", async (app) => { assert.deepEqual(await app.invoke("list_trashed_profiles"), []); diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index 6bec9ae4..9e40c7b1 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -170,7 +170,7 @@ test("profile creation stays compact and saves basic, protected, and ephemeral p JSON.stringify(basic), ); assert.equal(basic.tabs, 0); - assert.deepEqual(basic.fields, ["profile-name"]); + assert.deepEqual(basic.fields, ["profile-name", "profile-count"]); assert.deepEqual(basic.checkboxes, ["enable-password", "ephemeral"]); assert.equal(basic.advancedVisible, false); assert.ok(!basic.text.includes(en.profiles.ephemeralDescription)); @@ -3349,6 +3349,109 @@ test("bulk edit opens from the action bar and changes only the ticked rows", asy ); }); +test("templates save from the create dialog and the inspector, and several profiles go to one batch", async () => { + await withApp( + "ui-templates", + async (app) => { + await stubCommand(app, "is_geoip_database_available", true); + const base = await createUiProfile(app, "Template Base"); + await app.waitForText(base.name); + const templates = () => app.invoke("list_profile_templates"); + + await openProfileCreation(app); + await app.fillSelector("#profile-name", "Batch"); + await app.clickSelector('[data-slot="template-picker"]'); + await app.waitForText(en.templates.empty); + await app.fillSelector('[data-slot="template-name"]', "Quick"); + await app.clickSelector('[data-slot="template-save"]'); + await app.waitFor( + async () => (await templates()).some((t) => t.name === "Quick"), + { description: "the template saved from the form" }, + ); + const [quick] = await templates(); + assert.equal(quick.settings.name_prefix, "Batch"); + await app.waitFor( + () => + app.execute( + `return document.querySelector('[data-slot="template-picker"]')?.innerText.includes("Quick") ?? false;`, + ), + { description: "the saved template shown as applied" }, + ); + + await app.clickSelector('[data-slot="profile-count-more"]'); + await app.clickSelector('[data-slot="profile-count-more"]'); + await app.waitForText( + en.templates.createMany_other.replace("{{count}}", "3"), + ); + assert.equal( + await app.execute( + `return document.querySelector('[data-slot="profile-count-hint"]') !== null;`, + ), + true, + ); + assert.equal( + await app.execute( + `return document.querySelector('#enable-password')?.disabled ?? null;`, + ), + true, + "a password is for one profile only", + ); + await app.capture("templates-create-many"); + + await stubCommand(app, "bulk_create_profiles", { + created: [{ id: "a" }, { id: "b" }, { id: "c" }], + error: null, + cancelled: false, + }); + await app.clickSelector('[data-slot="profile-create-submit"]'); + await app.waitFor( + () => app.execute(`return !document.querySelector('#profile-name');`), + { description: "the dialog closing after the batch" }, + ); + const [call] = await app.execute( + `return (window.__donutStubbedCalls ?? []).filter((c) => c.command === "bulk_create_profiles");`, + ); + assert.equal(call.payload.count, 3); + assert.equal(call.payload.name, "Batch"); + assert.equal(call.payload.settings.name_prefix, "Batch"); + await app.waitForText( + en.templates.created_other.replace("{{count}}", "3"), + ); + + await openProfileCreation(app); + await app.clickSelector('[data-slot="template-picker"]'); + await app.clickSelector('[data-slot="template-option"]'); + await app.waitFor( + () => + app.execute( + `return document.querySelector('#profile-name')?.value === "Batch";`, + ), + { description: "the template's name prefix filling an empty name" }, + ); + await app.clickSelector('[data-slot="template-picker"]'); + await app.clickSelector('[data-slot="template-delete"]'); + await app.clickSelector('[data-slot="template-delete-confirm"]'); + await app.waitFor(async () => (await templates()).length === 0, { + description: "the template deleted from the picker", + }); + await dismissSurface(app); + await dismissSurface(app); + + await app.clickSelector( + `[data-slot="profile-inspect-trigger"][data-profile-id="${base.id}"]`, + ); + await app.clickSelector('[data-slot="profile-info-save-template"]'); + await app.waitForText(en.templates.saveFromProfileDescription); + await app.clickSelector('[data-slot="save-template-confirm"]'); + await app.waitFor( + async () => (await templates()).some((t) => t.name === "Template Base"), + { description: "the template saved from the inspector" }, + ); + }, + { seedDownloadedBrowser: true }, + ); +}); + test("the group bookmark editor adds, reorders and removes a row", async () => { await withApp("ui-group-bookmarks", async (app) => { const group = await app.invoke("create_profile_group", { name: "Client" }); diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 6d40d0fc..c5d2f4dd 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -274,6 +274,33 @@ impl From for ApiStatusResponse { } } +#[derive(Debug, Deserialize, ToSchema)] +struct TemplateRequest { + /// 1 to 60 characters, unique (case-insensitive). + name: String, + #[serde(default)] + settings: crate::profile_templates::TemplateSettings, +} + +#[derive(Debug, Deserialize, ToSchema)] +struct CreateFromTemplateRequest { + /// 1 to 100. + count: u32, + /// One profile takes this name; several take it followed by numbers that + /// continue after the highest in use. Defaults to the template's name + /// prefix, else its name. + #[serde(default)] + name: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +struct ApiBulkCreateResponse { + /// The profiles made, in order. + profiles: Vec, + /// Why creation stopped before `count` (an error code JSON), or `null`. + error: Option, +} + #[derive(Debug, Deserialize, ToSchema)] struct StatusRequest { /// 1 to 40 characters, unique (case-insensitive). @@ -745,6 +772,11 @@ struct ImportProxiesResponse { update_status, delete_status, bulk_edit_profiles, + get_templates, + create_template, + update_template, + delete_template, + create_profiles_from_template, get_tags, get_proxies, get_proxy, @@ -795,6 +827,11 @@ struct ImportProxiesResponse { StatusRequest, BulkEditRequest, BulkEditResponse, + TemplateRequest, + CreateFromTemplateRequest, + ApiBulkCreateResponse, + crate::profile_templates::ProfileTemplate, + crate::profile_templates::TemplateSettings, crate::profile::patch::ProfilePatch, ApiProxyResponse, crate::proxy_rotation::RotationResult, @@ -892,6 +929,7 @@ struct ImportProxiesResponse { (name = "profiles", description = "Profile management endpoints"), (name = "groups", description = "Group management endpoints"), (name = "statuses", description = "Profile status management endpoints"), + (name = "templates", description = "Profile templates and creating profiles from them"), (name = "tags", description = "Tag management endpoints"), (name = "proxies", description = "Proxy management endpoints"), (name = "vpns", description = "VPN management endpoints"), @@ -1087,6 +1125,9 @@ fn build_v1_router() -> Router { .routes(routes!(get_statuses, create_status)) .routes(routes!(update_status, delete_status)) .routes(routes!(bulk_edit_profiles)) + .routes(routes!(get_templates, create_template)) + .routes(routes!(update_template, delete_template)) + .routes(routes!(create_profiles_from_template)) .routes(routes!(get_tags)) .routes(routes!(get_proxies, create_proxy)) .routes(routes!(import_proxies_api)) @@ -1399,7 +1440,7 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) { if let Some(code) = value.get("code").and_then(|c| c.as_str()) { let status = if code.ends_with("_NOT_FOUND") { StatusCode::NOT_FOUND - } else if LAUNCH_CONFLICT_CODES.contains(&code) { + } else if LAUNCH_CONFLICT_CODES.contains(&code) || code == "BULK_CREATE_BUSY" { // Someone or something else holds this profile: another team member, a // browser already open, or a remote session whose work has not been // pulled back yet. All of them are "try again later", not "your request @@ -2310,6 +2351,139 @@ async fn bulk_edit_profiles( .map_err(manager_error_response) } +// API Handlers - Templates +#[utoipa::path( + get, + path = "/v1/templates", + responses( + (status = 200, description = "Every profile template, in their order", body = Vec), + (status = 401, description = "Unauthorized") + ), + security( + ("bearer_auth" = []) + ), + tag = "templates" +)] +async fn get_templates() -> Json> { + Json(crate::profile_templates::list()) +} + +#[utoipa::path( + post, + path = "/v1/templates", + request_body = TemplateRequest, + responses( + (status = 200, description = "Template created. Device fields in `wayfern_config` are dropped: every profile made from it gets its own device", body = crate::profile_templates::ProfileTemplate), + (status = 400, description = "Empty, too long or duplicate name, an invalid value, both a proxy and a VPN, or 100 templates already"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "A status, group or extension group does not exist") + ), + security( + ("bearer_auth" = []) + ), + tag = "templates" +)] +async fn create_template( + Json(request): Json, +) -> Result, (StatusCode, String)> { + crate::profile_templates::create(&request.name, request.settings) + .await + .map(Json) + .map_err(manager_error_response) +} + +#[utoipa::path( + put, + path = "/v1/templates/{id}", + params( + ("id" = String, Path, description = "Template ID") + ), + request_body = TemplateRequest, + responses( + (status = 200, description = "Template replaced", body = crate::profile_templates::ProfileTemplate), + (status = 400, description = "Empty, too long or duplicate name, an invalid value, or both a proxy and a VPN"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Template, status, group or extension group not found") + ), + security( + ("bearer_auth" = []) + ), + tag = "templates" +)] +async fn update_template( + Path(id): Path, + Json(request): Json, +) -> Result, (StatusCode, String)> { + crate::profile_templates::update(&id, &request.name, request.settings) + .await + .map(Json) + .map_err(manager_error_response) +} + +#[utoipa::path( + delete, + path = "/v1/templates/{id}", + params( + ("id" = String, Path, description = "Template ID") + ), + responses( + (status = 204, description = "Template deleted; profiles made from it keep their settings"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Template not found") + ), + security( + ("bearer_auth" = []) + ), + tag = "templates" +)] +async fn delete_template(Path(id): Path) -> Result { + crate::profile_templates::delete(&id) + .map(|()| StatusCode::NO_CONTENT) + .map_err(manager_error_response) +} + +#[utoipa::path( + post, + path = "/v1/templates/{id}/profiles", + params( + ("id" = String, Path, description = "Template ID") + ), + request_body = CreateFromTemplateRequest, + responses( + (status = 200, description = "Profiles made with the newest downloaded Wayfern, each with its own device. When `error` is set, creation stopped early and `profiles` lists what was made", body = ApiBulkCreateResponse), + (status = 400, description = "Count outside 1 to 100, empty name, a name in use, or a proxy or VPN that does not work"), + (status = 401, description = "Unauthorized"), + (status = 402, description = "The template's fingerprint OS needs a paid plan"), + (status = 404, description = "Template not found"), + (status = 409, description = "Another bulk creation is running"), + (status = 429, description = "The hourly profile allowance is used up; nothing was made") + ), + security( + ("bearer_auth" = []) + ), + tag = "templates" +)] +async fn create_profiles_from_template( + State(state): State, + Path(id): Path, + Json(request): Json, +) -> Result, (StatusCode, String)> { + crate::profile_templates::create_from_template( + &state.app_handle, + &id, + request.name.as_deref(), + request.count, + ) + .await + .map(|result| { + Json(ApiBulkCreateResponse { + profiles: result.created.iter().map(ApiProfile::from).collect(), + error: result.error, + }) + }) + .map_err(manager_error_response) +} + // API Handlers - Tags #[utoipa::path( get, @@ -5991,6 +6165,36 @@ mod tests { } } + #[test] + fn openapi_documents_bulk_edit_and_templates() { + let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); + for path in [ + "/v1/profiles/bulk-edit", + "/v1/templates", + "/v1/templates/{id}", + "/v1/templates/{id}/profiles", + ] { + assert!( + spec["paths"][path].is_object(), + "{path} must be in the spec" + ); + } + let patch = &spec["components"]["schemas"]["ProfilePatch"]; + assert!(patch["properties"]["status_id"].is_object()); + assert!( + schema_required(&spec, "ProfilePatch").is_empty(), + "every patch field is optional" + ); + assert!( + schema_required(&spec, "TemplateSettings").is_empty(), + "every template setting is optional" + ); + assert!(!schema_required(&spec, "CreateFromTemplateRequest") + .iter() + .any(|f| f == "name")); + assert!(spec["paths"]["/v1/templates/{id}/profiles"]["post"]["responses"]["429"].is_object()); + } + #[test] fn openapi_documents_the_profile_dns_server() { let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index baee5be4..af6dc7a6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3343,6 +3343,13 @@ pub fn run_with_builder( profile_statuses::update_profile_status, profile_statuses::delete_profile_status, profile_statuses::set_profiles_status, + profile_templates::list_profile_templates, + profile_templates::create_profile_template, + profile_templates::update_profile_template, + profile_templates::delete_profile_template, + profile_templates::save_profile_template_from_profile, + profile_templates::bulk_create_profiles, + profile_templates::cancel_bulk_create_profiles, totp::generate_totp_code, profile_totp::list_profile_totp, profile_totp::add_profile_totp, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 377d8fa0..b4c71ffd 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -2780,6 +2780,39 @@ impl McpServer { "required": [] }), }, + McpTool { + name: "list_profile_templates".to_string(), + description: "List the profile templates: saved settings (proxy or VPN, group, tags, status, DNS, start pages, fingerprint OS and options) that new profiles are made from. A template never holds a device; every profile made from it gets its own.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + McpTool { + name: "create_profiles_from_template".to_string(), + description: "Make 1 to 100 profiles from a template with the newest downloaded Wayfern, each with its own new device. Everything is checked first, each proxy once. One profile takes the name as given; several take it followed by numbers that continue after the highest in use. Creation stops at the hourly profile allowance; the result lists what was made and why it stopped early, if it did.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "template_id": { + "type": "string", + "description": "The template's id, from list_profile_templates" + }, + "count": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "How many profiles to make" + }, + "name": { + "type": "string", + "description": "Optional. Defaults to the template's name prefix, else its name" + } + }, + "required": ["template_id", "count"] + }), + }, // Group management tools McpTool { name: "list_groups".to_string(), @@ -4627,6 +4660,13 @@ impl McpServer { "text": serde_json::to_string_pretty(&crate::profile_statuses::list()).unwrap_or_default() }] })), + "list_profile_templates" => Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string_pretty(&crate::profile_templates::list()).unwrap_or_default() + }] + })), + "create_profiles_from_template" => self.handle_create_profiles_from_template(arguments).await, "get_group" => self.handle_get_group(arguments).await, "create_group" => self.handle_create_group(arguments).await, "update_group" => self.handle_update_group(arguments).await, @@ -5921,6 +5961,63 @@ impl McpServer { })) } + async fn handle_create_profiles_from_template( + &self, + arguments: &serde_json::Value, + ) -> Result { + let invalid = |message: &str| McpError { + code: -32602, + message: message.to_string(), + data: None, + }; + let template_id = arguments + .get("template_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| invalid("Missing template_id"))?; + let count = arguments + .get("count") + .and_then(serde_json::Value::as_u64) + .and_then(|count| u32::try_from(count).ok()) + .ok_or_else(|| invalid("count must be a whole number from 1 to 100"))?; + let name = arguments.get("name").and_then(|v| v.as_str()); + // Cloned out of the lock before the long await, as in create_profile. + 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(), + data: None, + })? + .clone() + }; + let result = + crate::profile_templates::create_from_template(&app_handle, template_id, name, count) + .await + .map_err(|e| McpError { + code: -32000, + message: format!("Creating profiles failed: {e}"), + data: None, + })?; + let profiles: Vec = result + .created + .iter() + .map(|profile| serde_json::json!({ "id": profile.id, "name": profile.name })) + .collect(); + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string_pretty(&serde_json::json!({ + "created": profiles, + "stopped_early": result.error, + })) + .unwrap_or_default() + }] + })) + } + async fn handle_get_group( &self, arguments: &serde_json::Value, @@ -10025,7 +10122,7 @@ mod tests { // tool was added or removed on purpose. assert_eq!( tools.len(), - 96, + 98, "the tool list is a published contract; update this number deliberately" ); diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 6e76705e..a7e12309 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -1552,6 +1552,28 @@ impl ProfileManager { } } + /// Set what profile creation takes no value for on a profile just made + /// from a template. + pub fn finish_new_profile( + &self, + mut profile: BrowserProfile, + patch: &super::patch::ProfilePatch, + ) -> Result> { + if !patch.apply_to(&mut profile) { + return Ok(profile); + } + profile.updated_at = Some(crate::proxy_manager::now_secs()); + self.save_profile(&profile)?; + crate::sync::queue_profile_sync_if_eligible(&profile); + if profile.is_sync_enabled() { + Self::sync_patched_groups(patch); + } + if let Err(e) = events::emit_empty("profiles-changed") { + log::warn!("Event emit failed event=profiles-changed err=\"{e}\""); + } + Ok(profile) + } + /// Give the listed profiles one status, or none. A running profile can /// take a status: it is a label, not a launch setting. pub fn set_profiles_status( diff --git a/src-tauri/src/profile_templates.rs b/src-tauri/src/profile_templates.rs index 738676bb..eeb37fd1 100644 --- a/src-tauri/src/profile_templates.rs +++ b/src-tauri/src/profile_templates.rs @@ -1,12 +1,20 @@ //! Profile templates: saved settings that new profiles are made from, one at a -//! time or many at once. A template never holds a fingerprint: every profile -//! made from it gets its own device. +//! time or many at once. A template never holds a device: every profile made +//! from it gets its own. -use crate::sync::config_entities::{ConfigEntity, ConfigKind, ConfigStore}; +use crate::profile::patch::ProfilePatch; +use crate::profile::{BrowserProfile, ProfileManager}; +use crate::sync::config_entities::{self, ConfigEntity, ConfigKind, ConfigStore}; use crate::wayfern_manager::WayfernConfig; use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicBool, Ordering}; -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +const MAX_TEMPLATES: usize = 100; +const MAX_NAME_CHARS: usize = 60; +/// The most profiles one bulk creation makes. +pub const MAX_BULK_CREATE: u32 = 100; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, utoipa::ToSchema)] pub struct TemplateSettings { /// What the profiles made from the template are named after. #[serde(default)] @@ -46,10 +54,11 @@ pub struct TemplateSettings { /// Fingerprint settings (OS, screen bounds, WebRTC, images, WebGL, /// session restore, launch arguments). The device fields are always empty. #[serde(default)] + #[schema(value_type = Option)] pub wayfern_config: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ProfileTemplate { pub id: String, pub name: String, @@ -75,3 +84,605 @@ impl ConfigEntity for ProfileTemplate { &STORE } } + +fn code(code: &str) -> String { + crate::backend_error(code) +} + +fn trimmed(value: Option) -> Option { + value + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +fn checked_name(name: &str) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(code("NAME_CANNOT_BE_EMPTY")); + } + if name.chars().count() > MAX_NAME_CHARS { + return Err(code("TEMPLATE_NAME_TOO_LONG")); + } + Ok(name.to_string()) +} + +/// What describes a kind of device, never one device: the fields that +/// identify a device, and a camera file that only exists on this computer, +/// stay out of a template. +fn portable(mut config: WayfernConfig) -> WayfernConfig { + config.fingerprint = None; + config.identity_id = None; + config.identity_baseline = None; + config.identity_overrides = None; + config.persona = None; + config.location = None; + config.geo_proxy_signature = None; + config.proxy = None; + config.camera_file = None; + config.camera_crop = None; + config +} + +impl TemplateSettings { + /// Every value checked and in its stored form. + pub async fn checked(self) -> Result { + let name_prefix = trimmed(self.name_prefix); + if name_prefix + .as_ref() + .is_some_and(|prefix| prefix.chars().count() > MAX_NAME_CHARS) + { + return Err(code("TEMPLATE_NAME_TOO_LONG")); + } + let proxy_id = trimmed(self.proxy_id); + let vpn_id = trimmed(self.vpn_id); + let mut proxy_ids: Vec = Vec::new(); + for id in self.proxy_ids { + let id = id.trim().to_string(); + if !id.is_empty() && !proxy_ids.contains(&id) { + proxy_ids.push(id); + } + } + if vpn_id.is_some() && (proxy_id.is_some() || !proxy_ids.is_empty()) { + return Err(code("PROXY_AND_VPN_MUTUALLY_EXCLUSIVE")); + } + + let checked = ProfilePatch { + status_id: Some(self.status_id), + tags_add: self.tags, + note: Some(self.note), + group_id: Some(self.group_id), + extension_group_id: Some(self.extension_group_id), + dns_blocklist: Some(self.dns_blocklist), + dns_server: Some(self.dns_server), + proxy_bypass_rules: Some(self.proxy_bypass_rules), + start_urls: Some(self.start_urls), + launch_hook: Some(self.launch_hook), + ..Default::default() + } + .validated()?; + + let mut wayfern_config = self.wayfern_config.map(portable); + if let Some(config) = wayfern_config.as_mut() { + config.launch_args = config.checked_launch_args().await?; + } + + Ok(Self { + name_prefix, + proxy_id, + vpn_id, + proxy_ids, + group_id: checked.group_id.flatten(), + tags: checked.tags_add, + status_id: checked.status_id.flatten(), + note: trimmed(checked.note.flatten()), + extension_group_id: checked.extension_group_id.flatten(), + dns_blocklist: checked.dns_blocklist.flatten(), + dns_server: checked.dns_server.flatten(), + proxy_bypass_rules: checked.proxy_bypass_rules.unwrap_or_default(), + start_urls: checked.start_urls.unwrap_or_default(), + launch_hook: checked.launch_hook.flatten(), + ephemeral: self.ephemeral, + // An ephemeral profile keeps nothing, so there is nothing to clear. + clear_on_close: self.clear_on_close && !self.ephemeral, + wayfern_config, + }) + } + + /// The settings profile creation takes no value for, set right after it. + fn after_creation(&self) -> ProfilePatch { + ProfilePatch { + status_id: self.status_id.clone().map(Some), + tags_add: self.tags.clone(), + note: self.note.clone().map(Some), + extension_group_id: self.extension_group_id.clone().map(Some), + proxy_bypass_rules: (!self.proxy_bypass_rules.is_empty()) + .then(|| self.proxy_bypass_rules.clone()), + start_urls: (!self.start_urls.is_empty()).then(|| self.start_urls.clone()), + clear_on_close: self.clear_on_close.then_some(true), + ..Default::default() + } + } + + /// A profile's settings, without its name, its note or its device: those + /// belong to that one profile. + fn from_profile(profile: &BrowserProfile) -> Self { + Self { + name_prefix: None, + proxy_id: profile.proxy_id.clone(), + vpn_id: profile.vpn_id.clone(), + proxy_ids: Vec::new(), + group_id: profile.group_id.clone(), + tags: profile.tags.clone(), + status_id: profile.status_id.clone(), + note: None, + extension_group_id: profile.extension_group_id.clone(), + dns_blocklist: profile.dns_blocklist.clone(), + dns_server: profile.dns_server.clone(), + proxy_bypass_rules: profile.proxy_bypass_rules.clone(), + start_urls: profile.start_urls.clone(), + launch_hook: profile.launch_hook.clone(), + ephemeral: profile.ephemeral, + clear_on_close: profile.clear_on_close, + wayfern_config: profile.wayfern_config.clone().map(portable), + } + } +} + +pub fn list() -> Vec { + let mut templates = STORE.list(); + templates.sort_by(|a, b| { + a.position + .cmp(&b.position) + .then_with(|| a.name.cmp(&b.name)) + }); + templates +} + +pub fn get(template_id: &str) -> Result { + STORE + .get(template_id) + .ok_or_else(|| code("TEMPLATE_NOT_FOUND")) +} + +pub async fn create(name: &str, settings: TemplateSettings) -> Result { + let name = checked_name(name)?; + let settings = settings.checked().await?; + let template = STORE.mutate(|templates| { + if templates.len() >= MAX_TEMPLATES { + return Err(code("TEMPLATE_LIMIT_REACHED")); + } + if templates.iter().any(|t| t.name.eq_ignore_ascii_case(&name)) { + return Err(code("TEMPLATE_EXISTS")); + } + let template = ProfileTemplate { + id: uuid::Uuid::new_v4().to_string(), + name, + settings, + position: templates.iter().map(|t| t.position + 1).max().unwrap_or(0), + updated_at: crate::proxy_manager::now_secs(), + }; + templates.push(template.clone()); + Ok(template) + })?; + config_entities::changed_locally::(&template.id); + log::info!("Template created template={}", template.id); + Ok(template) +} + +pub async fn update( + template_id: &str, + name: &str, + settings: TemplateSettings, +) -> Result { + let name = checked_name(name)?; + let settings = settings.checked().await?; + let template = STORE.mutate(|templates| { + if templates + .iter() + .any(|t| t.id != template_id && t.name.eq_ignore_ascii_case(&name)) + { + return Err(code("TEMPLATE_EXISTS")); + } + let template = templates + .iter_mut() + .find(|t| t.id == template_id) + .ok_or_else(|| code("TEMPLATE_NOT_FOUND"))?; + template.name = name; + template.settings = settings; + template.updated_at = crate::proxy_manager::now_secs(); + Ok(template.clone()) + })?; + config_entities::changed_locally::(&template.id); + Ok(template) +} + +pub fn delete(template_id: &str) -> Result<(), String> { + let removed = STORE.mutate(|templates| { + let before = templates.len(); + templates.retain(|t| t.id != template_id); + Ok(templates.len() != before) + })?; + if !removed { + return Err(code("TEMPLATE_NOT_FOUND")); + } + config_entities::deleted_locally::(template_id); + log::info!("Template deleted template={template_id}"); + Ok(()) +} + +pub async fn save_profile_as_template( + profile_id: &str, + name: &str, +) -> Result { + let profile = ProfileManager::instance() + .list_profiles() + .map_err(|e| crate::wrap_backend_error(e, "Failed to list profiles"))? + .into_iter() + .find(|profile| profile.id.to_string() == profile_id) + .ok_or_else(|| code("PROFILE_NOT_FOUND"))?; + create(name, TemplateSettings::from_profile(&profile)).await +} + +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +pub struct BulkCreateResult { + /// The profiles made, in order. + #[schema(value_type = Vec)] + pub created: Vec, + /// Why creation stopped before the count was reached: an error code JSON, + /// as the single-profile errors. `None` when it was not stopped by an error. + pub error: Option, + /// The person stopped it before the count was reached. + pub cancelled: bool, +} + +#[derive(Clone, Serialize)] +struct BulkCreateProgress { + done: u32, + total: u32, +} + +static BULK_RUNNING: AtomicBool = AtomicBool::new(false); +static BULK_CANCEL: AtomicBool = AtomicBool::new(false); + +struct BulkRun; + +impl Drop for BulkRun { + fn drop(&mut self) { + BULK_RUNNING.store(false, Ordering::SeqCst); + } +} + +/// Names for `count` new profiles: the name as it is for one, else the name +/// with numbers that continue after the highest one already in use, so a +/// second batch never collides with the first. +fn names_for(base: &str, count: u32, existing: &[String]) -> Vec { + if count == 1 { + return vec![base.to_string()]; + } + let prefix = format!("{} ", base.to_lowercase()); + let highest = existing + .iter() + .filter_map(|name| { + let lower = name.to_lowercase(); + lower.strip_prefix(prefix.as_str())?.parse::().ok() + }) + .max() + .unwrap_or(0); + (1..=u64::from(count)) + .map(|n| format!("{base} {}", highest + n)) + .collect() +} + +fn latest_wayfern_version() -> Option { + let mut versions = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance() + .get_downloaded_versions("wayfern"); + versions.sort_by(|a, b| crate::api_client::compare_versions(b, a)); + versions.into_iter().next() +} + +/// Make `count` profiles with these settings. Everything is checked first, +/// each proxy once; then the profiles are made one by one until the count is +/// reached, an error stops the run, or the person cancels it. What was made +/// before a stop stays. +pub async fn create_profiles( + app_handle: &tauri::AppHandle, + settings: TemplateSettings, + name: &str, + count: u32, +) -> Result { + if !(1..=MAX_BULK_CREATE).contains(&count) { + return Err( + serde_json::json!({ + "code": "BULK_CREATE_COUNT_INVALID", + "params": { "max": MAX_BULK_CREATE.to_string() } + }) + .to_string(), + ); + } + let base = name.trim(); + if base.is_empty() { + return Err(code("NAME_CANNOT_BE_EMPTY")); + } + if BULK_RUNNING.swap(true, Ordering::SeqCst) { + return Err(code("BULK_CREATE_BUSY")); + } + let _run = BulkRun; + BULK_CANCEL.store(false, Ordering::SeqCst); + + let settings = settings.checked().await?; + let after = settings.after_creation().validated()?; + let version = latest_wayfern_version().ok_or_else(|| { + serde_json::json!({ "code": "BROWSER_NOT_DOWNLOADED", "params": { "browser": "Wayfern" } }) + .to_string() + })?; + let os = settings + .wayfern_config + .as_ref() + .and_then(|c| c.os.as_deref()); + if !crate::cloud_auth::CLOUD_AUTH + .is_fingerprint_os_allowed(os) + .await + { + return Err(code("FINGERPRINT_REQUIRES_PRO")); + } + let routes: Vec> = if settings.proxy_ids.is_empty() { + vec![settings.proxy_id.clone()] + } else { + settings.proxy_ids.iter().cloned().map(Some).collect() + }; + for proxy_id in &routes { + crate::validate_profile_network(proxy_id.as_deref(), settings.vpn_id.as_deref()).await?; + } + + let manager = ProfileManager::instance(); + let existing: Vec = manager + .list_profiles() + .map_err(|e| crate::wrap_backend_error(e, "Failed to list profiles"))? + .into_iter() + .map(|profile| profile.name) + .collect(); + let names = names_for(base, count, &existing); + + let started = std::time::Instant::now(); + let mut created = Vec::with_capacity(names.len()); + let mut error = None; + let mut cancelled = false; + for (index, name) in names.iter().enumerate() { + if BULK_CANCEL.load(Ordering::SeqCst) { + cancelled = true; + break; + } + // A batch stops at the hourly allowance on every plan. One profile past + // a free allowance is a warning; a batch past it gets generation + // blocked upstream. + let allowance = crate::profile_generation_limiter::get_profile_creation_allowance().await; + if let Some(retry_after) = allowance.retry_after_secs { + error = Some( + serde_json::json!({ + "code": "PROFILE_GENERATION_LIMIT_REACHED", + "params": { + "limit": allowance.per_hour.to_string(), + "retryAfterSeconds": retry_after.to_string(), + } + }) + .to_string(), + ); + break; + } + let made = manager + .create_profile_with_group( + app_handle, + name, + "wayfern", + &version, + "stable", + routes[index % routes.len()].clone(), + settings.vpn_id.clone(), + Some(settings.wayfern_config.clone().unwrap_or_default()), + settings.group_id.clone(), + settings.ephemeral, + settings.dns_blocklist.clone(), + settings.dns_server.clone(), + settings.launch_hook.clone(), + ) + .await + .and_then(|profile| manager.finish_new_profile(profile, &after)); + match made { + Ok(profile) => created.push(profile), + Err(e) => { + error = Some(crate::wrap_backend_error(e, "Failed to create profile")); + break; + } + } + let progress = BulkCreateProgress { + done: index as u32 + 1, + total: count, + }; + if let Err(e) = crate::events::emit("profiles-bulk-create-progress", progress) { + log::debug!("Event emit failed event=profiles-bulk-create-progress err=\"{e}\""); + } + } + + let elapsed_ms = started.elapsed().as_millis(); + match &error { + Some(err) => log::warn!( + "Profiles created in bulk requested={count} created={} elapsed_ms={elapsed_ms} err=\"{err}\"", + created.len() + ), + None => log::info!( + "Profiles created in bulk requested={count} created={} cancelled={cancelled} elapsed_ms={elapsed_ms}", + created.len() + ), + } + if created.is_empty() { + if let Some(error) = error { + return Err(error); + } + } + Ok(BulkCreateResult { + created, + error, + cancelled, + }) +} + +/// Make profiles from a saved template. The name defaults to the template's +/// name prefix, else its name. +pub async fn create_from_template( + app_handle: &tauri::AppHandle, + template_id: &str, + name: Option<&str>, + count: u32, +) -> Result { + let template = get(template_id)?; + let name = name + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .or_else(|| template.settings.name_prefix.clone()) + .unwrap_or_else(|| template.name.clone()); + create_profiles(app_handle, template.settings, &name, count).await +} + +#[tauri::command] +pub fn list_profile_templates() -> Vec { + list() +} + +#[tauri::command] +pub async fn create_profile_template( + name: String, + settings: TemplateSettings, +) -> Result { + create(&name, settings).await +} + +#[tauri::command] +pub async fn update_profile_template( + template_id: String, + name: String, + settings: TemplateSettings, +) -> Result { + update(&template_id, &name, settings).await +} + +#[tauri::command] +pub fn delete_profile_template(template_id: String) -> Result<(), String> { + delete(&template_id) +} + +#[tauri::command] +pub async fn save_profile_template_from_profile( + profile_id: String, + name: String, +) -> Result { + save_profile_as_template(&profile_id, &name).await +} + +#[tauri::command] +pub async fn bulk_create_profiles( + app_handle: tauri::AppHandle, + settings: TemplateSettings, + name: String, + count: u32, +) -> Result { + create_profiles(&app_handle, settings, &name, count).await +} + +/// Stop a running bulk creation after the profile being made now. +#[tauri::command] +pub fn cancel_bulk_create_profiles() { + BULK_CANCEL.store(true, Ordering::SeqCst); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_continue_after_the_highest_number_in_use() { + let existing = vec![ + "Shop 1".to_string(), + "shop 7".to_string(), + "Shop 3b".to_string(), + "Shopper 9".to_string(), + ]; + assert_eq!( + names_for("Shop", 3, &existing), + ["Shop 8", "Shop 9", "Shop 10"] + ); + assert_eq!(names_for("Mail", 2, &existing), ["Mail 1", "Mail 2"]); + assert_eq!( + names_for("Shop", 1, &existing), + ["Shop"], + "one profile keeps the name as typed" + ); + } + + #[test] + fn a_template_never_carries_a_device() { + let config = WayfernConfig { + fingerprint: Some("{\"a\":1}".to_string()), + identity_id: Some("id".to_string()), + identity_overrides: Some("{}".to_string()), + location: Some("{}".to_string()), + camera_file: Some("/Users/me/cam.png".to_string()), + os: Some("windows".to_string()), + block_images: Some(true), + ..Default::default() + }; + let kept = portable(config); + assert!(kept.fingerprint.is_none() && kept.identity_id.is_none()); + assert!(kept.identity_overrides.is_none() && kept.location.is_none()); + assert!(kept.camera_file.is_none()); + assert_eq!(kept.os.as_deref(), Some("windows")); + assert_eq!(kept.block_images, Some(true)); + + let profile = BrowserProfile { + name: "Shop".to_string(), + note: Some("login for shop".to_string()), + tags: vec!["shop".to_string()], + wayfern_config: Some(WayfernConfig { + fingerprint: Some("{}".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let settings = TemplateSettings::from_profile(&profile); + assert_eq!(settings.tags, ["shop"]); + assert_eq!(settings.note, None, "a note belongs to its one profile"); + assert!(settings.wayfern_config.unwrap().fingerprint.is_none()); + } + + #[tokio::test] + async fn settings_are_checked_and_tidied() { + let settings = TemplateSettings { + name_prefix: Some(" Shop ".to_string()), + proxy_ids: vec![" a ".to_string(), "a".to_string(), "".to_string()], + tags: vec![" x ".to_string(), "x".to_string()], + ephemeral: true, + clear_on_close: true, + start_urls: vec!["shop.example".to_string()], + ..Default::default() + } + .checked() + .await + .unwrap(); + assert_eq!(settings.name_prefix.as_deref(), Some("Shop")); + assert_eq!(settings.proxy_ids, ["a"]); + assert_eq!(settings.tags, ["x"]); + assert!(!settings.clear_on_close); + assert_eq!(settings.start_urls, ["https://shop.example/"]); + + let both = TemplateSettings { + proxy_id: Some("p".to_string()), + vpn_id: Some("v".to_string()), + ..Default::default() + }; + assert!(both + .checked() + .await + .unwrap_err() + .contains("PROXY_AND_VPN_MUTUALLY_EXCLUSIVE")); + } +} diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index c62d14b0..8868f921 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -2,6 +2,7 @@ import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { motion, useReducedMotion } from "motion/react"; import type * as React from "react"; import { useCallback, useEffect, useId, useRef, useState } from "react"; @@ -13,10 +14,14 @@ import { LuChevronRight, LuFlame, LuGlobe, + LuLayoutTemplate, LuLoaderCircle, LuLock, LuLockOpen, + LuMinus, + LuPlus, LuShieldCheck, + LuTrash2, LuTriangleAlert, } from "react-icons/lu"; import { DnsServerField } from "@/components/dns-server-field"; @@ -70,16 +75,28 @@ import { import { WayfernConfigForm } from "@/components/wayfern-config-form"; import { useBrowserDownload } from "@/hooks/use-browser-download"; import { useInputModality } from "@/hooks/use-input-modality"; +import { useProfileTemplates } from "@/hooks/use-profile-templates"; import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useVpnEvents } from "@/hooks/use-vpn-events"; +import { translateBackendError } from "@/lib/backend-errors"; import { getOSDisplayName, getOSIcon } from "@/lib/browser-utils"; import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels"; import { MOTION_EASE_OUT } from "@/lib/motion"; import { startPagesFromText } from "@/lib/start-pages"; +import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { cn } from "@/lib/utils"; -import type { BrowserReleaseTypes, WayfernConfig, WayfernOS } from "@/types"; +import type { + BrowserReleaseTypes, + BulkCreateResult, + ProfileTemplate, + TemplateSettings, + WayfernConfig, + WayfernOS, +} from "@/types"; const PASSWORD_MIN_LEN = 8; +/** The most profiles one bulk creation makes; the backend holds the same cap. */ +const MAX_BULK_CREATE = 100; const OS_OPTIONS: WayfernOS[] = ["windows", "macos", "linux", "android", "ios"]; @@ -155,6 +172,14 @@ export function CreateProfileDialog({ useState(defaultWayfernConfig); const [isCreating, setIsCreating] = useState(false); const [ephemeral, setEphemeral] = useState(false); + const templates = useProfileTemplates(); + const [appliedTemplate, setAppliedTemplate] = + useState(null); + const [count, setCount] = useState(1); + const [progress, setProgress] = useState<{ + done: number; + total: number; + } | null>(null); const [allowance, setAllowance] = useState( null, ); @@ -190,6 +215,19 @@ export function CreateProfileDialog({ allowance !== null && allowance.used >= allowance.per_hour; const hourlyCapReached = overAllowance && allowance.enforcement === "hard"; const creatingQuickly = overAllowance && allowance.enforcement === "soft"; + // A batch stops at the hourly allowance on every plan, so it is never + // offered past what is left. + const maxCount = + allowance === null + ? MAX_BULK_CREATE + : Math.max( + 1, + Math.min(MAX_BULK_CREATE, allowance.per_hour - allowance.used), + ); + const many = count > 1; + useEffect(() => { + setCount((current) => Math.min(current, maxCount)); + }, [maxCount]); const [enablePassword, setEnablePassword] = useState(false); const [password, setPassword] = useState(""); const [passwordConfirm, setPasswordConfirm] = useState(""); @@ -318,10 +356,162 @@ export function CreateProfileDialog({ setReleaseTypesError(false); setWayfernConfig(defaultWayfernConfig()); setEphemeral(false); + setAppliedTemplate(null); + setCount(1); + setProgress(null); clearPassword(); onClose(); }; + /** The form as template settings, over the applied template's own extras. */ + const formSettings = (): TemplateSettings => { + const base = appliedTemplate?.settings; + const isVpn = selectedProxyId?.startsWith("vpn-") ?? false; + const proxyId = isVpn ? null : (selectedProxyId ?? null); + const spread = base?.proxy_ids ?? []; + return { + ...base, + name_prefix: profileName.trim() || null, + proxy_id: proxyId, + vpn_id: isVpn ? (selectedProxyId?.slice(4) ?? null) : null, + // A template that spreads its profiles over several proxies keeps + // doing so while its first proxy is still the one chosen. + proxy_ids: proxyId && spread[0] === proxyId ? spread : [], + group_id: + base?.group_id ?? + (selectedGroupId && selectedGroupId !== "__all__" + ? selectedGroupId + : null), + extension_group_id: selectedExtensionGroupId ?? null, + dns_blocklist: dnsBlocklist || null, + dns_server: dnsServer.trim() || null, + start_urls: startPagesFromText(startPages), + launch_hook: launchHook.trim() || null, + ephemeral, + wayfern_config: wayfernConfig, + }; + }; + + const applyTemplate = (template: ProfileTemplate | null) => { + setAppliedTemplate(template); + if (!template) return; + const settings = template.settings; + setSelectedProxyId( + settings.vpn_id + ? `vpn-${settings.vpn_id}` + : (settings.proxy_ids?.[0] ?? settings.proxy_id ?? undefined), + ); + setWayfernConfig({ + ...defaultWayfernConfig(), + ...(settings.wayfern_config ?? {}), + }); + setDnsBlocklist(settings.dns_blocklist ?? ""); + setDnsServer(settings.dns_server ?? ""); + setStartPages((settings.start_urls ?? []).join("\n")); + setLaunchHook(settings.launch_hook ?? ""); + setSelectedExtensionGroupId(settings.extension_group_id ?? undefined); + setEphemeral(settings.ephemeral === true); + if (settings.ephemeral) clearPassword(); + if (!profileName.trim()) { + setProfileName(settings.name_prefix ?? template.name); + } + }; + + const saveTemplate = async (name: string) => { + const existing = templates.find( + (template) => template.name.toLowerCase() === name.toLowerCase(), + ); + try { + const saved = existing + ? await invoke("update_profile_template", { + templateId: existing.id, + name, + settings: formSettings(), + }) + : await invoke("create_profile_template", { + name, + settings: formSettings(), + }); + setAppliedTemplate(saved); + showSuccessToast(t("templates.saved", { name: saved.name })); + return true; + } catch (error) { + showErrorToast(translateBackendError(t, error)); + return false; + } + }; + + const deleteTemplate = async (template: ProfileTemplate) => { + try { + await invoke("delete_profile_template", { templateId: template.id }); + if (appliedTemplate?.id === template.id) setAppliedTemplate(null); + } catch (error) { + showErrorToast(translateBackendError(t, error)); + } + }; + + const changeCount = (next: number) => { + setCount(next); + if (next > 1) clearPassword(); + }; + + /** Several profiles, or one from a template: one backend run for both. */ + const createFromSettings = async () => { + setIsCreating(true); + setProgress({ done: 0, total: count }); + const stopListening = await listen<{ done: number; total: number }>( + "profiles-bulk-create-progress", + (event) => setProgress(event.payload), + ); + try { + const result = await invoke("bulk_create_profiles", { + settings: formSettings(), + name: profileName.trim(), + count, + }); + const [first] = result.created; + if (count === 1 && first && enablePassword && !ephemeral) { + try { + await invoke("set_profile_password", { + profileId: first.id, + password, + }); + } catch (error) { + showErrorToast( + t("errors.setProfilePasswordFailed", { + error: translateBackendError(t, error), + }), + ); + } + } + if (result.error) { + showErrorToast( + t("templates.stoppedEarly", { + created: result.created.length, + total: count, + error: translateBackendError(t, result.error), + }), + ); + } else { + showSuccessToast( + t("templates.created", { count: result.created.length }), + ); + } + handleClose(); + } catch (error) { + showErrorToast( + t("errors.createProfileFailed", { + error: translateBackendError(t, error), + }), + ); + void loadAllowance(); + } finally { + stopListening(); + setIsCreating(false); + setProgress(null); + } + }; + const handleCreate = async () => { if (isCreateDisabled || !creatableVersion) return; if (enablePassword && !ephemeral) { @@ -337,6 +527,10 @@ export function CreateProfileDialog({ } } setPasswordError(null); + if (many || appliedTemplate) { + await createFromSettings(); + return; + } setIsCreating(true); const isVpnSelection = selectedProxyId?.startsWith("vpn-") ?? false; try { @@ -396,12 +590,17 @@ export function CreateProfileDialog({ : {}; const createButton = ( - {t("common.buttons.create")} + {progress + ? t("templates.progress", progress) + : many + ? t("templates.createMany", { count }) + : t("common.buttons.create")} ); @@ -433,10 +632,19 @@ export function CreateProfileDialog({ aria-describedby={undefined} dismissible={!isCreating} > - + {t("createProfile.title")} +
+ {many && ( + + {t("templates.namesHint", { + name: profileName.trim() || t("createProfile.profileName"), + })} + {allowance !== null && + ` ${t("templates.allowanceLeft", { count: maxCount })}`} + + )} + : } label={t("profiles.passwordProtectedBadge")} - hint={t("createProfile.passwordProtect.description")} + hint={ + many + ? t("templates.passwordOneOnly") + : t("createProfile.passwordProtect.description") + } />
- + ) : ( + + )} + - {t("common.buttons.cancel")} - + /> {hourlyCapReached ? ( { @@ -1283,3 +1525,269 @@ function OptionToggle({ ); } + +/** Picks a template for the form, saves the form as one, or deletes one. */ +function TemplatePicker({ + templates, + applied, + onApply, + onSave, + onDelete, + disabled, + animate, +}: { + templates: ProfileTemplate[]; + applied: ProfileTemplate | null; + onApply: (template: ProfileTemplate | null) => void; + onSave: (name: string) => Promise; + onDelete: (template: ProfileTemplate) => Promise; + disabled: boolean; + animate: boolean; +}) { + const { t } = useTranslation(); + const valueId = useId(); + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [saving, setSaving] = useState(false); + const [confirmingDelete, setConfirmingDelete] = useState(null); + const trimmed = name.trim(); + const replaces = templates.some( + (template) => template.name.toLowerCase() === trimmed.toLowerCase(), + ); + + useEffect(() => { + if (!open) return; + setName(applied?.name ?? ""); + setConfirmingDelete(null); + }, [open, applied]); + + const save = async () => { + if (!trimmed || saving) return; + setSaving(true); + const saved = await onSave(trimmed); + setSaving(false); + if (saved) setOpen(false); + }; + + return ( + + + + + + {templates.length > 0 ? ( + + + + {applied && ( + { + onApply(null); + setOpen(false); + }} + > + + )} + {templates.map((template) => ( + { + onApply(template); + setOpen(false); + }} + > + + ))} + + + + ) : ( +

+ {t("templates.empty")} +

+ )} +
+

+ {t("templates.saveCurrent")} +

+
+ setName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.nativeEvent.isComposing) { + event.preventDefault(); + void save(); + } + }} + placeholder={t("templates.namePlaceholder")} + aria-label={t("templates.namePlaceholder")} + className="h-8 text-sm" + /> + +
+
+
+
+ ); +} + +/** How many profiles to create, from 1 to `max`. */ +function CountStepper({ + value, + max, + onChange, + disabled, +}: { + value: number; + max: number; + onChange: (value: number) => void; + disabled: boolean; +}) { + const { t } = useTranslation(); + const [draft, setDraft] = useState(String(value)); + useEffect(() => { + setDraft(String(value)); + }, [value]); + + const commit = () => { + const parsed = Number.parseInt(draft, 10); + const next = Number.isFinite(parsed) + ? Math.min(max, Math.max(1, parsed)) + : value; + setDraft(String(next)); + if (next !== value) onChange(next); + }; + const step = + "grid h-9 w-7 place-items-center text-muted-foreground transition-colors duration-100 hover:text-foreground disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-3.5"; + + return ( +
+ + setDraft(event.target.value.replace(/\D/g, ""))} + onBlur={commit} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + commit(); + } + }} + className="w-8 bg-transparent text-center text-sm tabular-nums outline-none disabled:opacity-50" + /> + +
+ ); +} diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 6849df83..b2e9d16b 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -73,6 +73,7 @@ import { ProfileStatusChip, ProfileStatusesDialog, } from "@/components/profile-statuses-dialog"; +import { SaveTemplateDialog } from "@/components/save-template-dialog"; import { DATA_TABLE_CLASSES, TableColumnResizer, @@ -1819,6 +1820,8 @@ export function ProfilesDataTable({ React.useState(null); const [statusesDialogOpen, setStatusesDialogOpen] = React.useState(false); const [bulkEditOpen, setBulkEditOpen] = React.useState(false); + const [templateProfile, setTemplateProfile] = + React.useState(null); const [dnsBlocklistProfile, setDnsBlocklistProfile] = React.useState(null); const [launchHookProfile, setLaunchHookProfile] = @@ -4133,6 +4136,7 @@ export function ProfilesDataTable({ setLaunchHookProfile(profile); }} onCloneProfile={onCloneProfile} + onSaveAsTemplate={setTemplateProfile} onLaunchWithSync={onLaunchWithSync} onSetPassword={onSetPassword} onChangePassword={onChangePassword} @@ -4281,6 +4285,10 @@ export function ProfilesDataTable({ profileId={bypassRulesProfile?.id ?? null} initialRules={bypassRulesProfile?.proxy_bypass_rules ?? []} /> + setTemplateProfile(null)} + /> setBulkEditOpen(false)} diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index ea16869c..3b4bc232 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -26,6 +26,7 @@ import { LuHouse, LuKey, LuKeyRound, + LuLayoutTemplate, LuLink, LuLock, LuLockOpen, @@ -129,6 +130,7 @@ interface ProfileInfoDialogProps { onOpenDnsBlocklist?: (profile: BrowserProfile) => void; onOpenLaunchHook?: (profile: BrowserProfile) => void; onCloneProfile?: (profile: BrowserProfile) => void; + onSaveAsTemplate?: (profile: BrowserProfile) => void; onDeleteProfile?: (profile: BrowserProfile) => void; onLaunchWithSync?: (profile: BrowserProfile) => void; onSetPassword?: (profile: BrowserProfile) => void; @@ -590,6 +592,7 @@ function ProfileInfoContents({ onOpenDnsBlocklist, onOpenLaunchHook, onCloneProfile, + onSaveAsTemplate, onDeleteProfile, onLaunchWithSync, onSetPassword, @@ -809,6 +812,15 @@ function ProfileInfoContents({ runningBadge: isRunning, hidden: profile.ephemeral === true, }, + { + id: "saveTemplate", + icon: , + label: t("templates.saveCurrent"), + onClick: () => { + handleAction(() => onSaveAsTemplate?.(profile)); + }, + hidden: !onSaveAsTemplate, + }, { id: "extension", icon: , @@ -1022,6 +1034,7 @@ function ProfileInfoLayout({ const cookiesCopyAction = findAction("cookiesCopy"); const cookiesAction = cookiesManageAction ?? cookiesCopyAction; const extensionAction = findAction("extension"); + const saveTemplateAction = findAction("saveTemplate"); const syncAction = findAction("sync"); const synchronizerAction = findAction("synchronizer"); const _launchHookAction = findAction("hook"); @@ -1176,6 +1189,18 @@ function ProfileInfoLayout({ {t("profileInfo.duplicate")} )} + {saveTemplateAction && ( + + )} + void save()} + > + {t("templates.save")} + + + + + ); +} diff --git a/src/hooks/use-config-list.ts b/src/hooks/use-config-list.ts new file mode 100644 index 00000000..2206614b --- /dev/null +++ b/src/hooks/use-config-list.ts @@ -0,0 +1,34 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { useEffect, useState } from "react"; + +/** A list the backend owns, loaded by `command` and reloaded on `event`. */ +export function useConfigList(command: string, event: string): T[] { + const [items, setItems] = useState([]); + + useEffect(() => { + let mounted = true; + let unlisten: (() => void) | undefined; + const load = async () => { + try { + const next = await invoke(command); + if (mounted) setItems(next); + } catch (error) { + console.error(`Failed to load ${command}:`, error); + } + }; + void load(); + void listen(event, () => { + void load(); + }).then((stop) => { + if (mounted) unlisten = stop; + else stop(); + }); + return () => { + mounted = false; + unlisten?.(); + }; + }, [command, event]); + + return items; +} diff --git a/src/hooks/use-profile-statuses.ts b/src/hooks/use-profile-statuses.ts index 20ae23d3..15ce31cb 100644 --- a/src/hooks/use-profile-statuses.ts +++ b/src/hooks/use-profile-statuses.ts @@ -1,35 +1,10 @@ -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; -import { useEffect, useState } from "react"; +import { useConfigList } from "@/hooks/use-config-list"; import type { ProfileStatus } from "@/types"; /** Every profile status, in order, kept current by `profile-statuses-changed`. */ export function useProfileStatuses(): ProfileStatus[] { - const [statuses, setStatuses] = useState([]); - - useEffect(() => { - let mounted = true; - let unlisten: (() => void) | undefined; - const load = async () => { - try { - const next = await invoke("list_profile_statuses"); - if (mounted) setStatuses(next); - } catch (error) { - console.error("Failed to load profile statuses:", error); - } - }; - void load(); - void listen("profile-statuses-changed", () => { - void load(); - }).then((stop) => { - if (mounted) unlisten = stop; - else stop(); - }); - return () => { - mounted = false; - unlisten?.(); - }; - }, []); - - return statuses; + return useConfigList( + "list_profile_statuses", + "profile-statuses-changed", + ); } diff --git a/src/hooks/use-profile-templates.ts b/src/hooks/use-profile-templates.ts new file mode 100644 index 00000000..b941cb05 --- /dev/null +++ b/src/hooks/use-profile-templates.ts @@ -0,0 +1,10 @@ +import { useConfigList } from "@/hooks/use-config-list"; +import type { ProfileTemplate } from "@/types"; + +/** Every profile template, in order, kept current by `profile-templates-changed`. */ +export function useProfileTemplates(): ProfileTemplate[] { + return useConfigList( + "list_profile_templates", + "profile-templates-changed", + ); +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index b4300a42..a2fde79e 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1790,6 +1790,32 @@ "applied_one": "{{count}} Profil geändert", "applied_other": "{{count}} Profile geändert" }, + "templates": { + "label": "Vorlage", + "none": "Keine Vorlage", + "empty": "Noch keine Vorlagen. Richten Sie hier ein Profil ein und speichern Sie es dann als Vorlage.", + "saveCurrent": "Als Vorlage speichern", + "namePlaceholder": "Name der Vorlage", + "save": "Speichern", + "replace": "Ersetzen", + "saved": "Vorlage {{name}} gespeichert", + "delete": "Vorlage {{name}} löschen", + "count": "Anzahl der Profile", + "fewer": "Weniger", + "more": "Mehr", + "namesHint": "Die Profile heißen {{name}} 1, {{name}} 2 und so weiter, nach den bereits verwendeten Nummern.", + "allowanceLeft_one": "Sie können in dieser Stunde noch {{count}} erstellen.", + "allowanceLeft_other": "Sie können in dieser Stunde noch {{count}} erstellen.", + "createMany_one": "{{count}} Profil erstellen", + "createMany_other": "{{count}} Profile erstellen", + "progress": "Erstelle {{done}} von {{total}}", + "stop": "Stoppen", + "created_one": "{{count}} Profil erstellt", + "created_other": "{{count}} Profile erstellt", + "stoppedEarly": "{{created}} von {{total}} Profilen erstellt. {{error}}", + "passwordOneOnly": "Ein Passwort können Sie nur festlegen, wenn Sie ein einzelnes Profil erstellen.", + "saveFromProfileDescription": "Neue Profile aus dieser Vorlage erhalten ihre Einstellungen und jeweils ein eigenes neues Gerät. Name, Notiz und Gerät bleiben bei diesem Profil." + }, "vpns": { "form": { "titleEdit": "VPN bearbeiten", @@ -2531,7 +2557,13 @@ "totpLimitReached": "Ein Profil kann höchstens 50 2FA-Schlüssel speichern.", "totpEntryNotFound": "Dieser 2FA-Schlüssel existiert nicht mehr.", "dnsBlocklistInvalid": "Diese Stufe der DNS-Sperrliste existiert nicht.", - "webrtcModeInvalid": "WebRTC muss Automatisch, Nur Proxy-Ausgang oder Blockiert sein." + "webrtcModeInvalid": "WebRTC muss Automatisch, Nur Proxy-Ausgang oder Blockiert sein.", + "templateNotFound": "Diese Vorlage existiert nicht mehr.", + "templateExists": "Eine Vorlage mit diesem Namen existiert bereits.", + "templateNameTooLong": "Ein Vorlagenname darf höchstens 60 Zeichen haben.", + "templateLimitReached": "Sie können höchstens 100 Vorlagen behalten. Löschen Sie zuerst eine.", + "bulkCreateCountInvalid": "Sie können 1 bis {{max}} Profile auf einmal erstellen.", + "bulkCreateBusy": "Es werden bereits Profile erstellt. Warten Sie, bis das abgeschlossen ist." }, "rail": { "profiles": "Profile", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6732c620..0dcc68aa 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1790,6 +1790,32 @@ "applied_one": "Changed {{count}} profile", "applied_other": "Changed {{count}} profiles" }, + "templates": { + "label": "Template", + "none": "No template", + "empty": "No templates yet. Set up a profile here, then save it as a template.", + "saveCurrent": "Save as template", + "namePlaceholder": "Template name", + "save": "Save", + "replace": "Replace", + "saved": "Template {{name}} saved", + "delete": "Delete template {{name}}", + "count": "Number of profiles", + "fewer": "Fewer", + "more": "More", + "namesHint": "The profiles get the names {{name}} 1, {{name}} 2 and so on, after the numbers already in use.", + "allowanceLeft_one": "You can create {{count}} more this hour.", + "allowanceLeft_other": "You can create {{count}} more this hour.", + "createMany_one": "Create {{count}} profile", + "createMany_other": "Create {{count}} profiles", + "progress": "Creating {{done}} of {{total}}", + "stop": "Stop", + "created_one": "Created {{count}} profile", + "created_other": "Created {{count}} profiles", + "stoppedEarly": "Created {{created}} of {{total}} profiles. {{error}}", + "passwordOneOnly": "You can set a password only when you create one profile.", + "saveFromProfileDescription": "New profiles made from this template get its settings and their own new device. The name, the note and the device stay with this profile." + }, "vpns": { "form": { "titleEdit": "Edit VPN", @@ -2531,7 +2557,13 @@ "totpLimitReached": "A profile can keep at most 50 2FA keys.", "totpEntryNotFound": "This 2FA key no longer exists.", "dnsBlocklistInvalid": "This DNS blocklist level does not exist.", - "webrtcModeInvalid": "WebRTC must be Automatic, Proxy exit only or Blocked." + "webrtcModeInvalid": "WebRTC must be Automatic, Proxy exit only or Blocked.", + "templateNotFound": "This template no longer exists.", + "templateExists": "A template with this name already exists.", + "templateNameTooLong": "A template name can have at most 60 characters.", + "templateLimitReached": "You can keep at most 100 templates. Delete one first.", + "bulkCreateCountInvalid": "You can create from 1 to {{max}} profiles at a time.", + "bulkCreateBusy": "Profiles are already being created. Wait until that is done." }, "rail": { "profiles": "Profiles", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 15c01054..0316fa9d 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1799,6 +1799,35 @@ "applied_many": "{{count}} perfiles cambiados", "applied_other": "{{count}} perfiles cambiados" }, + "templates": { + "label": "Plantilla", + "none": "Sin plantilla", + "empty": "Aún no hay plantillas. Configura aquí un perfil y luego guárdalo como plantilla.", + "saveCurrent": "Guardar como plantilla", + "namePlaceholder": "Nombre de la plantilla", + "save": "Guardar", + "replace": "Reemplazar", + "saved": "Plantilla {{name}} guardada", + "delete": "Eliminar la plantilla {{name}}", + "count": "Número de perfiles", + "fewer": "Menos", + "more": "Más", + "namesHint": "Los perfiles se llaman {{name}} 1, {{name}} 2 y así sucesivamente, después de los números ya usados.", + "allowanceLeft_one": "Puedes crear {{count}} más en esta hora.", + "allowanceLeft_many": "Puedes crear {{count}} más en esta hora.", + "allowanceLeft_other": "Puedes crear {{count}} más en esta hora.", + "createMany_one": "Crear {{count}} perfil", + "createMany_many": "Crear {{count}} perfiles", + "createMany_other": "Crear {{count}} perfiles", + "progress": "Creando {{done}} de {{total}}", + "stop": "Detener", + "created_one": "{{count}} perfil creado", + "created_many": "{{count}} perfiles creados", + "created_other": "{{count}} perfiles creados", + "stoppedEarly": "Se crearon {{created}} de {{total}} perfiles. {{error}}", + "passwordOneOnly": "Solo puedes poner una contraseña cuando creas un único perfil.", + "saveFromProfileDescription": "Los perfiles nuevos creados con esta plantilla reciben sus ajustes y su propio dispositivo nuevo. El nombre, la nota y el dispositivo se quedan con este perfil." + }, "vpns": { "form": { "titleEdit": "Editar VPN", @@ -2544,7 +2573,13 @@ "totpLimitReached": "Un perfil puede guardar como máximo 50 claves 2FA.", "totpEntryNotFound": "Esta clave 2FA ya no existe.", "dnsBlocklistInvalid": "Este nivel de lista de bloqueo DNS no existe.", - "webrtcModeInvalid": "WebRTC debe ser Automático, Solo salida del proxy o Bloqueado." + "webrtcModeInvalid": "WebRTC debe ser Automático, Solo salida del proxy o Bloqueado.", + "templateNotFound": "Esta plantilla ya no existe.", + "templateExists": "Ya existe una plantilla con este nombre.", + "templateNameTooLong": "El nombre de una plantilla puede tener como máximo 60 caracteres.", + "templateLimitReached": "Puedes guardar como máximo 100 plantillas. Elimina una primero.", + "bulkCreateCountInvalid": "Puedes crear de 1 a {{max}} perfiles a la vez.", + "bulkCreateBusy": "Ya se están creando perfiles. Espera a que termine." }, "rail": { "profiles": "Perfiles", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 50f2ae2b..3dcb5810 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1799,6 +1799,35 @@ "applied_many": "{{count}} profils modifiés", "applied_other": "{{count}} profils modifiés" }, + "templates": { + "label": "Modèle", + "none": "Aucun modèle", + "empty": "Aucun modèle pour l'instant. Configurez un profil ici, puis enregistrez-le comme modèle.", + "saveCurrent": "Enregistrer comme modèle", + "namePlaceholder": "Nom du modèle", + "save": "Enregistrer", + "replace": "Remplacer", + "saved": "Modèle {{name}} enregistré", + "delete": "Supprimer le modèle {{name}}", + "count": "Nombre de profils", + "fewer": "Moins", + "more": "Plus", + "namesHint": "Les profils s'appellent {{name}} 1, {{name}} 2 et ainsi de suite, après les numéros déjà utilisés.", + "allowanceLeft_one": "Vous pouvez encore en créer {{count}} cette heure-ci.", + "allowanceLeft_many": "Vous pouvez encore en créer {{count}} cette heure-ci.", + "allowanceLeft_other": "Vous pouvez encore en créer {{count}} cette heure-ci.", + "createMany_one": "Créer {{count}} profil", + "createMany_many": "Créer {{count}} profils", + "createMany_other": "Créer {{count}} profils", + "progress": "Création de {{done}} sur {{total}}", + "stop": "Arrêter", + "created_one": "{{count}} profil créé", + "created_many": "{{count}} profils créés", + "created_other": "{{count}} profils créés", + "stoppedEarly": "{{created}} profils sur {{total}} créés. {{error}}", + "passwordOneOnly": "Vous ne pouvez définir un mot de passe que si vous créez un seul profil.", + "saveFromProfileDescription": "Les nouveaux profils créés à partir de ce modèle reçoivent ses réglages et leur propre nouvel appareil. Le nom, la note et l'appareil restent avec ce profil." + }, "vpns": { "form": { "titleEdit": "Modifier le VPN", @@ -2544,7 +2573,13 @@ "totpLimitReached": "Un profil peut garder au plus 50 clés 2FA.", "totpEntryNotFound": "Cette clé 2FA n'existe plus.", "dnsBlocklistInvalid": "Ce niveau de liste de blocage DNS n'existe pas.", - "webrtcModeInvalid": "WebRTC doit être Automatique, Sortie du proxy uniquement ou Bloqué." + "webrtcModeInvalid": "WebRTC doit être Automatique, Sortie du proxy uniquement ou Bloqué.", + "templateNotFound": "Ce modèle n'existe plus.", + "templateExists": "Un modèle portant ce nom existe déjà.", + "templateNameTooLong": "Le nom d'un modèle peut contenir au plus 60 caractères.", + "templateLimitReached": "Vous pouvez garder au plus 100 modèles. Supprimez-en un d'abord.", + "bulkCreateCountInvalid": "Vous pouvez créer de 1 à {{max}} profils à la fois.", + "bulkCreateBusy": "Des profils sont déjà en cours de création. Attendez la fin." }, "rail": { "profiles": "Profils", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 809cd872..9d2425d7 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1790,6 +1790,32 @@ "applied_one": "{{count}} 件のプロファイルを変更しました", "applied_other": "{{count}} 件のプロファイルを変更しました" }, + "templates": { + "label": "テンプレート", + "none": "テンプレートなし", + "empty": "テンプレートはまだありません。ここでプロファイルを設定し、テンプレートとして保存してください。", + "saveCurrent": "テンプレートとして保存", + "namePlaceholder": "テンプレート名", + "save": "保存", + "replace": "置き換え", + "saved": "テンプレート {{name}} を保存しました", + "delete": "テンプレート {{name}} を削除", + "count": "プロファイル数", + "fewer": "減らす", + "more": "増やす", + "namesHint": "プロファイル名は {{name}} 1、{{name}} 2 のように、使用済みの番号の続きになります。", + "allowanceLeft_one": "この1時間であと {{count}} 件作成できます。", + "allowanceLeft_other": "この1時間であと {{count}} 件作成できます。", + "createMany_one": "{{count}} 件のプロファイルを作成", + "createMany_other": "{{count}} 件のプロファイルを作成", + "progress": "{{total}} 件中 {{done}} 件を作成中", + "stop": "停止", + "created_one": "{{count}} 件のプロファイルを作成しました", + "created_other": "{{count}} 件のプロファイルを作成しました", + "stoppedEarly": "{{total}} 件中 {{created}} 件のプロファイルを作成しました。{{error}}", + "passwordOneOnly": "パスワードはプロファイルを1件だけ作成するときにのみ設定できます。", + "saveFromProfileDescription": "このテンプレートから作成した新しいプロファイルは、その設定とそれぞれ新しいデバイスを受け取ります。名前、メモ、デバイスはこのプロファイルに残ります。" + }, "vpns": { "form": { "titleEdit": "VPN を編集", @@ -2531,7 +2557,13 @@ "totpLimitReached": "1 つのプロファイルに保存できる 2FA キーは 50 個までです。", "totpEntryNotFound": "この 2FA キーはもう存在しません。", "dnsBlocklistInvalid": "この DNS ブロックリストのレベルは存在しません。", - "webrtcModeInvalid": "WebRTC は「自動」「プロキシ出口のみ」「ブロック」のいずれかにしてください。" + "webrtcModeInvalid": "WebRTC は「自動」「プロキシ出口のみ」「ブロック」のいずれかにしてください。", + "templateNotFound": "このテンプレートはもう存在しません。", + "templateExists": "この名前のテンプレートは既に存在します。", + "templateNameTooLong": "テンプレート名は60文字以内にしてください。", + "templateLimitReached": "保存できるテンプレートは最大100件です。先に1件削除してください。", + "bulkCreateCountInvalid": "一度に作成できるプロファイルは1〜{{max}}件です。", + "bulkCreateBusy": "既にプロファイルを作成中です。完了するまでお待ちください。" }, "rail": { "profiles": "プロファイル", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 4ceae69b..4f4afda1 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1790,6 +1790,32 @@ "applied_one": "프로필 {{count}}개를 변경했습니다", "applied_other": "프로필 {{count}}개를 변경했습니다" }, + "templates": { + "label": "템플릿", + "none": "템플릿 없음", + "empty": "아직 템플릿이 없습니다. 여기서 프로필을 설정한 다음 템플릿으로 저장하세요.", + "saveCurrent": "템플릿으로 저장", + "namePlaceholder": "템플릿 이름", + "save": "저장", + "replace": "바꾸기", + "saved": "템플릿 {{name}}을(를) 저장했습니다", + "delete": "템플릿 {{name}} 삭제", + "count": "프로필 수", + "fewer": "줄이기", + "more": "늘리기", + "namesHint": "프로필 이름은 이미 사용 중인 번호 다음부터 {{name}} 1, {{name}} 2 순서로 붙습니다.", + "allowanceLeft_one": "이번 시간에 {{count}}개를 더 만들 수 있습니다.", + "allowanceLeft_other": "이번 시간에 {{count}}개를 더 만들 수 있습니다.", + "createMany_one": "프로필 {{count}}개 만들기", + "createMany_other": "프로필 {{count}}개 만들기", + "progress": "{{total}}개 중 {{done}}개 만드는 중", + "stop": "중지", + "created_one": "프로필 {{count}}개를 만들었습니다", + "created_other": "프로필 {{count}}개를 만들었습니다", + "stoppedEarly": "프로필 {{total}}개 중 {{created}}개를 만들었습니다. {{error}}", + "passwordOneOnly": "비밀번호는 프로필을 하나만 만들 때만 설정할 수 있습니다.", + "saveFromProfileDescription": "이 템플릿으로 만든 새 프로필은 템플릿의 설정과 각자의 새 기기를 받습니다. 이름, 메모, 기기는 이 프로필에 남습니다." + }, "vpns": { "form": { "titleEdit": "VPN 편집", @@ -2531,7 +2557,13 @@ "totpLimitReached": "프로필 하나에 2FA 키는 최대 50개까지 저장할 수 있습니다.", "totpEntryNotFound": "이 2FA 키는 더 이상 존재하지 않습니다.", "dnsBlocklistInvalid": "이 DNS 차단 목록 수준은 존재하지 않습니다.", - "webrtcModeInvalid": "WebRTC는 자동, 프록시 출구만, 차단 중 하나여야 합니다." + "webrtcModeInvalid": "WebRTC는 자동, 프록시 출구만, 차단 중 하나여야 합니다.", + "templateNotFound": "이 템플릿은 더 이상 존재하지 않습니다.", + "templateExists": "이 이름의 템플릿이 이미 있습니다.", + "templateNameTooLong": "템플릿 이름은 최대 60자까지 가능합니다.", + "templateLimitReached": "템플릿은 최대 100개까지 보관할 수 있습니다. 먼저 하나를 삭제하세요.", + "bulkCreateCountInvalid": "한 번에 1개부터 {{max}}개까지 프로필을 만들 수 있습니다.", + "bulkCreateBusy": "이미 프로필을 만드는 중입니다. 끝날 때까지 기다리세요." }, "rail": { "profiles": "프로필", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 94775568..9121456f 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1799,6 +1799,35 @@ "applied_many": "{{count}} perfis alterados", "applied_other": "{{count}} perfis alterados" }, + "templates": { + "label": "Modelo", + "none": "Sem modelo", + "empty": "Ainda não há modelos. Configure um perfil aqui e depois salve-o como modelo.", + "saveCurrent": "Salvar como modelo", + "namePlaceholder": "Nome do modelo", + "save": "Salvar", + "replace": "Substituir", + "saved": "Modelo {{name}} salvo", + "delete": "Excluir o modelo {{name}}", + "count": "Número de perfis", + "fewer": "Menos", + "more": "Mais", + "namesHint": "Os perfis recebem os nomes {{name}} 1, {{name}} 2 e assim por diante, depois dos números já usados.", + "allowanceLeft_one": "Você pode criar mais {{count}} nesta hora.", + "allowanceLeft_many": "Você pode criar mais {{count}} nesta hora.", + "allowanceLeft_other": "Você pode criar mais {{count}} nesta hora.", + "createMany_one": "Criar {{count}} perfil", + "createMany_many": "Criar {{count}} perfis", + "createMany_other": "Criar {{count}} perfis", + "progress": "Criando {{done}} de {{total}}", + "stop": "Parar", + "created_one": "{{count}} perfil criado", + "created_many": "{{count}} perfis criados", + "created_other": "{{count}} perfis criados", + "stoppedEarly": "{{created}} de {{total}} perfis criados. {{error}}", + "passwordOneOnly": "Você só pode definir uma senha ao criar um único perfil.", + "saveFromProfileDescription": "Os novos perfis criados a partir deste modelo recebem as configurações dele e um novo dispositivo próprio. O nome, a nota e o dispositivo ficam com este perfil." + }, "vpns": { "form": { "titleEdit": "Editar VPN", @@ -2544,7 +2573,13 @@ "totpLimitReached": "Um perfil pode guardar no máximo 50 chaves 2FA.", "totpEntryNotFound": "Esta chave 2FA não existe mais.", "dnsBlocklistInvalid": "Este nível de lista de bloqueio DNS não existe.", - "webrtcModeInvalid": "O WebRTC precisa ser Automático, Só saída do proxy ou Bloqueado." + "webrtcModeInvalid": "O WebRTC precisa ser Automático, Só saída do proxy ou Bloqueado.", + "templateNotFound": "Este modelo não existe mais.", + "templateExists": "Já existe um modelo com este nome.", + "templateNameTooLong": "O nome de um modelo pode ter no máximo 60 caracteres.", + "templateLimitReached": "Você pode manter no máximo 100 modelos. Exclua um primeiro.", + "bulkCreateCountInvalid": "Você pode criar de 1 a {{max}} perfis de cada vez.", + "bulkCreateBusy": "Já há perfis sendo criados. Aguarde até terminar." }, "rail": { "profiles": "Perfis", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 384d485a..9f49cd59 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1808,6 +1808,38 @@ "applied_many": "Изменено {{count}} профилей", "applied_other": "Изменено {{count}} профилей" }, + "templates": { + "label": "Шаблон", + "none": "Без шаблона", + "empty": "Шаблонов пока нет. Настройте здесь профиль, затем сохраните его как шаблон.", + "saveCurrent": "Сохранить как шаблон", + "namePlaceholder": "Название шаблона", + "save": "Сохранить", + "replace": "Заменить", + "saved": "Шаблон {{name}} сохранён", + "delete": "Удалить шаблон {{name}}", + "count": "Количество профилей", + "fewer": "Меньше", + "more": "Больше", + "namesHint": "Профили получат имена {{name}} 1, {{name}} 2 и так далее, после уже занятых номеров.", + "allowanceLeft_one": "В этот час можно создать ещё {{count}}.", + "allowanceLeft_few": "В этот час можно создать ещё {{count}}.", + "allowanceLeft_many": "В этот час можно создать ещё {{count}}.", + "allowanceLeft_other": "В этот час можно создать ещё {{count}}.", + "createMany_one": "Создать {{count}} профиль", + "createMany_few": "Создать {{count}} профиля", + "createMany_many": "Создать {{count}} профилей", + "createMany_other": "Создать {{count}} профилей", + "progress": "Создание {{done}} из {{total}}", + "stop": "Остановить", + "created_one": "Создан {{count}} профиль", + "created_few": "Создано {{count}} профиля", + "created_many": "Создано {{count}} профилей", + "created_other": "Создано {{count}} профилей", + "stoppedEarly": "Создано профилей: {{created}} из {{total}}. {{error}}", + "passwordOneOnly": "Пароль можно задать, только когда вы создаёте один профиль.", + "saveFromProfileDescription": "Новые профили из этого шаблона получат его настройки и каждый своё новое устройство. Имя, заметка и устройство останутся у этого профиля." + }, "vpns": { "form": { "titleEdit": "Изменить VPN", @@ -2557,7 +2589,13 @@ "totpLimitReached": "Профиль может хранить не более 50 ключей 2FA.", "totpEntryNotFound": "Этого ключа 2FA больше нет.", "dnsBlocklistInvalid": "Такого уровня списка блокировки DNS нет.", - "webrtcModeInvalid": "WebRTC должен быть «Автоматически», «Только выход прокси» или «Заблокирован»." + "webrtcModeInvalid": "WebRTC должен быть «Автоматически», «Только выход прокси» или «Заблокирован».", + "templateNotFound": "Этого шаблона больше нет.", + "templateExists": "Шаблон с таким названием уже существует.", + "templateNameTooLong": "Название шаблона может содержать не более 60 символов.", + "templateLimitReached": "Можно хранить не более 100 шаблонов. Сначала удалите один.", + "bulkCreateCountInvalid": "За один раз можно создать от 1 до {{max}} профилей.", + "bulkCreateBusy": "Профили уже создаются. Дождитесь окончания." }, "rail": { "profiles": "Профили", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 4815c72c..4d6248dc 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1790,6 +1790,32 @@ "applied_one": "{{count}} profil değiştirildi", "applied_other": "{{count}} profil değiştirildi" }, + "templates": { + "label": "Şablon", + "none": "Şablon yok", + "empty": "Henüz şablon yok. Burada bir profil ayarlayın, ardından şablon olarak kaydedin.", + "saveCurrent": "Şablon olarak kaydet", + "namePlaceholder": "Şablon adı", + "save": "Kaydet", + "replace": "Değiştir", + "saved": "{{name}} şablonu kaydedildi", + "delete": "{{name}} şablonunu sil", + "count": "Profil sayısı", + "fewer": "Azalt", + "more": "Artır", + "namesHint": "Profiller, kullanılan numaralardan sonra gelecek şekilde {{name}} 1, {{name}} 2 diye adlandırılır.", + "allowanceLeft_one": "Bu saat içinde {{count}} tane daha oluşturabilirsiniz.", + "allowanceLeft_other": "Bu saat içinde {{count}} tane daha oluşturabilirsiniz.", + "createMany_one": "{{count}} profil oluştur", + "createMany_other": "{{count}} profil oluştur", + "progress": "{{total}} profilden {{done}} tanesi oluşturuluyor", + "stop": "Durdur", + "created_one": "{{count}} profil oluşturuldu", + "created_other": "{{count}} profil oluşturuldu", + "stoppedEarly": "{{total}} profilden {{created}} tanesi oluşturuldu. {{error}}", + "passwordOneOnly": "Parolayı yalnızca tek bir profil oluştururken ayarlayabilirsiniz.", + "saveFromProfileDescription": "Bu şablondan oluşturulan yeni profiller şablonun ayarlarını ve her biri kendi yeni cihazını alır. Ad, not ve cihaz bu profilde kalır." + }, "vpns": { "form": { "titleEdit": "VPN'i Düzenle", @@ -2531,7 +2557,13 @@ "totpLimitReached": "Bir profil en fazla 50 2FA anahtarı saklayabilir.", "totpEntryNotFound": "Bu 2FA anahtarı artık yok.", "dnsBlocklistInvalid": "Bu DNS engel listesi düzeyi yok.", - "webrtcModeInvalid": "WebRTC Otomatik, Yalnızca proxy çıkışı veya Engelli olmalıdır." + "webrtcModeInvalid": "WebRTC Otomatik, Yalnızca proxy çıkışı veya Engelli olmalıdır.", + "templateNotFound": "Bu şablon artık yok.", + "templateExists": "Bu adda bir şablon zaten var.", + "templateNameTooLong": "Şablon adı en fazla 60 karakter olabilir.", + "templateLimitReached": "En fazla 100 şablon tutabilirsiniz. Önce birini silin.", + "bulkCreateCountInvalid": "Bir seferde 1 ile {{max}} arasında profil oluşturabilirsiniz.", + "bulkCreateBusy": "Profiller zaten oluşturuluyor. Bitmesini bekleyin." }, "rail": { "profiles": "Profiller", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index cae067fe..9e8ff5ac 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -1790,6 +1790,32 @@ "applied_one": "Đã thay đổi {{count}} profile", "applied_other": "Đã thay đổi {{count}} profile" }, + "templates": { + "label": "Mẫu", + "none": "Không dùng mẫu", + "empty": "Chưa có mẫu nào. Hãy thiết lập profile ở đây rồi lưu thành mẫu.", + "saveCurrent": "Lưu thành mẫu", + "namePlaceholder": "Tên mẫu", + "save": "Lưu", + "replace": "Thay thế", + "saved": "Đã lưu mẫu {{name}}", + "delete": "Xóa mẫu {{name}}", + "count": "Số lượng profile", + "fewer": "Bớt", + "more": "Thêm", + "namesHint": "Các profile được đặt tên {{name}} 1, {{name}} 2, v.v., tiếp sau các số đã dùng.", + "allowanceLeft_one": "Bạn có thể tạo thêm {{count}} trong giờ này.", + "allowanceLeft_other": "Bạn có thể tạo thêm {{count}} trong giờ này.", + "createMany_one": "Tạo {{count}} profile", + "createMany_other": "Tạo {{count}} profile", + "progress": "Đang tạo {{done}} trên {{total}}", + "stop": "Dừng", + "created_one": "Đã tạo {{count}} profile", + "created_other": "Đã tạo {{count}} profile", + "stoppedEarly": "Đã tạo {{created}} trên {{total}} profile. {{error}}", + "passwordOneOnly": "Bạn chỉ có thể đặt mật khẩu khi tạo một profile.", + "saveFromProfileDescription": "Các profile mới tạo từ mẫu này nhận cài đặt của mẫu và mỗi profile có thiết bị mới riêng. Tên, ghi chú và thiết bị vẫn thuộc profile này." + }, "vpns": { "form": { "titleEdit": "Chỉnh sửa VPN", @@ -2531,7 +2557,13 @@ "totpLimitReached": "Mỗi profile lưu tối đa 50 khóa 2FA.", "totpEntryNotFound": "Khóa 2FA này không còn tồn tại.", "dnsBlocklistInvalid": "Mức danh sách chặn DNS này không tồn tại.", - "webrtcModeInvalid": "WebRTC phải là Tự động, Chỉ lối ra proxy hoặc Bị chặn." + "webrtcModeInvalid": "WebRTC phải là Tự động, Chỉ lối ra proxy hoặc Bị chặn.", + "templateNotFound": "Mẫu này không còn tồn tại.", + "templateExists": "Đã có mẫu với tên này.", + "templateNameTooLong": "Tên mẫu có tối đa 60 ký tự.", + "templateLimitReached": "Bạn chỉ có thể giữ tối đa 100 mẫu. Hãy xóa một mẫu trước.", + "bulkCreateCountInvalid": "Mỗi lần bạn có thể tạo từ 1 đến {{max}} profile.", + "bulkCreateBusy": "Đang có profile được tạo. Hãy chờ đến khi xong." }, "rail": { "profiles": "Profile", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index cb34e205..753c6964 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1790,6 +1790,32 @@ "applied_one": "已更改 {{count}} 个配置文件", "applied_other": "已更改 {{count}} 个配置文件" }, + "templates": { + "label": "模板", + "none": "不使用模板", + "empty": "还没有模板。在这里设置好配置文件,然后将其保存为模板。", + "saveCurrent": "保存为模板", + "namePlaceholder": "模板名称", + "save": "保存", + "replace": "替换", + "saved": "已保存模板 {{name}}", + "delete": "删除模板 {{name}}", + "count": "配置文件数量", + "fewer": "减少", + "more": "增加", + "namesHint": "配置文件将命名为 {{name}} 1、{{name}} 2 等,编号接在已用编号之后。", + "allowanceLeft_one": "本小时内还可以创建 {{count}} 个。", + "allowanceLeft_other": "本小时内还可以创建 {{count}} 个。", + "createMany_one": "创建 {{count}} 个配置文件", + "createMany_other": "创建 {{count}} 个配置文件", + "progress": "正在创建第 {{done}} 个,共 {{total}} 个", + "stop": "停止", + "created_one": "已创建 {{count}} 个配置文件", + "created_other": "已创建 {{count}} 个配置文件", + "stoppedEarly": "已创建 {{created}} 个配置文件,共 {{total}} 个。{{error}}", + "passwordOneOnly": "只有在创建单个配置文件时才能设置密码。", + "saveFromProfileDescription": "用此模板创建的新配置文件会获得它的设置以及各自的新设备。名称、备注和设备保留在此配置文件中。" + }, "vpns": { "form": { "titleEdit": "编辑 VPN", @@ -2531,7 +2557,13 @@ "totpLimitReached": "一个配置文件最多保存 50 个 2FA 密钥。", "totpEntryNotFound": "此 2FA 密钥已不存在。", "dnsBlocklistInvalid": "该 DNS 拦截列表级别不存在。", - "webrtcModeInvalid": "WebRTC 必须是“自动”“仅代理出口”或“已阻止”。" + "webrtcModeInvalid": "WebRTC 必须是“自动”“仅代理出口”或“已阻止”。", + "templateNotFound": "该模板已不存在。", + "templateExists": "已存在同名模板。", + "templateNameTooLong": "模板名称最多 60 个字符。", + "templateLimitReached": "最多可保留 100 个模板。请先删除一个。", + "bulkCreateCountInvalid": "每次可以创建 1 到 {{max}} 个配置文件。", + "bulkCreateBusy": "已在创建配置文件。请等待完成。" }, "rail": { "profiles": "配置文件", diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts index d89a65df..6b209256 100644 --- a/src/lib/backend-errors.ts +++ b/src/lib/backend-errors.ts @@ -43,6 +43,12 @@ export type BackendErrorCode = | "TOTP_ENTRY_NOT_FOUND" | "DNS_BLOCKLIST_INVALID" | "WEBRTC_MODE_INVALID" + | "TEMPLATE_NOT_FOUND" + | "TEMPLATE_EXISTS" + | "TEMPLATE_NAME_TOO_LONG" + | "TEMPLATE_LIMIT_REACHED" + | "BULK_CREATE_COUNT_INVALID" + | "BULK_CREATE_BUSY" | "COOKIE_DB_LOCKED" | "COOKIE_DB_UNAVAILABLE" | "COOKIE_IMPORT_BROWSER_RUNNING" @@ -419,6 +425,20 @@ export function translateBackendError(t: TFunction, err: unknown): string { return t("backendErrors.dnsBlocklistInvalid"); case "WEBRTC_MODE_INVALID": return t("backendErrors.webrtcModeInvalid"); + case "TEMPLATE_NOT_FOUND": + return t("backendErrors.templateNotFound"); + case "TEMPLATE_EXISTS": + return t("backendErrors.templateExists"); + case "TEMPLATE_NAME_TOO_LONG": + return t("backendErrors.templateNameTooLong"); + case "TEMPLATE_LIMIT_REACHED": + return t("backendErrors.templateLimitReached"); + case "BULK_CREATE_COUNT_INVALID": + return t("backendErrors.bulkCreateCountInvalid", { + max: parsed.params?.max ?? "100", + }); + case "BULK_CREATE_BUSY": + return t("backendErrors.bulkCreateBusy"); case "COOKIE_DB_LOCKED": return t("backendErrors.cookieDbLocked"); case "COOKIE_DB_UNAVAILABLE": diff --git a/src/types.ts b/src/types.ts index 578a7313..c03f555e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -362,6 +362,46 @@ export interface ProfileStatus { updated_at: number; } +/** + * Saved settings that new profiles are made from. A template never holds a + * device: every profile made from it gets its own. + */ +export interface TemplateSettings { + name_prefix?: string | null; + proxy_id?: string | null; + vpn_id?: string | null; + /** Proxies to spread new profiles across, one each in turn. */ + proxy_ids?: string[]; + group_id?: string | null; + tags?: string[]; + status_id?: string | null; + note?: string | null; + extension_group_id?: string | null; + dns_blocklist?: string | null; + dns_server?: string | null; + proxy_bypass_rules?: string[]; + start_urls?: string[]; + launch_hook?: string | null; + ephemeral?: boolean; + clear_on_close?: boolean; + wayfern_config?: WayfernConfig | null; +} + +export interface ProfileTemplate { + id: string; + name: string; + settings: TemplateSettings; + position: number; + updated_at: number; +} + +export interface BulkCreateResult { + created: BrowserProfile[]; + /** Why creation stopped before the count, as a backend error, or null. */ + error: string | null; + cancelled: boolean; +} + export interface GroupWithCount { id: string; name: string;