mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-20 14:37:30 +02:00
feat: profile templates
This commit is contained in:
1 parent
3f5b573ead
commit
2cc91ed250
29 files changed
+2430
-62
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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"), []);
|
||||
|
||||
+104
-1
@@ -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" });
|
||||
|
||||
+205
-1
@@ -274,6 +274,33 @@ impl From<crate::profile_statuses::ProfileStatus> 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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct ApiBulkCreateResponse {
|
||||
/// The profiles made, in order.
|
||||
profiles: Vec<ApiProfile>,
|
||||
/// Why creation stopped before `count` (an error code JSON), or `null`.
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[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<ApiServerState> {
|
||||
.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<crate::profile_templates::ProfileTemplate>),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "templates"
|
||||
)]
|
||||
async fn get_templates() -> Json<Vec<crate::profile_templates::ProfileTemplate>> {
|
||||
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<TemplateRequest>,
|
||||
) -> Result<Json<crate::profile_templates::ProfileTemplate>, (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<String>,
|
||||
Json(request): Json<TemplateRequest>,
|
||||
) -> Result<Json<crate::profile_templates::ProfileTemplate>, (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<String>) -> Result<StatusCode, (StatusCode, String)> {
|
||||
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<ApiServerState>,
|
||||
Path(id): Path<String>,
|
||||
Json(request): Json<CreateFromTemplateRequest>,
|
||||
) -> Result<Json<ApiBulkCreateResponse>, (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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<serde_json::Value, McpError> {
|
||||
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<serde_json::Value> = 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"
|
||||
);
|
||||
|
||||
|
||||
@@ -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<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
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(
|
||||
|
||||
@@ -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<Object>)]
|
||||
pub wayfern_config: Option<WayfernConfig>,
|
||||
}
|
||||
|
||||
#[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<String>) -> Option<String> {
|
||||
value
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
fn checked_name(name: &str) -> Result<String, String> {
|
||||
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<Self, String> {
|
||||
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<String> = 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<ProfileTemplate> {
|
||||
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<ProfileTemplate, String> {
|
||||
STORE
|
||||
.get(template_id)
|
||||
.ok_or_else(|| code("TEMPLATE_NOT_FOUND"))
|
||||
}
|
||||
|
||||
pub async fn create(name: &str, settings: TemplateSettings) -> Result<ProfileTemplate, String> {
|
||||
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::<ProfileTemplate>(&template.id);
|
||||
log::info!("Template created template={}", template.id);
|
||||
Ok(template)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
template_id: &str,
|
||||
name: &str,
|
||||
settings: TemplateSettings,
|
||||
) -> Result<ProfileTemplate, String> {
|
||||
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::<ProfileTemplate>(&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::<ProfileTemplate>(template_id);
|
||||
log::info!("Template deleted template={template_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn save_profile_as_template(
|
||||
profile_id: &str,
|
||||
name: &str,
|
||||
) -> Result<ProfileTemplate, String> {
|
||||
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<Object>)]
|
||||
pub created: Vec<BrowserProfile>,
|
||||
/// 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<String>,
|
||||
/// 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<String> {
|
||||
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::<u64>().ok()
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
(1..=u64::from(count))
|
||||
.map(|n| format!("{base} {}", highest + n))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn latest_wayfern_version() -> Option<String> {
|
||||
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<BulkCreateResult, String> {
|
||||
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<Option<String>> = 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<String> = 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<BulkCreateResult, String> {
|
||||
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<ProfileTemplate> {
|
||||
list()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_profile_template(
|
||||
name: String,
|
||||
settings: TemplateSettings,
|
||||
) -> Result<ProfileTemplate, String> {
|
||||
create(&name, settings).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_profile_template(
|
||||
template_id: String,
|
||||
name: String,
|
||||
settings: TemplateSettings,
|
||||
) -> Result<ProfileTemplate, String> {
|
||||
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<ProfileTemplate, String> {
|
||||
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<BulkCreateResult, String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<WayfernConfig>(defaultWayfernConfig);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [ephemeral, setEphemeral] = useState(false);
|
||||
const templates = useProfileTemplates();
|
||||
const [appliedTemplate, setAppliedTemplate] =
|
||||
useState<ProfileTemplate | null>(null);
|
||||
const [count, setCount] = useState(1);
|
||||
const [progress, setProgress] = useState<{
|
||||
done: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [allowance, setAllowance] = useState<ProfileCreationAllowance | null>(
|
||||
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<ProfileTemplate>("update_profile_template", {
|
||||
templateId: existing.id,
|
||||
name,
|
||||
settings: formSettings(),
|
||||
})
|
||||
: await invoke<ProfileTemplate>("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<BulkCreateResult>("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 = (
|
||||
<LoadingButton
|
||||
data-slot="profile-create-submit"
|
||||
onClick={handleCreate}
|
||||
isLoading={isCreating}
|
||||
disabled={isCreateDisabled}
|
||||
isLoading={isCreating && !progress}
|
||||
disabled={isCreateDisabled || progress !== null}
|
||||
className="h-9 rounded-md px-4 transition-[opacity,box-shadow,transform] duration-200"
|
||||
>
|
||||
{t("common.buttons.create")}
|
||||
{progress
|
||||
? t("templates.progress", progress)
|
||||
: many
|
||||
? t("templates.createMany", { count })
|
||||
: t("common.buttons.create")}
|
||||
</LoadingButton>
|
||||
);
|
||||
|
||||
@@ -433,10 +632,19 @@ export function CreateProfileDialog({
|
||||
aria-describedby={undefined}
|
||||
dismissible={!isCreating}
|
||||
>
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogHeader className="shrink-0 flex-row items-center justify-between gap-2 space-y-0">
|
||||
<DialogTitle className="text-base">
|
||||
{t("createProfile.title")}
|
||||
</DialogTitle>
|
||||
<TemplatePicker
|
||||
templates={templates}
|
||||
applied={appliedTemplate}
|
||||
onApply={applyTemplate}
|
||||
onSave={saveTemplate}
|
||||
onDelete={deleteTemplate}
|
||||
disabled={isCreating}
|
||||
animate={animateEntry}
|
||||
/>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
@@ -553,6 +761,20 @@ export function CreateProfileDialog({
|
||||
</Popover>
|
||||
</motion.div>
|
||||
|
||||
{many && (
|
||||
<motion.p
|
||||
data-slot="profile-count-hint"
|
||||
className="px-2 text-xs text-muted-foreground"
|
||||
{...reveal}
|
||||
>
|
||||
{t("templates.namesHint", {
|
||||
name: profileName.trim() || t("createProfile.profileName"),
|
||||
})}
|
||||
{allowance !== null &&
|
||||
` ${t("templates.allowanceLeft", { count: maxCount })}`}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
{...rise(0.04)}
|
||||
@@ -706,11 +928,15 @@ export function CreateProfileDialog({
|
||||
setEphemeral(false);
|
||||
} else clearPassword();
|
||||
}}
|
||||
disabled={isCreating}
|
||||
disabled={isCreating || many}
|
||||
animate={animateEntry}
|
||||
icon={enablePassword ? <LuLock /> : <LuLockOpen />}
|
||||
label={t("profiles.passwordProtectedBadge")}
|
||||
hint={t("createProfile.passwordProtect.description")}
|
||||
hint={
|
||||
many
|
||||
? t("templates.passwordOneOnly")
|
||||
: t("createProfile.passwordProtect.description")
|
||||
}
|
||||
/>
|
||||
<OptionToggle
|
||||
id="ephemeral"
|
||||
@@ -1101,13 +1327,29 @@ export function CreateProfileDialog({
|
||||
{t("createProfile.advancedOptions")}
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={handleClose}
|
||||
{progress ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
data-slot="profile-create-stop"
|
||||
onClick={() => void invoke("cancel_bulk_create_profiles")}
|
||||
>
|
||||
{t("templates.stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={handleClose}
|
||||
disabled={isCreating}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<CountStepper
|
||||
value={count}
|
||||
max={maxCount}
|
||||
onChange={changeCount}
|
||||
disabled={isCreating}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
/>
|
||||
{hourlyCapReached ? (
|
||||
<Tooltip
|
||||
onOpenChange={(open) => {
|
||||
@@ -1283,3 +1525,269 @@ function OptionToggle({
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<boolean>;
|
||||
onDelete: (template: ProfileTemplate) => Promise<void>;
|
||||
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<string | null>(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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="template-picker"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-describedby={valueId}
|
||||
className={cn(optionPill(applied !== null), "h-7 shrink-0 text-xs")}
|
||||
disabled={disabled}
|
||||
>
|
||||
<PillIcon on={applied !== null} animate={animate}>
|
||||
<LuLayoutTemplate />
|
||||
</PillIcon>
|
||||
<PillLabel
|
||||
id={valueId}
|
||||
label={applied?.name ?? t("templates.label")}
|
||||
animate={animate}
|
||||
/>
|
||||
<LuChevronDown
|
||||
className={cn(
|
||||
"-mr-0.5 opacity-60 transition-transform duration-200 motion-reduce:transition-none",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="end" sideOffset={6}>
|
||||
{templates.length > 0 ? (
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{applied && (
|
||||
<CommandItem
|
||||
value="__no_template__"
|
||||
onSelect={() => {
|
||||
onApply(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate text-muted-foreground">
|
||||
{t("templates.none")}
|
||||
</span>
|
||||
</CommandItem>
|
||||
)}
|
||||
{templates.map((template) => (
|
||||
<CommandItem
|
||||
key={template.id}
|
||||
value={template.id}
|
||||
data-slot="template-option"
|
||||
className="group/template"
|
||||
onSelect={() => {
|
||||
onApply(template);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
applied?.id === template.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{template.name}
|
||||
</span>
|
||||
{confirmingDelete === template.id ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="template-delete-confirm"
|
||||
className="rounded-md px-1.5 py-0.5 text-xs font-medium text-destructive hover:bg-destructive/10"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setConfirmingDelete(null);
|
||||
void onDelete(template);
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.delete")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="template-delete"
|
||||
aria-label={t("templates.delete", {
|
||||
name: template.name,
|
||||
})}
|
||||
className="grid size-6 place-items-center rounded-md text-muted-foreground opacity-0 transition-opacity duration-100 group-hover/template:opacity-100 group-data-[selected=true]/template:opacity-100 hover:bg-foreground/8 hover:text-destructive focus-visible:opacity-100 [&_svg]:size-3.5"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setConfirmingDelete(template.id);
|
||||
}}
|
||||
>
|
||||
<LuTrash2 aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
) : (
|
||||
<p className="px-3 pt-3 text-xs text-muted-foreground">
|
||||
{t("templates.empty")}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1.5 p-2">
|
||||
<p className={cn(FIELD_LABEL_CLASS, "px-1")}>
|
||||
{t("templates.saveCurrent")}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
variant="soft"
|
||||
data-slot="template-name"
|
||||
value={name}
|
||||
maxLength={60}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
data-slot="template-save"
|
||||
className="h-8 shrink-0"
|
||||
disabled={!trimmed || saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{replaces ? t("templates.replace") : t("templates.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("templates.count")}
|
||||
className="inline-flex h-9 items-center rounded-md bg-foreground/5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("templates.fewer")}
|
||||
data-slot="profile-count-fewer"
|
||||
className={step}
|
||||
disabled={disabled || value <= 1}
|
||||
onClick={() => onChange(value - 1)}
|
||||
>
|
||||
<LuMinus aria-hidden="true" />
|
||||
</button>
|
||||
<input
|
||||
id="profile-count"
|
||||
data-slot="profile-count"
|
||||
inputMode="numeric"
|
||||
aria-label={t("templates.count")}
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("templates.more")}
|
||||
data-slot="profile-count-more"
|
||||
className={step}
|
||||
disabled={disabled || value >= max}
|
||||
onClick={() => onChange(value + 1)}
|
||||
>
|
||||
<LuPlus aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<BrowserProfile | null>(null);
|
||||
const [statusesDialogOpen, setStatusesDialogOpen] = React.useState(false);
|
||||
const [bulkEditOpen, setBulkEditOpen] = React.useState(false);
|
||||
const [templateProfile, setTemplateProfile] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
|
||||
React.useState<BrowserProfile | null>(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 ?? []}
|
||||
/>
|
||||
<SaveTemplateDialog
|
||||
profile={templateProfile}
|
||||
onClose={() => setTemplateProfile(null)}
|
||||
/>
|
||||
<BulkEditDialog
|
||||
isOpen={bulkEditOpen}
|
||||
onClose={() => setBulkEditOpen(false)}
|
||||
|
||||
@@ -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: <LuLayoutTemplate className="size-4" />,
|
||||
label: t("templates.saveCurrent"),
|
||||
onClick: () => {
|
||||
handleAction(() => onSaveAsTemplate?.(profile));
|
||||
},
|
||||
hidden: !onSaveAsTemplate,
|
||||
},
|
||||
{
|
||||
id: "extension",
|
||||
icon: <LuPuzzle className="size-4" />,
|
||||
@@ -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")}
|
||||
</Button>
|
||||
)}
|
||||
{saveTemplateAction && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
data-slot="profile-info-save-template"
|
||||
onClick={saveTemplateAction.onClick}
|
||||
>
|
||||
<LuLayoutTemplate className="size-3" />
|
||||
{saveTemplateAction.label}
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-slot="profile-info-close"
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, ProfileTemplate } from "@/types";
|
||||
|
||||
/** Saves a profile's settings, without its name, note or device, as a template. */
|
||||
export function SaveTemplateDialog({
|
||||
profile,
|
||||
onClose,
|
||||
}: {
|
||||
profile: BrowserProfile | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState("");
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (profile) setName(profile.name);
|
||||
}, [profile]);
|
||||
|
||||
const save = async () => {
|
||||
if (!profile || !name.trim()) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const template = await invoke<ProfileTemplate>(
|
||||
"save_profile_template_from_profile",
|
||||
{ profileId: profile.id, name: name.trim() },
|
||||
);
|
||||
showSuccessToast(t("templates.saved", { name: template.name }));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
showErrorToast(translateBackendError(t, error));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={profile !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isSaving) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md" data-slot="save-template-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("templates.saveCurrent")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("templates.saveFromProfileDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
variant="soft"
|
||||
autoFocus
|
||||
value={name}
|
||||
maxLength={60}
|
||||
onChange={(event) => 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")}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={isSaving}>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
data-slot="save-template-confirm"
|
||||
isLoading={isSaving}
|
||||
disabled={!name.trim()}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t("templates.save")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<T>(command: string, event: string): T[] {
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
let unlisten: (() => void) | undefined;
|
||||
const load = async () => {
|
||||
try {
|
||||
const next = await invoke<T[]>(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;
|
||||
}
|
||||
@@ -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<ProfileStatus[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
let unlisten: (() => void) | undefined;
|
||||
const load = async () => {
|
||||
try {
|
||||
const next = await invoke<ProfileStatus[]>("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<ProfileStatus>(
|
||||
"list_profile_statuses",
|
||||
"profile-statuses-changed",
|
||||
);
|
||||
}
|
||||
@@ -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<ProfileTemplate>(
|
||||
"list_profile_templates",
|
||||
"profile-templates-changed",
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "プロファイル",
|
||||
|
||||
@@ -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": "프로필",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Профили",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "配置文件",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user