feat: profile statuses

This commit is contained in:
zhom committed 2026-10-11 23:56:41 +04:00
1 parent 187788b309
commit 69d3c90a7c
41 files changed
+2279 -36

No files matched your search

+15 -3
View File
@@ -776,7 +776,19 @@ export class SyncService implements OnModuleInit {
ctx: UserContext,
pollIntervalMs = 5000,
): Observable<SubscribeEventDto> {
const basePrefixes = ["profiles/", "proxies/", "groups/", "tombstones/"];
// Every key prefix the desktop reacts to. Filtering happens after the one
// LIST per scope, so a prefix added here costs no extra S3 call.
const basePrefixes = [
"profiles/",
"proxies/",
"groups/",
"vpns/",
"extensions/",
"extension_groups/",
"statuses/",
"templates/",
"tombstones/",
];
const scopes = this.scopesFor(ctx);
// Per-connection state (not shared across subscribers).
@@ -839,7 +851,7 @@ export class SyncService implements OnModuleInit {
}
// Phase 2 — one LIST per scope (not per base prefix). Filter to the
// four base prefixes client-side. This is the cost we pay only when
// base prefixes client-side. This is the cost we pay only when
// a manifest told us there's something new to look at.
const currentState = new Map<string, string>();
for (const scope of scopes) {
@@ -862,7 +874,7 @@ export class SyncService implements OnModuleInit {
? fullKey.substring(scope.length)
: fullKey;
// Skip the manifest object itself + anything outside the
// four data prefixes.
// data prefixes.
if (relativeKey === MANIFEST_KEY) continue;
if (!basePrefixes.some((bp) => relativeKey.startsWith(bp))) {
continue;
+5
View File
@@ -56,6 +56,11 @@ export const commandCoverage = {
"update_profile_proxy_bypass_rules",
"update_profile_dns_blocklist",
"update_profile_dns_server",
"profile_statuses::list_profile_statuses",
"profile_statuses::create_profile_status",
"profile_statuses::update_profile_status",
"profile_statuses::delete_profile_status",
"profile_statuses::set_profiles_status",
"rename_profile",
"detect_existing_profiles",
"import_browser_profiles",
+68
View File
@@ -356,6 +356,72 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a
dnsServer: " https://dns.quad9.net/dns-query ",
});
assert.equal(withDns.dns_server, "https://dns.quad9.net/dns-query");
// Statuses: a set the person defines, one per profile, removed from
// every profile when deleted.
const warming = await app.invoke("create_profile_status", {
name: " Warming up ",
color: "#EAB308",
});
assert.equal(warming.name, "Warming up");
assert.equal(warming.color, "#eab308");
const banned = await app.invoke("create_profile_status", {
name: "Banned",
color: "#ef4444",
});
assert.match(
await app.invokeError("create_profile_status", {
name: "banned",
color: "#000000",
}),
/PROFILE_STATUS_EXISTS/,
);
assert.match(
await app.invokeError("create_profile_status", {
name: "Paused",
color: "red",
}),
/PROFILE_STATUS_COLOR_INVALID/,
);
const renamed = await app.invoke("update_profile_status", {
statusId: banned.id,
name: "Suspended",
color: "#ef4444",
});
assert.equal(renamed.name, "Suspended");
assert.deepEqual(
(await app.invoke("list_profile_statuses")).map((item) => item.name),
["Warming up", "Suspended"],
);
assert.match(
await app.invokeError("set_profiles_status", {
profileIds: [profile.id],
statusId: "missing",
}),
/PROFILE_STATUS_NOT_FOUND/,
);
await app.invoke("set_profiles_status", {
profileIds: [profile.id],
statusId: banned.id,
});
assert.equal(
(await app.invoke("list_browser_profiles")).find(
(item) => item.id === profile.id,
).status_id,
banned.id,
);
await app.invoke("delete_profile_status", { statusId: banned.id });
assert.equal(
(await app.invoke("list_browser_profiles")).find(
(item) => item.id === profile.id,
).status_id,
null,
"deleting a status takes it off its profiles",
);
await app.invoke("set_profiles_status", {
profileIds: [profile.id],
statusId: warming.id,
});
await app.invoke("update_profile_clear_on_close", {
profileId: profile.id,
clearOnClose: true,
@@ -373,6 +439,7 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a
]);
assert.equal(changed.dns_blocklist, "light");
assert.equal(changed.dns_server, "https://dns.quad9.net/dns-query");
assert.equal(changed.status_id, warming.id);
assert.equal(changed.clear_on_close, true);
assert.deepEqual((await app.invoke("get_all_tags")).sort(), [
"alpha",
@@ -623,6 +690,7 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a
assert.notEqual(clone.id, profile.id);
assert.equal(clone.name, "Cloned Profile");
assert.equal(clone.dns_server, "https://dns.quad9.net/dns-query");
assert.equal(clone.status_id, warming.id, "a clone keeps the status");
const counts = await app.invoke("get_groups_with_profile_counts");
assert.equal(counts.find((item) => item.id === group.id).count, 2);
assert.equal((await app.invoke("get_profile_groups")).length, 1);
+60
View File
@@ -4128,6 +4128,66 @@ test("a freshly paid account is welcomed once and walked to its plan tips", asyn
});
});
test("profile statuses start from a common set and are picked from the table", async () => {
await withApp("ui-profile-statuses", async (app) => {
const profile = await createUiProfile(app, "Profile With Status");
await app.waitForText(profile.name);
const cell = '[data-slot="profile-status-cell"]';
const statusOf = async () =>
(await app.invoke("list_browser_profiles")).find(
(item) => item.id === profile.id,
).status_id ?? null;
await app.clickSelector(cell);
await app.clickText(en.profileStatuses.manage, { roles: ["option"] });
await app.waitForText(en.profileStatuses.empty);
await app.capture("profile-statuses-empty");
await app.clickText(en.profileStatuses.addStarter, { roles: ["button"] });
await app.waitFor(
async () => (await app.invoke("list_profile_statuses")).length === 4,
{ description: "the starter statuses" },
);
assert.deepEqual(
(await app.invoke("list_profile_statuses")).map((item) => item.name),
[
en.profileStatuses.starter.active,
en.profileStatuses.starter.warming,
en.profileStatuses.starter.review,
en.profileStatuses.starter.banned,
],
);
await app.capture("profile-statuses-starter");
await dismissSurface(app);
await app.clickSelector(cell);
await app.clickText(en.profileStatuses.starter.banned, {
exact: false,
roles: ["option"],
});
const banned = (await app.invoke("list_profile_statuses")).find(
(item) => item.name === en.profileStatuses.starter.banned,
);
await app.waitFor(async () => (await statusOf()) === banned.id, {
description: "the picked status",
});
await app.waitFor(
() =>
app.execute(
`return document.querySelector(arguments[0])?.textContent.includes(arguments[1]);`,
[cell, en.profileStatuses.starter.banned],
),
{ description: "the cell shows the status" },
);
await app.capture("profile-status-cell");
await app.clickSelector(cell);
await app.clickText(en.profileStatuses.none, { roles: ["option"] });
await app.waitFor(async () => (await statusOf()) === null, {
description: "the status cleared",
});
});
});
test("the profile network section saves, refuses and clears a DNS server", async () => {
await withApp("ui-dns-server", async (app) => {
const profile = await createUiProfile(app, "Profile DNS Server");
+192
View File
@@ -49,6 +49,8 @@ pub struct ApiProfile {
/// The DNS server the profile resolves names with, through its own proxy
/// or VPN. `null` when the proxy resolves them.
pub dns_server: Option<String>,
/// The profile's status id (see `/v1/statuses`), or `null`.
pub status_id: Option<String>,
/// Extension group loaded into the browser at launch. Settable via
/// `PUT /v1/profiles/{id}`; exposed here so a caller can read back what it
/// set instead of having to go through the desktop app.
@@ -102,6 +104,7 @@ impl From<&crate::profile::types::BrowserProfile> for ApiProfile {
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
vpn_id: profile.vpn_id.clone(),
dns_server: profile.dns_server.clone(),
status_id: profile.status_id.clone(),
extension_group_id: profile.extension_group_id.clone(),
ephemeral: profile.ephemeral,
temporary: profile.temporary,
@@ -168,6 +171,9 @@ pub struct CreateProfileRequest {
/// proxy resolve names. Anything else is rejected with 400.
#[serde(default)]
pub dns_server: Option<String>,
/// Optional status id from `/v1/statuses`.
#[serde(default)]
pub status_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
@@ -198,6 +204,8 @@ pub struct UpdateProfileRequest {
/// clears it, so the proxy resolves names again. Same formats as on create.
/// Takes effect at the next launch.
pub dns_server: Option<String>,
/// Omitted or `null` leaves the status unchanged; an empty string clears it.
pub status_id: Option<String>,
}
#[derive(Clone)]
@@ -222,6 +230,34 @@ struct UpdateGroupRequest {
name: String,
}
/// A profile status: a named, colored label for where a profile's account
/// stands.
#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct ApiStatusResponse {
id: String,
name: String,
/// `#rrggbb`.
color: String,
}
impl From<crate::profile_statuses::ProfileStatus> for ApiStatusResponse {
fn from(status: crate::profile_statuses::ProfileStatus) -> Self {
Self {
id: status.id,
name: status.name,
color: status.color,
}
}
}
#[derive(Debug, Deserialize, ToSchema)]
struct StatusRequest {
/// 1 to 40 characters, unique (case-insensitive).
name: String,
/// `#rrggbb`.
color: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct ApiProxyResponse {
id: String,
@@ -659,6 +695,10 @@ struct ImportProxiesResponse {
create_group,
update_group,
delete_group,
get_statuses,
create_status,
update_status,
delete_status,
get_tags,
get_proxies,
get_proxy,
@@ -704,6 +744,8 @@ struct ImportProxiesResponse {
ApiGroupResponse,
CreateGroupRequest,
UpdateGroupRequest,
ApiStatusResponse,
StatusRequest,
ApiProxyResponse,
CreateProxyRequest,
UpdateProxyRequest,
@@ -798,6 +840,7 @@ struct ImportProxiesResponse {
tags(
(name = "profiles", description = "Profile management endpoints"),
(name = "groups", description = "Group management endpoints"),
(name = "statuses", description = "Profile status management endpoints"),
(name = "tags", description = "Tag management endpoints"),
(name = "proxies", description = "Proxy management endpoints"),
(name = "vpns", description = "VPN management endpoints"),
@@ -990,6 +1033,8 @@ fn build_v1_router() -> Router<ApiServerState> {
.routes(routes!(import_profile_cookies))
.routes(routes!(get_groups, create_group))
.routes(routes!(get_group, update_group, delete_group))
.routes(routes!(get_statuses, create_status))
.routes(routes!(update_status, delete_status))
.routes(routes!(get_tags))
.routes(routes!(get_proxies, create_proxy))
.routes(routes!(import_proxies_api))
@@ -1585,6 +1630,14 @@ async fn create_profile(
});
}
if let Some(status_id) = request.status_id.as_deref().filter(|id| !id.is_empty()) {
if !crate::profile_statuses::exists(status_id) {
return Err(manager_error_response(crate::backend_error(
"PROFILE_STATUS_NOT_FOUND",
)));
}
}
// Create profile using the async create_profile_with_group method
match profile_manager
.create_profile_with_group(
@@ -1605,6 +1658,14 @@ async fn create_profile(
.await
{
Ok(mut profile) => {
if let Some(status_id) = request.status_id.clone().filter(|id| !id.is_empty()) {
if let Err(e) =
profile_manager.set_profiles_status(&[profile.id.to_string()], Some(status_id.clone()))
{
return Err(manager_error_response(e));
}
profile.status_id = Some(status_id);
}
if request.temporary.unwrap_or(false) {
match profile_manager.mark_profile_temporary(&profile.id.to_string()) {
Ok(updated) => profile = updated,
@@ -1695,6 +1756,13 @@ async fn update_profile(
crate::profile_dns::normalize_dns_server(Some(dns_server.clone()))
.map_err(manager_error_response)?;
}
if let Some(status_id) = request.status_id.as_deref().filter(|id| !id.is_empty()) {
if !crate::profile_statuses::exists(status_id) {
return Err(manager_error_response(crate::backend_error(
"PROFILE_STATUS_NOT_FOUND",
)));
}
}
// Update profile fields
if let Some(new_name) = request.name {
@@ -1822,6 +1890,13 @@ async fn update_profile(
}
}
if let Some(status_id) = request.status_id {
let status_id = Some(status_id).filter(|id| !id.is_empty());
if let Err(e) = profile_manager.set_profiles_status(std::slice::from_ref(&id), status_id) {
return Err(manager_error_response(e));
}
}
// Return updated profile
get_profile(Path(id), State(state))
.await
@@ -2042,6 +2117,99 @@ async fn delete_group(
}
}
// API Handlers - Statuses
#[utoipa::path(
get,
path = "/v1/statuses",
responses(
(status = 200, description = "Every profile status, in their order", body = Vec<ApiStatusResponse>),
(status = 401, description = "Unauthorized")
),
security(
("bearer_auth" = [])
),
tag = "statuses"
)]
async fn get_statuses() -> Json<Vec<ApiStatusResponse>> {
Json(
crate::profile_statuses::list()
.into_iter()
.map(ApiStatusResponse::from)
.collect(),
)
}
#[utoipa::path(
post,
path = "/v1/statuses",
request_body = StatusRequest,
responses(
(status = 200, description = "Status created", body = ApiStatusResponse),
(status = 400, description = "Empty, too long or duplicate name, invalid color, or 100 statuses already"),
(status = 401, description = "Unauthorized")
),
security(
("bearer_auth" = [])
),
tag = "statuses"
)]
async fn create_status(
Json(request): Json<StatusRequest>,
) -> Result<Json<ApiStatusResponse>, (StatusCode, String)> {
crate::profile_statuses::create(&request.name, &request.color)
.map(|status| Json(status.into()))
.map_err(manager_error_response)
}
#[utoipa::path(
put,
path = "/v1/statuses/{id}",
params(
("id" = String, Path, description = "Status ID")
),
request_body = StatusRequest,
responses(
(status = 200, description = "Status updated", body = ApiStatusResponse),
(status = 400, description = "Empty, too long or duplicate name, or invalid color"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Status not found")
),
security(
("bearer_auth" = [])
),
tag = "statuses"
)]
async fn update_status(
Path(id): Path<String>,
Json(request): Json<StatusRequest>,
) -> Result<Json<ApiStatusResponse>, (StatusCode, String)> {
crate::profile_statuses::update(&id, &request.name, &request.color)
.map(|status| Json(status.into()))
.map_err(manager_error_response)
}
#[utoipa::path(
delete,
path = "/v1/statuses/{id}",
params(
("id" = String, Path, description = "Status ID")
),
responses(
(status = 204, description = "Status deleted and taken off every profile that had it"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Status not found")
),
security(
("bearer_auth" = [])
),
tag = "statuses"
)]
async fn delete_status(Path(id): Path<String>) -> Result<StatusCode, (StatusCode, String)> {
crate::profile_statuses::delete(&id)
.map(|()| StatusCode::NO_CONTENT)
.map_err(manager_error_response)
}
// API Handlers - Tags
#[utoipa::path(
get,
@@ -5648,6 +5816,30 @@ mod tests {
}
}
#[test]
fn openapi_documents_profile_statuses() {
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
for path in ["/v1/statuses", "/v1/statuses/{id}"] {
assert!(
spec["paths"][path].is_object(),
"{path} must be in the spec"
);
}
for schema in ["CreateProfileRequest", "UpdateProfileRequest", "ApiProfile"] {
assert_eq!(
spec["components"]["schemas"][schema]["properties"]["status_id"]["type"],
serde_json::json!(["string", "null"]),
"{schema}.status_id must be a nullable string"
);
assert!(
!schema_required(&spec, schema)
.iter()
.any(|f| f == "status_id"),
"{schema}.status_id must be optional"
);
}
}
#[test]
fn openapi_documents_the_profile_dns_server() {
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
+2
View File
@@ -693,6 +693,8 @@ mod tests {
created_by_email: None,
dns_blocklist: None,
dns_server: None,
status_id: None,
start_urls: Vec::new(),
password_protected: false,
clear_on_close: false,
created_at: None,
+2
View File
@@ -736,6 +736,8 @@ mod tests {
created_by_email: None,
dns_blocklist: None,
dns_server: None,
status_id: None,
start_urls: Vec::new(),
password_protected: false,
clear_on_close: false,
created_at: None,
+2
View File
@@ -509,6 +509,8 @@ mod tests {
created_by_email: None,
dns_blocklist: None,
dns_server: None,
status_id: None,
start_urls: Vec::new(),
password_protected: false,
clear_on_close: false,
created_at: None,
+9
View File
@@ -112,6 +112,8 @@ mod profile_dns;
mod profile_generation_limiter;
mod profile_import;
mod profile_importer;
mod profile_statuses;
mod profile_templates;
mod proxy_distribution;
mod proxy_manager;
pub mod proxy_runner;
@@ -1868,6 +1870,8 @@ async fn generate_sample_fingerprint(
created_by_email: None,
dns_blocklist: None,
dns_server: None,
status_id: None,
start_urls: Vec::new(),
password_protected: false,
clear_on_close: false,
created_at: None,
@@ -3328,6 +3332,11 @@ pub fn run_with_builder(
update_profile_dns_blocklist,
update_profile_dns_server,
profile_dns::describe_dns_server,
profile_statuses::list_profile_statuses,
profile_statuses::create_profile_status,
profile_statuses::update_profile_status,
profile_statuses::delete_profile_status,
profile_statuses::set_profiles_status,
check_browser_status,
kill_browser_profile,
rename_profile,
+65 -1
View File
@@ -2549,6 +2549,10 @@ impl McpServer {
"type": "boolean",
"description": "Create a profile for one run: implies ephemeral, is deleted when its browser stops, and is swept at startup if it outlived a crash (default: false)"
},
"status_id": {
"type": "string",
"description": "Optional status id from list_profile_statuses"
},
"dns_server": {
"type": "string",
"description": "Optional. DNS server the profile resolves names with, through its own proxy or VPN: https://host/path (DNS over HTTPS), tls://host[:port] (DNS over TLS), tcp://host[:port] (plain DNS) or a bare host[:port]. A bare address of a well-known public resolver such as 1.1.1.1 uses its DNS over HTTPS endpoint. If the server does not answer, connections fail instead of falling back to the proxy's resolver. Omit it to let the proxy resolve names."
@@ -2667,6 +2671,10 @@ impl McpServer {
"type": "boolean",
"description": "Wipe browsing data (keeping extensions and bookmarks) when the browser exits. Not available for ephemeral or password-protected profiles."
},
"status_id": {
"type": "string",
"description": "Status id from list_profile_statuses (empty string to remove)"
},
"dns_server": {
"type": "string",
"description": "DNS server the profile resolves names with, through its own proxy or VPN: https://host/path (DNS over HTTPS), tls://host[:port] (DNS over TLS), tcp://host[:port] (plain DNS) or a bare host[:port]. A bare address of a well-known public resolver such as 1.1.1.1 uses its DNS over HTTPS endpoint. If the server does not answer, connections fail instead of falling back to the proxy's resolver. Empty string to let the proxy resolve names again. Takes effect at the next launch."
@@ -2721,6 +2729,15 @@ impl McpServer {
"required": ["profile_id"]
}),
},
McpTool {
name: "list_profile_statuses".to_string(),
description: "List the profile statuses (named, colored labels such as Active or Banned) in their order. Set one on a profile with update_profile's status_id.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
// Group management tools
McpTool {
name: "list_groups".to_string(),
@@ -4505,6 +4522,12 @@ impl McpServer {
"get_profile_status" => self.handle_get_profile_status(arguments).await,
// Group management
"list_groups" => self.handle_list_groups().await,
"list_profile_statuses" => Ok(serde_json::json!({
"content": [{
"type": "text",
"text": serde_json::to_string_pretty(&crate::profile_statuses::list()).unwrap_or_default()
}]
})),
"get_group" => self.handle_get_group(arguments).await,
"create_group" => self.handle_create_group(arguments).await,
"update_group" => self.handle_update_group(arguments).await,
@@ -5281,6 +5304,18 @@ impl McpServer {
.clone()
};
if let Some(status_id) = arguments
.get("status_id")
.and_then(|v| v.as_str())
.filter(|id| !id.is_empty() && !crate::profile_statuses::exists(id))
{
return Err(McpError {
code: -32602,
message: format!("Unknown status id: {status_id}"),
data: None,
});
}
let temporary = arguments
.get("temporary")
.and_then(serde_json::Value::as_bool)
@@ -5334,6 +5369,15 @@ impl McpServer {
})?;
}
if let Some(status_id) = arguments
.get("status_id")
.and_then(|v| v.as_str())
.filter(|id| !id.is_empty())
{
let _ = ProfileManager::instance()
.set_profiles_status(&[profile.id.to_string()], Some(status_id.to_string()));
}
if let Some(tags) = tags {
let _ = ProfileManager::instance().update_profile_tags(
&app_handle,
@@ -5502,6 +5546,26 @@ impl McpServer {
})?;
}
if let Some(status_id) = arguments.get("status_id").and_then(|v| v.as_str()) {
let status_id = Some(status_id.to_string()).filter(|id| !id.is_empty());
if status_id
.as_deref()
.is_some_and(|id| !crate::profile_statuses::exists(id))
{
return Err(McpError {
code: -32602,
message: format!("Unknown status id: {}", status_id.unwrap_or_default()),
data: None,
});
}
pm.set_profiles_status(&[profile_id.to_string()], status_id)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update status: {e}"),
data: None,
})?;
}
if let Some(dns_server) = arguments.get("dns_server").and_then(|v| v.as_str()) {
pm.update_profile_dns_server(profile_id, Some(dns_server.to_string()))
.map_err(|e| McpError {
@@ -9827,7 +9891,7 @@ mod tests {
// tool was added or removed on purpose.
assert_eq!(
tools.len(),
93,
94,
"the tool list is a published contract; update this number deliberately"
);
+46
View File
@@ -379,6 +379,8 @@ impl ProfileManager {
created_by_email: None,
dns_blocklist: None,
dns_server: None,
status_id: None,
start_urls: Vec::new(),
password_protected: false,
clear_on_close: false,
created_at: None,
@@ -499,6 +501,8 @@ impl ProfileManager {
created_by_email: None,
dns_blocklist,
dns_server,
status_id: None,
start_urls: Vec::new(),
password_protected: false,
clear_on_close: false,
created_at: Some(
@@ -1459,6 +1463,46 @@ impl ProfileManager {
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(
&self,
profile_ids: &[String],
status_id: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut changed = Vec::new();
for profile_id in profile_ids {
let profile = self.find_profile(profile_id)?;
if profile.status_id != status_id {
changed.push(profile);
}
}
for mut profile in changed {
profile.status_id = status_id.clone();
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
}
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
Ok(())
}
/// Take a deleted status off every profile that pointed at it.
pub fn clear_status_references(&self, status_id: &str) -> Result<(), Box<dyn std::error::Error>> {
let ids: Vec<String> = self
.list_profiles()?
.into_iter()
.filter(|p| p.status_id.as_deref() == Some(status_id))
.map(|p| p.id.to_string())
.collect();
if ids.is_empty() {
return Ok(());
}
self.set_profiles_status(&ids, None)
}
/// Trash several profiles at once. Every profile is checked before any of
/// them moves, so one running browser blocks the whole batch instead of
/// leaving it half done.
@@ -1572,6 +1616,8 @@ impl ProfileManager {
created_by_email: None,
dns_blocklist: source.dns_blocklist,
dns_server: source.dns_server,
status_id: source.status_id,
start_urls: source.start_urls,
password_protected: false,
clear_on_close: false,
created_at: Some(
+6
View File
@@ -79,6 +79,12 @@ pub struct BrowserProfile {
/// or VPN (see `profile_dns`). `None` lets the proxy resolve them.
#[serde(default)]
pub dns_server: Option<String>,
/// The profile's status (see `profile_statuses`), by id.
#[serde(default)]
pub status_id: Option<String>,
/// Pages a launch opens, in order. An explicit launch URL replaces them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub start_urls: Vec<String>,
/// True when the on-disk profile dir is encrypted with a per-profile password.
/// Decryption goes to a RAM-backed ephemeral dir, never to disk.
#[serde(default)]
+4
View File
@@ -1001,6 +1001,8 @@ impl ProfileImporter {
created_by_email: None,
dns_blocklist: None,
dns_server: None,
status_id: None,
start_urls: Vec::new(),
password_protected: false,
clear_on_close: false,
created_at: None,
@@ -1083,6 +1085,8 @@ impl ProfileImporter {
created_by_email: None,
dns_blocklist: None,
dns_server: None,
status_id: None,
start_urls: Vec::new(),
password_protected: false,
clear_on_close: false,
created_at: Some(
+243
View File
@@ -0,0 +1,243 @@
//! Profile statuses: a named, colored label per profile for where its account
//! stands (active, warming up, banned, ...). The person defines the set; a
//! profile points at one by id.
use crate::sync::config_entities::{self, ConfigEntity, ConfigKind, ConfigStore};
use serde::{Deserialize, Serialize};
const MAX_STATUSES: usize = 100;
const MAX_NAME_CHARS: usize = 40;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProfileStatus {
pub id: String,
pub name: String,
/// `#RRGGBB`. A data color the person picked, not a theme color.
pub color: String,
/// Order in lists and menus: the order the statuses were created in.
#[serde(default)]
pub position: u64,
#[serde(default)]
pub updated_at: u64,
}
static STORE: ConfigStore<ProfileStatus> = ConfigStore::new("profile_statuses.json");
impl ConfigEntity for ProfileStatus {
const KIND: ConfigKind = ConfigKind::Status;
fn id(&self) -> &str {
&self.id
}
fn updated_at(&self) -> u64 {
self.updated_at
}
fn store() -> &'static ConfigStore<Self> {
&STORE
}
}
fn code(code: &str) -> String {
crate::backend_error(code)
}
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("PROFILE_STATUS_NAME_TOO_LONG"));
}
Ok(name.to_string())
}
fn checked_color(color: &str) -> Result<String, String> {
let color = color.trim();
let valid =
color.len() == 7 && color.starts_with('#') && color[1..].bytes().all(|b| b.is_ascii_hexdigit());
if valid {
Ok(color.to_ascii_lowercase())
} else {
Err(code("PROFILE_STATUS_COLOR_INVALID"))
}
}
pub fn list() -> Vec<ProfileStatus> {
let mut statuses = STORE.list();
statuses.sort_by(|a, b| {
a.position
.cmp(&b.position)
.then_with(|| a.name.cmp(&b.name))
});
statuses
}
pub fn exists(status_id: &str) -> bool {
STORE.get(status_id).is_some()
}
pub fn create(name: &str, color: &str) -> Result<ProfileStatus, String> {
let name = checked_name(name)?;
let color = checked_color(color)?;
let status = STORE.mutate(|statuses| {
if statuses.len() >= MAX_STATUSES {
return Err(code("PROFILE_STATUS_LIMIT_REACHED"));
}
if statuses.iter().any(|s| s.name.eq_ignore_ascii_case(&name)) {
return Err(code("PROFILE_STATUS_EXISTS"));
}
let status = ProfileStatus {
id: uuid::Uuid::new_v4().to_string(),
name,
color,
position: statuses.iter().map(|s| s.position + 1).max().unwrap_or(0),
updated_at: crate::proxy_manager::now_secs(),
};
statuses.push(status.clone());
Ok(status)
})?;
config_entities::changed_locally::<ProfileStatus>(&status.id);
Ok(status)
}
pub fn update(status_id: &str, name: &str, color: &str) -> Result<ProfileStatus, String> {
let name = checked_name(name)?;
let color = checked_color(color)?;
let status = STORE.mutate(|statuses| {
if statuses
.iter()
.any(|s| s.id != status_id && s.name.eq_ignore_ascii_case(&name))
{
return Err(code("PROFILE_STATUS_EXISTS"));
}
let status = statuses
.iter_mut()
.find(|s| s.id == status_id)
.ok_or_else(|| code("PROFILE_STATUS_NOT_FOUND"))?;
if status.name != name || status.color != color {
status.name = name;
status.color = color;
status.updated_at = crate::proxy_manager::now_secs();
}
Ok(status.clone())
})?;
config_entities::changed_locally::<ProfileStatus>(&status.id);
Ok(status)
}
/// Deletes the status and takes it off every profile that had it.
pub fn delete(status_id: &str) -> Result<(), String> {
let removed = STORE.mutate(|statuses| {
let before = statuses.len();
statuses.retain(|s| s.id != status_id);
Ok(statuses.len() != before)
})?;
if !removed {
return Err(code("PROFILE_STATUS_NOT_FOUND"));
}
crate::profile::ProfileManager::instance()
.clear_status_references(status_id)
.map_err(|e| crate::wrap_backend_error(e, "Failed to clear the status from profiles"))?;
config_entities::deleted_locally::<ProfileStatus>(status_id);
Ok(())
}
#[tauri::command]
pub fn list_profile_statuses() -> Vec<ProfileStatus> {
list()
}
#[tauri::command]
pub fn create_profile_status(name: String, color: String) -> Result<ProfileStatus, String> {
create(&name, &color)
}
#[tauri::command]
pub fn update_profile_status(
status_id: String,
name: String,
color: String,
) -> Result<ProfileStatus, String> {
update(&status_id, &name, &color)
}
#[tauri::command]
pub fn delete_profile_status(status_id: String) -> Result<(), String> {
delete(&status_id)
}
/// Give every listed profile one status, or none.
#[tauri::command]
pub fn set_profiles_status(
profile_ids: Vec<String>,
status_id: Option<String>,
) -> Result<(), String> {
let status_id = status_id.filter(|id| !id.is_empty());
if let Some(id) = &status_id {
if !exists(id) {
return Err(code("PROFILE_STATUS_NOT_FOUND"));
}
}
crate::profile::ProfileManager::instance()
.set_profiles_status(&profile_ids, status_id)
.map_err(|e| crate::wrap_backend_error(e, "Failed to set the profile status"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_are_trimmed_and_bounded() {
assert_eq!(checked_name(" Warming up ").unwrap(), "Warming up");
assert!(checked_name(" ")
.unwrap_err()
.contains("NAME_CANNOT_BE_EMPTY"));
assert!(checked_name(&"x".repeat(41))
.unwrap_err()
.contains("PROFILE_STATUS_NAME_TOO_LONG"));
assert_eq!(checked_name(&"é".repeat(40)).unwrap().chars().count(), 40);
}
#[test]
fn statuses_keep_their_order_and_refuse_duplicate_names() {
let dir = tempfile::tempdir().unwrap();
let _data = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
let active = create("Active", "#22AA44").unwrap();
let banned = create("Banned", "#cc2222").unwrap();
assert_eq!(active.color, "#22aa44");
assert!(create(" active ", "#000000")
.unwrap_err()
.contains("PROFILE_STATUS_EXISTS"));
assert_eq!(
list().iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
["Active", "Banned"]
);
let renamed = update(&banned.id, "Suspended", "#cc2222").unwrap();
assert_eq!(renamed.name, "Suspended");
assert!(renamed.updated_at >= banned.updated_at);
assert!(update(&banned.id, "Active", "#cc2222")
.unwrap_err()
.contains("PROFILE_STATUS_EXISTS"));
assert!(update("missing", "X", "#000000")
.unwrap_err()
.contains("PROFILE_STATUS_NOT_FOUND"));
delete(&active.id).unwrap();
assert!(!exists(&active.id));
assert!(delete(&active.id)
.unwrap_err()
.contains("PROFILE_STATUS_NOT_FOUND"));
assert_eq!(list().len(), 1);
}
#[test]
fn colors_must_be_six_digit_hex() {
assert_eq!(checked_color("#A1B2C3").unwrap(), "#a1b2c3");
for bad in ["", "#fff", "a1b2c3", "#a1b2c", "#a1b2c3d", "#zzzzzz", "red"] {
assert!(checked_color(bad).is_err(), "{bad:?}");
}
}
}
+77
View File
@@ -0,0 +1,77 @@
//! 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.
use crate::sync::config_entities::{ConfigEntity, ConfigKind, ConfigStore};
use crate::wayfern_manager::WayfernConfig;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TemplateSettings {
/// What the profiles made from the template are named after.
#[serde(default)]
pub name_prefix: Option<String>,
#[serde(default)]
pub proxy_id: Option<String>,
#[serde(default)]
pub vpn_id: Option<String>,
/// Proxies to spread new profiles across, one each in turn. Wins over
/// `proxy_id` when not empty.
#[serde(default)]
pub proxy_ids: Vec<String>,
#[serde(default)]
pub group_id: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub status_id: Option<String>,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub extension_group_id: Option<String>,
#[serde(default)]
pub dns_blocklist: Option<String>,
#[serde(default)]
pub dns_server: Option<String>,
#[serde(default)]
pub proxy_bypass_rules: Vec<String>,
#[serde(default)]
pub start_urls: Vec<String>,
#[serde(default)]
pub launch_hook: Option<String>,
#[serde(default)]
pub ephemeral: bool,
#[serde(default)]
pub clear_on_close: bool,
/// Fingerprint settings (OS, screen bounds, WebRTC, images, WebGL,
/// session restore, launch arguments). The device fields are always empty.
#[serde(default)]
pub wayfern_config: Option<WayfernConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileTemplate {
pub id: String,
pub name: String,
#[serde(default)]
pub settings: TemplateSettings,
#[serde(default)]
pub position: u64,
#[serde(default)]
pub updated_at: u64,
}
static STORE: ConfigStore<ProfileTemplate> = ConfigStore::new("profile_templates.json");
impl ConfigEntity for ProfileTemplate {
const KIND: ConfigKind = ConfigKind::Template;
fn id(&self) -> &str {
&self.id
}
fn updated_at(&self) -> u64 {
self.updated_at
}
fn store() -> &'static ConfigStore<Self> {
&STORE
}
}
+374
View File
@@ -0,0 +1,374 @@
//! Small config entities that sync whole, with no per-item switch: profile
//! statuses and profile templates.
//!
//! Each kind is one JSON file on this device and one object per entity in the
//! cloud at `<folder>/<id>.json`. Conflicts resolve on `updated_at`, last write
//! wins, as for every other config entity. A delete leaves the tombstone
//! `tombstones/<folder>/<id>.json`, and a local copy older than its tombstone
//! is removed rather than uploaded again.
//!
//! They sync whenever sync is set up: they hold no secrets, they are what
//! profiles refer to by id, and a team shares them through the owner's space.
use super::engine::SyncEngine;
use super::types::{SyncError, SyncResult};
use crate::events;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::marker::PhantomData;
use std::sync::Mutex;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ConfigKind {
Status,
Template,
}
impl ConfigKind {
pub const ALL: [ConfigKind; 2] = [ConfigKind::Status, ConfigKind::Template];
/// The cloud key folder, also the tombstone folder.
pub fn folder(self) -> &'static str {
match self {
ConfigKind::Status => "statuses",
ConfigKind::Template => "templates",
}
}
/// The entity type a live tombstone event carries.
pub fn tombstone_type(self) -> &'static str {
match self {
ConfigKind::Status => "status",
ConfigKind::Template => "template",
}
}
pub fn from_tombstone_type(entity_type: &str) -> Option<Self> {
Self::ALL
.into_iter()
.find(|kind| kind.tombstone_type() == entity_type)
}
pub fn from_folder(folder: &str) -> Option<Self> {
Self::ALL.into_iter().find(|kind| kind.folder() == folder)
}
/// Emitted whenever the local set of this kind changes.
pub fn changed_event(self) -> &'static str {
match self {
ConfigKind::Status => "profile-statuses-changed",
ConfigKind::Template => "profile-templates-changed",
}
}
}
pub trait ConfigEntity: Serialize + DeserializeOwned + Clone + Send + Sync + 'static {
const KIND: ConfigKind;
fn id(&self) -> &str;
/// Unix seconds of the last user edit.
fn updated_at(&self) -> u64;
fn store() -> &'static ConfigStore<Self>;
}
#[derive(Serialize, Deserialize)]
#[serde(bound = "T: Serialize + DeserializeOwned")]
struct StoreFile<T> {
#[serde(default = "Vec::new")]
items: Vec<T>,
}
/// Every entity of one kind, in one JSON file in the data directory.
pub struct ConfigStore<T> {
file_name: &'static str,
lock: Mutex<()>,
_kind: PhantomData<fn() -> T>,
}
impl<T: ConfigEntity> ConfigStore<T> {
pub const fn new(file_name: &'static str) -> Self {
Self {
file_name,
lock: Mutex::new(()),
_kind: PhantomData,
}
}
fn path(&self) -> std::path::PathBuf {
crate::app_dirs::data_subdir().join(self.file_name)
}
fn read(&self) -> Result<Vec<T>, String> {
let path = self.path();
match std::fs::read_to_string(&path) {
Ok(content) => serde_json::from_str::<StoreFile<T>>(&content)
.map(|file| file.items)
.map_err(|e| format!("{} is unreadable: {e}", self.file_name)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(format!("{} is unreadable: {e}", self.file_name)),
}
}
fn write(&self, items: Vec<T>) -> Result<(), String> {
let path = self.path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let json = serde_json::to_string_pretty(&StoreFile { items }).map_err(|e| e.to_string())?;
crate::app_dirs::write_owner_only(&path, json.as_bytes()).map_err(|e| e.to_string())
}
pub fn list(&self) -> Vec<T> {
let _guard = self.lock.lock().unwrap_or_else(|e| e.into_inner());
self.read().unwrap_or_else(|e| {
log::warn!("Config store read failed err=\"{e}\"");
Vec::new()
})
}
pub fn get(&self, id: &str) -> Option<T> {
self.list().into_iter().find(|item| item.id() == id)
}
/// Read, change and write back under one lock, so two edits cannot lose
/// each other.
pub fn mutate<R>(
&self,
change: impl FnOnce(&mut Vec<T>) -> Result<R, String>,
) -> Result<R, String> {
let _guard = self.lock.lock().unwrap_or_else(|e| e.into_inner());
let mut items = self.read()?;
let result = change(&mut items)?;
self.write(items)?;
Ok(result)
}
fn put(&self, item: T) -> Result<(), String> {
self.mutate(|items| {
match items.iter_mut().find(|existing| existing.id() == item.id()) {
Some(existing) => *existing = item,
None => items.push(item),
}
Ok(())
})
}
fn remove(&self, id: &str) -> Result<bool, String> {
self.mutate(|items| {
let before = items.len();
items.retain(|item| item.id() != id);
Ok(items.len() != before)
})
}
}
fn notify(kind: ConfigKind) {
if let Err(e) = events::emit_empty(kind.changed_event()) {
log::warn!(
"Event emit failed event={} err=\"{e}\"",
kind.changed_event()
);
}
}
/// After a local create or edit: tell the UI, and queue the upload.
pub fn changed_locally<T: ConfigEntity>(id: &str) {
notify(T::KIND);
if !crate::sync::is_sync_configured() {
return;
}
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let id = id.to_string();
tauri::async_runtime::spawn(async move {
scheduler.queue_config_sync(T::KIND, id).await;
});
}
}
/// After a local delete: tell the UI, and queue the remote delete.
pub fn deleted_locally<T: ConfigEntity>(id: &str) {
notify(T::KIND);
if !crate::sync::is_sync_configured() {
return;
}
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let id = id.to_string();
tauri::async_runtime::spawn(async move {
scheduler.queue_config_delete(T::KIND, id).await;
});
}
}
/// Every local entity id of every kind, for the upload pass at sync start.
pub fn all_local_ids() -> Vec<(ConfigKind, String)> {
fn ids<T: ConfigEntity>() -> Vec<(ConfigKind, String)> {
T::store()
.list()
.iter()
.map(|item| (T::KIND, item.id().to_string()))
.collect()
}
let mut all = ids::<crate::profile_statuses::ProfileStatus>();
all.extend(ids::<crate::profile_templates::ProfileTemplate>());
all
}
/// Apply a deletion another device made.
pub fn apply_remote_delete(kind: ConfigKind, id: &str) {
fn remove<T: ConfigEntity>(id: &str) {
match T::store().remove(id) {
Ok(true) => {
log::info!(
"{} deleted remotely, removed locally id={id}",
T::KIND.tombstone_type()
);
notify(T::KIND);
}
Ok(false) => {}
Err(e) => log::warn!(
"Remote {} delete not applied id={id} err=\"{e}\"",
T::KIND.tombstone_type()
),
}
}
match kind {
ConfigKind::Status => remove::<crate::profile_statuses::ProfileStatus>(id),
ConfigKind::Template => remove::<crate::profile_templates::ProfileTemplate>(id),
}
}
pub async fn sync_one(engine: &SyncEngine, kind: ConfigKind, id: &str) -> SyncResult<()> {
match kind {
ConfigKind::Status => {
engine
.sync_config::<crate::profile_statuses::ProfileStatus>(id)
.await
}
ConfigKind::Template => {
engine
.sync_config::<crate::profile_templates::ProfileTemplate>(id)
.await
}
}
}
pub async fn fetch_missing(engine: &SyncEngine) -> SyncResult<()> {
engine
.fetch_missing_configs::<crate::profile_statuses::ProfileStatus>()
.await?;
engine
.fetch_missing_configs::<crate::profile_templates::ProfileTemplate>()
.await
}
impl SyncEngine {
pub async fn sync_config<T: ConfigEntity>(&self, id: &str) -> SyncResult<()> {
let kind = T::KIND;
let local = T::store().get(id);
let local_updated_at = local.as_ref().map_or(0, T::updated_at);
if self
.tombstone_blocks(kind.folder(), id, local_updated_at)
.await?
{
// Deleted elsewhere after the last edit here. Uploading it again would
// undo that delete on every device.
if local.is_some() {
apply_remote_delete(kind, id);
}
return Ok(());
}
let remote_key = format!("{}/{id}.json", kind.folder());
let stat = self.client.stat(&remote_key).await?;
match (local, stat.exists) {
(Some(item), true) => {
let remote_updated_at = self.remote_updated_at(&stat, &remote_key).await;
if remote_updated_at > local_updated_at {
self.download_config::<T>(id).await?;
} else if local_updated_at > remote_updated_at {
self.upload_config(&item).await?;
}
}
(Some(item), false) => self.upload_config(&item).await?,
(None, true) => self.download_config::<T>(id).await?,
(None, false) => {}
}
Ok(())
}
async fn upload_config<T: ConfigEntity>(&self, item: &T) -> SyncResult<()> {
let json = serde_json::to_string_pretty(item)
.map_err(|e| SyncError::SerializationError(e.to_string()))?;
let remote_key = format!("{}/{}.json", T::KIND.folder(), item.id());
self
.upload_config_json(&remote_key, &json, item.updated_at())
.await?;
log::info!("{} uploaded id={}", T::KIND.tombstone_type(), item.id());
Ok(())
}
async fn download_config<T: ConfigEntity>(&self, id: &str) -> SyncResult<()> {
let remote_key = format!("{}/{id}.json", T::KIND.folder());
let presign = self.client.presign_download(&remote_key).await?;
let raw = self.client.download_bytes(&presign.url).await?;
let data = super::encryption::maybe_unseal_after_download(&raw)
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal {remote_key}: {e}")))?;
let item: T = serde_json::from_slice(&data)
.map_err(|e| SyncError::SerializationError(format!("Failed to parse {remote_key}: {e}")))?;
// The object's own id must be the one its key names, or a crafted object
// could overwrite a different local entity.
if item.id() != id {
return Err(SyncError::InvalidData(format!(
"{remote_key} holds a different id"
)));
}
T::store().put(item).map_err(SyncError::InvalidData)?;
notify(T::KIND);
log::info!("{} downloaded id={id}", T::KIND.tombstone_type());
Ok(())
}
pub async fn delete_config(&self, kind: ConfigKind, id: &str) -> SyncResult<()> {
let folder = kind.folder();
self
.client
.delete(
&format!("{folder}/{id}.json"),
Some(&format!("tombstones/{folder}/{id}.json")),
)
.await?;
log::info!("{} deleted from cloud id={id}", kind.tombstone_type());
Ok(())
}
/// Download every entity of this kind the cloud has and this device lacks.
pub async fn fetch_missing_configs<T: ConfigEntity>(&self) -> SyncResult<()> {
let folder = T::KIND.folder();
let prefix = format!("{folder}/");
let local: std::collections::HashSet<String> = T::store()
.list()
.iter()
.map(|item| item.id().to_string())
.collect();
let remote = self.client.list(&prefix).await?;
for object in &remote.objects {
let Some(id) = object
.key
.strip_prefix(&prefix)
.and_then(|rest| rest.strip_suffix(".json"))
.filter(|id| crate::app_dirs::is_plain_path_part(id))
else {
continue;
};
if local.contains(id) || self.tombstone_blocks(folder, id, 0).await.unwrap_or(true) {
continue;
}
if let Err(e) = self.download_config::<T>(id).await {
log::warn!(
"Missing {} download failed id={id} err=\"{e}\"",
T::KIND.tombstone_type()
);
}
}
Ok(())
}
}
+4 -4
View File
@@ -524,7 +524,7 @@ pub fn is_sync_configured() -> bool {
}
pub struct SyncEngine {
client: SyncClient,
pub(super) client: SyncClient,
}
impl SyncEngine {
@@ -588,7 +588,7 @@ impl SyncEngine {
/// decrypting the small JSON body and reading its embedded `updated_at` (for
/// older self-hosted servers that don't surface metadata). Legacy objects with
/// neither resolve to 0, so any real local edit (`updated_at` > 0) wins.
async fn remote_updated_at(&self, stat: &StatResponse, remote_key: &str) -> u64 {
pub(super) async fn remote_updated_at(&self, stat: &StatResponse, remote_key: &str) -> u64 {
if let Some(meta) = &stat.metadata {
if let Some(v) = meta
.get(UPDATED_AT_META_KEY)
@@ -617,7 +617,7 @@ impl SyncEngine {
/// future reconciles can compare via HEAD without downloading the body. The
/// body is sealed (E2E) exactly as before; only a plaintext unix timestamp
/// lives in the object metadata.
async fn upload_config_json(
pub(super) async fn upload_config_json(
&self,
remote_key: &str,
json: &str,
@@ -655,7 +655,7 @@ impl SyncEngine {
/// failed rather than as a silent success. A caller that cannot propagate
/// treats the error as blocking: a skipped pass is retried, a resurrection is
/// not self-correcting.
async fn tombstone_blocks(
pub(super) async fn tombstone_blocks(
&self,
kind: &str,
id: &str,
+7
View File
@@ -1,4 +1,5 @@
mod client;
pub mod config_entities;
pub mod encryption;
mod engine;
pub mod manifest;
@@ -140,6 +141,9 @@ pub async fn start_pipeline(app_handle: tauri::AppHandle) {
set_global_scheduler(scheduler.clone());
scheduler.sync_all_enabled_profiles(&app_handle).await;
for (kind, id) in config_entities::all_local_ids() {
scheduler.queue_config_sync(kind, id).await;
}
match SyncEngine::create_from_settings(&app_handle).await {
Ok(engine) => {
@@ -149,6 +153,9 @@ pub async fn start_pipeline(app_handle: tauri::AppHandle) {
if let Err(e) = engine.check_for_missing_synced_entities(&app_handle).await {
log::warn!("Missing entity check failed err=\"{e}\"");
}
if let Err(e) = config_entities::fetch_missing(&engine).await {
log::warn!("Missing config entity check failed err=\"{e}\"");
}
}
Err(e) => {
log::debug!("Missing profile check skipped err=\"{e}\"");
+76 -2
View File
@@ -1,3 +1,4 @@
use super::config_entities::{self, ConfigKind};
use super::engine::SyncEngine;
use super::subscription::SyncWorkItem;
use crate::events;
@@ -102,6 +103,8 @@ pub struct SyncScheduler {
pending_extensions: Arc<Mutex<HashSet<String>>>,
pending_extension_groups: Arc<Mutex<HashSet<String>>>,
pending_tombstones: Arc<Mutex<Vec<(String, String)>>>,
pending_configs: Arc<Mutex<HashSet<(ConfigKind, String)>>>,
pending_config_deletes: Arc<Mutex<HashSet<(ConfigKind, String)>>>,
running_profiles: Arc<Mutex<HashSet<String>>>,
in_flight_profiles: Arc<Mutex<HashSet<String>>>,
}
@@ -124,6 +127,8 @@ impl SyncScheduler {
pending_extensions: Arc::new(Mutex::new(HashSet::new())),
pending_extension_groups: Arc::new(Mutex::new(HashSet::new())),
pending_tombstones: Arc::new(Mutex::new(Vec::new())),
pending_configs: Arc::new(Mutex::new(HashSet::new())),
pending_config_deletes: Arc::new(Mutex::new(HashSet::new())),
running_profiles: Arc::new(Mutex::new(HashSet::new())),
in_flight_profiles: Arc::new(Mutex::new(HashSet::new())),
}
@@ -180,6 +185,12 @@ impl SyncScheduler {
}
drop(pending_proxies);
if !self.pending_configs.lock().await.is_empty()
|| !self.pending_config_deletes.lock().await.is_empty()
{
return true;
}
let pending_groups = self.pending_groups.lock().await;
if !pending_groups.is_empty() {
return true;
@@ -320,6 +331,19 @@ impl SyncScheduler {
pending.insert(extension_group_id);
}
pub async fn queue_config_sync(&self, kind: ConfigKind, id: String) {
self.pending_configs.lock().await.insert((kind, id));
}
pub async fn queue_config_delete(&self, kind: ConfigKind, id: String) {
self
.pending_configs
.lock()
.await
.remove(&(kind, id.clone()));
self.pending_config_deletes.lock().await.insert((kind, id));
}
pub async fn queue_tombstone(&self, entity_type: String, entity_id: String) {
let mut pending = self.pending_tombstones.lock().await;
if !pending
@@ -468,6 +492,7 @@ impl SyncScheduler {
SyncWorkItem::Vpn(id) => scheduler.queue_vpn_sync(id).await,
SyncWorkItem::Extension(id) => scheduler.queue_extension_sync(id).await,
SyncWorkItem::ExtensionGroup(id) => scheduler.queue_extension_group_sync(id).await,
SyncWorkItem::Config(kind, id) => scheduler.queue_config_sync(kind, id).await,
SyncWorkItem::Tombstone(entity_type, entity_id) => {
scheduler.queue_tombstone(entity_type, entity_id).await
}
@@ -503,6 +528,43 @@ impl SyncScheduler {
self.process_pending_vpns(app_handle).await;
self.process_pending_extensions(app_handle).await;
self.process_pending_extension_groups(app_handle).await;
self.process_pending_configs(app_handle).await;
}
async fn process_pending_configs(&self, app_handle: &tauri::AppHandle) {
let deletes: Vec<(ConfigKind, String)> =
self.pending_config_deletes.lock().await.drain().collect();
let syncs: Vec<(ConfigKind, String)> = self.pending_configs.lock().await.drain().collect();
if deletes.is_empty() && syncs.is_empty() {
return;
}
let engine = match SyncEngine::create_from_settings(app_handle).await {
Ok(engine) => {
SYNC_ENGINE.succeeded();
engine
}
Err(e) => {
SYNC_ENGINE.failed(e);
return;
}
};
for (kind, id) in deletes {
// Not retried, as for groups: the tombstone check at the next start
// keeps a copy that missed this from coming back on other devices only
// once the delete has reached the cloud.
if let Err(e) = engine.delete_config(kind, &id).await {
log::warn!(
"Remote {} delete failed id={id} err=\"{e}\"",
kind.tombstone_type()
);
}
}
for (kind, id) in syncs {
match config_entities::sync_one(&engine, kind, &id).await {
Ok(()) => log_sync_success(kind.tombstone_type(), &id),
Err(e) => log_sync_failure(kind.tombstone_type(), &id, &e),
}
}
}
async fn process_pending_profiles(&self, app_handle: &tauri::AppHandle) {
@@ -908,7 +970,15 @@ impl SyncScheduler {
"extension_group" => {
self.pending_extension_groups.lock().await.remove(entity_id);
}
_ => {}
other => {
if let Some(kind) = ConfigKind::from_tombstone_type(other) {
self
.pending_configs
.lock()
.await
.remove(&(kind, entity_id.to_string()));
}
}
}
}
@@ -1037,7 +1107,11 @@ impl SyncScheduler {
}
}
}
_ => {}
other => {
if let Some(kind) = ConfigKind::from_tombstone_type(other) {
config_entities::apply_remote_delete(kind, &entity_id);
}
}
}
}
}
+58 -1
View File
@@ -27,6 +27,7 @@ pub enum SyncWorkItem {
Vpn(String),
Extension(String),
ExtensionGroup(String),
Config(super::config_entities::ConfigKind, String),
Tombstone(String, String),
}
@@ -391,6 +392,14 @@ impl SyncSubscription {
key
}
/// `statuses/<id>.json` and the other config-entity keys.
fn config_key(key: &str) -> Option<(super::config_entities::ConfigKind, &str)> {
let (folder, rest) = key.split_once('/')?;
let kind = super::config_entities::ConfigKind::from_folder(folder)?;
let id = rest.strip_suffix(".json")?;
crate::app_dirs::is_plain_path_part(id).then_some((kind, id))
}
fn handle_event(event: &SubscribeEvent, work_tx: &mpsc::UnboundedSender<SyncWorkItem>) {
let Some(raw_key) = &event.key else {
return;
@@ -438,9 +447,16 @@ impl SyncSubscription {
.strip_prefix("extension_groups/")
.and_then(|s| s.strip_suffix(".json"))
.map(|s| SyncWorkItem::ExtensionGroup(s.to_string()))
} else if let Some((kind, id)) = Self::config_key(key) {
Some(SyncWorkItem::Config(kind, id.to_string()))
} else if key.starts_with("tombstones/") {
key.strip_prefix("tombstones/").and_then(|rest| {
if rest.starts_with("profiles/") {
if let Some((kind, id)) = Self::config_key(rest) {
Some(SyncWorkItem::Tombstone(
kind.tombstone_type().to_string(),
id.to_string(),
))
} else if rest.starts_with("profiles/") {
rest
.strip_prefix("profiles/")
.and_then(|s| s.strip_suffix(".json"))
@@ -551,6 +567,47 @@ impl SubscriptionManager {
mod tests {
use super::*;
fn work_for(key: &str) -> Option<SyncWorkItem> {
let (tx, mut rx) = mpsc::unbounded_channel();
let event = SubscribeEvent {
event_type: "change".to_string(),
key: Some(key.to_string()),
last_modified: None,
size: None,
};
SyncSubscription::handle_event(&event, &tx);
rx.try_recv().ok()
}
#[test]
fn config_entity_keys_become_work_and_tombstones() {
use crate::sync::config_entities::ConfigKind;
assert!(matches!(
work_for("statuses/s1.json"),
Some(SyncWorkItem::Config(ConfigKind::Status, id)) if id == "s1"
));
assert!(matches!(
work_for("teams/t9/templates/t1.json"),
Some(SyncWorkItem::Config(ConfigKind::Template, id)) if id == "t1"
));
assert!(matches!(
work_for("tombstones/statuses/s1.json"),
Some(SyncWorkItem::Tombstone(kind, id)) if kind == "status" && id == "s1"
));
assert!(matches!(
work_for("tombstones/templates/t1.json"),
Some(SyncWorkItem::Tombstone(kind, id)) if kind == "template" && id == "t1"
));
for key in [
"statuses/../x.json",
"statuses/a/b.json",
"statuses/s1.txt",
"unknown/x.json",
] {
assert!(work_for(key).is_none(), "{key}");
}
}
#[test]
fn reconnects_back_off_from_six_seconds_to_ten_minutes() {
let delays: Vec<u64> = (1..=10).map(|n| reconnect_delay(n).as_secs()).collect();
@@ -589,6 +589,8 @@ mod tests {
created_by_email: None,
dns_blocklist: None,
dns_server: None,
status_id: None,
start_urls: Vec::new(),
password_protected: false,
clear_on_close: false,
created_at: None,
+12 -1
View File
@@ -72,6 +72,7 @@ import { useKonamiCode } from "@/hooks/use-konami-code";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { useProfileEvents } from "@/hooks/use-profile-events";
import { useProfileStatuses } from "@/hooks/use-profile-statuses";
import { useProfileTableFilters } from "@/hooks/use-profile-table-filters";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useSyncSessions } from "@/hooks/use-sync-session";
@@ -283,6 +284,7 @@ export default function Home() {
error: groupsError,
loadGroups,
} = useGroupEvents();
const profileStatuses = useProfileStatuses();
const {
storedProxies,
@@ -2266,9 +2268,17 @@ export default function Home() {
proxyNames: new Map(storedProxies.map((p) => [p.id, p.name])),
vpnNames: new Map(vpnConfigs.map((v) => [v.id, v.name])),
extensionGroupNames: new Map(extensionGroups.map((e) => [e.id, e.name])),
statusNames: new Map(profileStatuses.map((s) => [s.id, s.name])),
runningProfiles,
}),
[groupsData, storedProxies, vpnConfigs, extensionGroups, runningProfiles],
[
groupsData,
storedProxies,
vpnConfigs,
extensionGroups,
profileStatuses,
runningProfiles,
],
);
const clearTableSelection = useCallback(() => setSelectedProfiles([]), []);
@@ -2346,6 +2356,7 @@ export default function Home() {
onSessionChanged={applySession}
/>
<ProfilesDataTable
profileStatuses={profileStatuses}
isLoading={isLoading && profiles.length === 0}
showOnboardingEmptyState={profiles.length === 0}
profiles={profileFilters.rows}
+153 -2
View File
@@ -67,6 +67,10 @@ import {
ProfileInfoDialog,
ProfileLaunchHookDialog,
} from "@/components/profile-info-dialog";
import {
ProfileStatusChip,
ProfileStatusesDialog,
} from "@/components/profile-statuses-dialog";
import {
DATA_TABLE_CLASSES,
TableColumnResizer,
@@ -83,6 +87,7 @@ import {
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { CopyToClipboard } from "@/components/ui/copy-to-clipboard";
import {
@@ -149,6 +154,7 @@ import type {
ExtensionGroup,
LocationItem,
ProfileBotState,
ProfileStatus,
StoredProxy,
SyncSessionInfo,
TrafficSnapshot,
@@ -273,6 +279,9 @@ interface TableMeta {
// Extension groups (for Ext column lookup)
extensionGroups: ExtensionGroup[];
profileStatuses: ProfileStatus[];
onManageStatuses: () => void;
// Click handlers for inline Ext / DNS cell editing
onAssignExtensionGroup?: (profileIds: string[]) => void;
setDnsBlocklistProfile: React.Dispatch<
@@ -690,6 +699,104 @@ function DnsCell({
);
}
// Inline status dropdown, same Popover/Command pattern as DNS. A status is a
// label, so it stays editable while the profile runs.
function StatusCell({
profile,
meta,
}: {
profile: BrowserProfile;
meta: TableMeta;
}) {
const [open, setOpen] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const current =
meta.profileStatuses.find((status) => status.id === profile.status_id) ??
null;
const onPick = async (statusId: string | null) => {
setIsSaving(true);
try {
await invoke("set_profiles_status", {
profileIds: [profile.id],
statusId,
});
} catch (error) {
showErrorToast(translateBackendError(meta.t as never, error));
} finally {
setIsSaving(false);
setOpen(false);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
data-slot="profile-status-cell"
disabled={isSaving}
className="flex h-6 w-full min-w-0 items-center rounded-md px-2 text-left text-sm transition-colors duration-100 hover:bg-foreground/6 disabled:opacity-50"
>
{current ? (
<ProfileStatusChip status={current} />
) : (
<EmptyCellValue label={meta.t("profileStatuses.none")} />
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-52 p-0" align="start">
<Command>
{meta.profileStatuses.length > 8 && (
<CommandInput placeholder={meta.t("profileStatuses.search")} />
)}
<CommandList>
<CommandEmpty>{meta.t("profileStatuses.noMatch")}</CommandEmpty>
<CommandGroup>
<CommandItem value="__none__" onSelect={() => void onPick(null)}>
{current === null && <LuCheck className="mr-2 size-3.5" />}
<span className={current === null ? "" : "ml-5"}>
{meta.t("profileStatuses.none")}
</span>
</CommandItem>
{meta.profileStatuses.map((status) => (
<CommandItem
key={status.id}
value={`${status.name} ${status.id}`}
onSelect={() => void onPick(status.id)}
>
{current?.id === status.id && (
<LuCheck className="mr-2 size-3.5" />
)}
<span
className={
current?.id === status.id ? "min-w-0" : "ml-5 min-w-0"
}
>
<ProfileStatusChip status={status} />
</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<CommandItem
value="__manage__"
onSelect={() => {
setOpen(false);
meta.onManageStatuses();
}}
>
<span className="ml-5">{meta.t("profileStatuses.manage")}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
/**
* The first eight characters of the profile's UUID: the whole first group of a
* v4, which is also the prefix `id:` searches on, so what the row shows can be
@@ -1545,6 +1652,8 @@ interface ProfilesDataTableProps {
* `ext:` to a group name and needs the same list. One invoke, one listener.
*/
extensionGroups: ExtensionGroup[];
/** Loaded by the page for the same reason: `state:` resolves to names. */
profileStatuses?: ProfileStatus[];
isUpdating: (browser: string) => boolean;
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
onAssignProfilesToGroup: (profileIds: string[]) => void;
@@ -1595,6 +1704,8 @@ interface ProfilesDataTableProps {
onImportProfiles?: () => void;
}
const EMPTY_STATUSES: ProfileStatus[] = [];
export function ProfilesDataTable({
filterControls,
onClearFilters,
@@ -1610,6 +1721,7 @@ export function ProfilesDataTable({
onOpenCookieManagement,
runningProfiles,
extensionGroups,
profileStatuses = EMPTY_STATUSES,
isUpdating,
onAssignProfilesToGroup,
onAssignProfilesToProxy,
@@ -1703,6 +1815,7 @@ export function ProfilesDataTable({
}, [allProfiles, profileForInfoDialog, setProfileForInfoDialog]);
const [bypassRulesProfile, setBypassRulesProfile] =
React.useState<BrowserProfile | null>(null);
const [statusesDialogOpen, setStatusesDialogOpen] = React.useState(false);
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
React.useState<BrowserProfile | null>(null);
const [launchHookProfile, setLaunchHookProfile] =
@@ -2388,6 +2501,9 @@ export function ProfilesDataTable({
onAssignExtensionGroup,
setDnsBlocklistProfile,
profileStatuses,
onManageStatuses: () => setStatusesDialogOpen(true),
// Rename helpers
handleRename,
setProfileToRename,
@@ -2530,6 +2646,7 @@ export function ProfilesDataTable({
handleBotStopRun,
handleBotViewActivity,
botFixHandler,
profileStatuses,
],
);
@@ -2999,6 +3116,18 @@ export function ProfilesDataTable({
return <ProfileIdCell profile={row.original} meta={meta} />;
},
},
{
id: "state",
size: 100,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profileStatuses.column");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
return <StatusCell profile={row.original} meta={meta} />;
},
},
{
id: "tags",
size: 100,
@@ -3416,6 +3545,7 @@ export function ProfilesDataTable({
{ id: "name", label: t("common.labels.name"), hideable: false },
{ id: "created_at", label: t("search.fields.created"), hideable: false },
{ id: "profileId", label: t("profiles.table.profileId") },
{ id: "state", label: t("profileStatuses.column") },
{ id: "tags", label: t("profileTable.tagsHeader") },
{ id: "note", label: t("profileTable.noteHeader") },
{ id: "proxy", label: t("profiles.table.proxy") },
@@ -3452,6 +3582,11 @@ export function ProfilesDataTable({
)?.name ?? ""
);
if (id === "dns") return row.dns_blocklist ?? "";
if (id === "state")
return (
profileStatuses.find((status) => status.id === row.status_id)
?.name ?? ""
);
return row.id;
};
return {
@@ -3460,7 +3595,15 @@ export function ProfilesDataTable({
enableResizing: !fixed && id !== "created_at",
minSize: fixed ? column.size : 48,
maxSize: 1600,
...(["tags", "note", "proxy", "ext", "dns", "profileId"].includes(id)
...([
"state",
"tags",
"note",
"proxy",
"ext",
"dns",
"profileId",
].includes(id)
? {
accessorFn: accessor,
enableSorting: true,
@@ -3469,7 +3612,7 @@ export function ProfilesDataTable({
: {}),
};
}),
[columns, storedProxies, vpnConfigs, extensionGroups],
[columns, storedProxies, vpnConfigs, extensionGroups, profileStatuses],
);
const table = useReactTable({
@@ -3530,6 +3673,7 @@ export function ProfilesDataTable({
: `${Math.max(84, Math.round(containerWidth * 0.09))}px`;
}
const proportions: Record<string, { pct: number; floor: number }> = {
state: { pct: 0.1, floor: 90 },
tags: { pct: 0.12, floor: 100 },
note: { pct: 0.1, floor: 80 },
proxy: { pct: 0.13, floor: 110 },
@@ -3559,6 +3703,7 @@ export function ProfilesDataTable({
profileId: w >= PROFILE_ID_MIN_WIDTH,
dns: w >= 768,
ext: w >= 672,
state: w >= 560,
note: w >= 576,
tags: w >= 512,
// Bot state survives further down than the other content columns:
@@ -4127,6 +4272,12 @@ export function ProfilesDataTable({
profileId={bypassRulesProfile?.id ?? null}
initialRules={bypassRulesProfile?.proxy_bypass_rules ?? []}
/>
<ProfileStatusesDialog
isOpen={statusesDialogOpen}
onClose={() => setStatusesDialogOpen(false)}
statuses={profileStatuses}
profiles={allProfiles}
/>
<ProfileDnsBlocklistDialog
isOpen={dnsBlocklistProfile !== null}
onClose={() => {
+353
View File
@@ -0,0 +1,353 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { LuPlus, LuTrash2 } from "react-icons/lu";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { BrowserProfile, ProfileStatus } from "@/types";
/**
* Data colors for statuses, picked to stay readable on light and dark themes.
* They label accounts the person tracks; they are not theme colors.
*/
export const STATUS_COLORS = [
"#22c55e",
"#84cc16",
"#eab308",
"#f97316",
"#ef4444",
"#ec4899",
"#a855f7",
"#3b82f6",
"#06b6d4",
"#94a3b8",
] as const;
/** The set offered when there are no statuses yet. */
const STARTER_STATUSES = [
{ key: "profileStatuses.starter.active", color: "#22c55e" },
{ key: "profileStatuses.starter.warming", color: "#eab308" },
{ key: "profileStatuses.starter.review", color: "#3b82f6" },
{ key: "profileStatuses.starter.banned", color: "#ef4444" },
] as const;
export function ProfileStatusDot({
color,
className,
}: {
color: string;
className?: string;
}) {
return (
<span
aria-hidden
className={cn("inline-block size-2 shrink-0 rounded-full", className)}
style={{ backgroundColor: color }}
/>
);
}
export function ProfileStatusChip({ status }: { status: ProfileStatus }) {
return (
<span
className="flex min-w-0 items-center gap-1.5"
data-slot="profile-status-chip"
>
<ProfileStatusDot color={status.color} />
<span className="truncate">{status.name}</span>
</span>
);
}
function ColorChoice({
value,
onChange,
disabled,
}: {
value: string;
onChange: (color: string) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
aria-label={t("profileStatuses.color")}
className="grid size-8 shrink-0 place-items-center rounded-md transition-colors duration-100 hover:bg-foreground/6 disabled:opacity-50"
>
<ProfileStatusDot color={value} className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-2">
<div className="grid grid-cols-5 gap-1">
{STATUS_COLORS.map((color) => (
<button
key={color}
type="button"
aria-label={color}
aria-pressed={value === color}
onClick={() => onChange(color)}
className={cn(
"grid size-7 place-items-center rounded-md transition-colors duration-100 hover:bg-foreground/6",
value === color && "bg-foreground/10",
)}
>
<ProfileStatusDot color={color} className="size-3.5" />
</button>
))}
</div>
<label className="mt-2 flex cursor-pointer items-center gap-2 rounded-md px-1 py-1 text-xs text-muted-foreground hover:bg-foreground/6">
<input
type="color"
value={value}
onChange={(event) => onChange(event.target.value)}
className="size-5 cursor-pointer rounded border-0 bg-transparent p-0"
/>
{t("profileStatuses.customColor")}
</label>
</PopoverContent>
</Popover>
);
}
function StatusRow({
status,
usage,
onDelete,
}: {
status: ProfileStatus;
usage: number;
onDelete: (status: ProfileStatus) => void;
}) {
const { t } = useTranslation();
const [name, setName] = React.useState(status.name);
React.useEffect(() => {
setName(status.name);
}, [status.name]);
const save = async (next: { name?: string; color?: string }) => {
const nextName = (next.name ?? name).trim();
const nextColor = next.color ?? status.color;
if (nextName === status.name && nextColor === status.color) return;
try {
await invoke("update_profile_status", {
statusId: status.id,
name: nextName,
color: nextColor,
});
} catch (error) {
setName(status.name);
showErrorToast(translateBackendError(t, error));
}
};
return (
<li className="flex items-center gap-1" data-slot="profile-status-row">
<ColorChoice
value={status.color}
onChange={(color) => void save({ color })}
/>
<Input
variant="soft"
value={name}
aria-label={t("profileStatuses.namePlaceholder")}
onChange={(event) => setName(event.target.value)}
onBlur={() => void save({})}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") setName(status.name);
}}
className="h-8 flex-1"
/>
<span className="w-12 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{usage}
</span>
<Button
type="button"
variant="subtle"
size="icon"
className="size-8"
aria-label={t("profileStatuses.delete")}
onClick={() => onDelete(status)}
>
<LuTrash2 className="size-3.5" />
</Button>
</li>
);
}
export function ProfileStatusesDialog({
isOpen,
onClose,
statuses,
profiles,
}: {
isOpen: boolean;
onClose: () => void;
statuses: ProfileStatus[];
profiles: BrowserProfile[];
}) {
const { t } = useTranslation();
const [newName, setNewName] = React.useState("");
const [newColor, setNewColor] = React.useState<string>(STATUS_COLORS[0]);
const [isAdding, setIsAdding] = React.useState(false);
const [deleting, setDeleting] = React.useState<ProfileStatus | null>(null);
const usage = React.useMemo(() => {
const counts = new Map<string, number>();
for (const profile of profiles) {
if (profile.status_id) {
counts.set(profile.status_id, (counts.get(profile.status_id) ?? 0) + 1);
}
}
return counts;
}, [profiles]);
React.useEffect(() => {
if (isOpen) {
setNewName("");
setNewColor(STATUS_COLORS[statuses.length % STATUS_COLORS.length]);
}
}, [isOpen, statuses.length]);
const add = async (entries: { name: string; color: string }[]) => {
setIsAdding(true);
try {
for (const entry of entries) {
await invoke("create_profile_status", entry);
}
setNewName("");
} catch (error) {
showErrorToast(translateBackendError(t, error));
} finally {
setIsAdding(false);
}
};
const confirmDelete = async () => {
if (!deleting) return;
try {
await invoke("delete_profile_status", { statusId: deleting.id });
} catch (error) {
showErrorToast(translateBackendError(t, error));
} finally {
setDeleting(null);
}
};
return (
<>
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md" data-slot="profile-statuses-dialog">
<DialogHeader>
<DialogTitle>{t("profileStatuses.title")}</DialogTitle>
<DialogDescription>
{t("profileStatuses.description")}
</DialogDescription>
</DialogHeader>
{statuses.length === 0 ? (
<div className="flex flex-col items-start gap-2 rounded-lg bg-foreground/4 p-3">
<p className="text-sm text-muted-foreground">
{t("profileStatuses.empty")}
</p>
<Button
type="button"
variant="soft"
size="sm"
disabled={isAdding}
onClick={() =>
void add(
STARTER_STATUSES.map((entry) => ({
name: t(entry.key),
color: entry.color,
})),
)
}
>
{t("profileStatuses.addStarter")}
</Button>
</div>
) : (
<ul className="flex max-h-80 flex-col gap-1 overflow-y-auto">
{statuses.map((status) => (
<StatusRow
key={status.id}
status={status}
usage={usage.get(status.id) ?? 0}
onDelete={setDeleting}
/>
))}
</ul>
)}
<form
className="flex items-center gap-1"
onSubmit={(event) => {
event.preventDefault();
if (newName.trim()) {
void add([{ name: newName.trim(), color: newColor }]);
}
}}
>
<ColorChoice
value={newColor}
onChange={setNewColor}
disabled={isAdding}
/>
<Input
id="new-profile-status"
variant="soft"
value={newName}
placeholder={t("profileStatuses.namePlaceholder")}
onChange={(event) => setNewName(event.target.value)}
disabled={isAdding}
className="h-8 flex-1"
/>
<Button
type="submit"
size="sm"
className="h-8"
disabled={isAdding || !newName.trim()}
>
<LuPlus className="size-3.5" />
{t("common.buttons.add")}
</Button>
</form>
</DialogContent>
</Dialog>
<DeleteConfirmationDialog
isOpen={deleting !== null}
onClose={() => setDeleting(null)}
onConfirm={confirmDelete}
title={t("profileStatuses.deleteTitle", {
name: deleting?.name ?? "",
})}
description={t("profileStatuses.deleteDescription")}
confirmButtonText={t("common.buttons.delete")}
/>
</>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useEffect, useState } from "react";
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;
}
+12
View File
@@ -38,6 +38,18 @@ export function useProfileTableFilters(
{ value: "stopped", label: t("tables.stopped") },
],
},
{
id: "state",
label: t("profileStatuses.column"),
value: (row) => row.status_id ?? "",
options: [
{ value: "", label: t("profileStatuses.none") },
...[...context.statusNames].map(([value, label]) => ({
value,
label,
})),
],
},
{
id: "tag",
label: t("profileTable.tagsHeader"),
+31 -2
View File
@@ -295,7 +295,8 @@
"locked": "Passwortgeschützt",
"ephemeral": "Flüchtiges Profil",
"created": "Erstellungsdatum",
"launched": "Datum des letzten Starts"
"launched": "Datum des letzten Starts",
"state": "Profilstatus"
},
"operators": {
"negate": "Schließt Treffer aus",
@@ -1718,6 +1719,29 @@
"upgraded": "Diese Adresse bietet auch verschlüsseltes DNS, daher verwendet Donut es. Beginnen Sie mit tcp://, um einfaches DNS zu behalten."
}
},
"profileStatuses": {
"column": "Status",
"none": "Kein Status",
"search": "Status suchen…",
"noMatch": "Kein passender Status",
"manage": "Status verwalten…",
"title": "Profilstatus",
"description": "Kennzeichnen Sie jedes Profil danach, wie es um sein Konto steht. Status werden mit Ihren anderen Geräten und Ihrem Team synchronisiert.",
"empty": "Noch keine Status. Beginnen Sie mit einer üblichen Auswahl oder fügen Sie unten eigene hinzu.",
"addStarter": "Aktiv, Aufwärmen, Prüfen und Gesperrt hinzufügen",
"starter": {
"active": "Aktiv",
"warming": "Aufwärmen",
"review": "Prüfen",
"banned": "Gesperrt"
},
"namePlaceholder": "Name des Status",
"color": "Farbe des Status",
"customColor": "Eigene Farbe",
"delete": "Status löschen",
"deleteTitle": "„{{name}}“ löschen?",
"deleteDescription": "Profile mit diesem Status haben danach keinen Status. Dies lässt sich nicht rückgängig machen."
},
"vpns": {
"form": {
"titleEdit": "VPN bearbeiten",
@@ -2441,7 +2465,12 @@
"feedbackRateLimited": "Sie haben in kurzer Zeit viel Feedback gesendet. Versuchen Sie es in ein paar Minuten erneut.",
"feedbackSendFailed": "Das Feedback konnte nicht gesendet werden. Versuchen Sie es später erneut.",
"feedbackLogsUnreadable": "Die Protokolle konnten nicht gelesen werden. Schalten Sie „Aktuelle Protokolle anhängen“ aus und senden Sie erneut.",
"dnsServerInvalid": "Dieser DNS-Server ist ungültig. Geben Sie eine IP-Adresse oder einen Hostnamen ein, tls://host für DNS over TLS oder eine https://-URL für DNS over HTTPS."
"dnsServerInvalid": "Dieser DNS-Server ist ungültig. Geben Sie eine IP-Adresse oder einen Hostnamen ein, tls://host für DNS over TLS oder eine https://-URL für DNS over HTTPS.",
"profileStatusExists": "Ein Status mit diesem Namen existiert bereits.",
"profileStatusNotFound": "Dieser Status existiert nicht mehr.",
"profileStatusColorInvalid": "Wählen Sie eine Farbe für den Status.",
"profileStatusNameTooLong": "Ein Statusname darf höchstens 40 Zeichen haben.",
"profileStatusLimitReached": "Sie haben 100 Status, das Maximum. Löschen Sie einen, um einen weiteren hinzuzufügen."
},
"rail": {
"profiles": "Profile",
+31 -2
View File
@@ -295,7 +295,8 @@
"locked": "Password protected",
"ephemeral": "Ephemeral profile",
"created": "Creation date",
"launched": "Last launch date"
"launched": "Last launch date",
"state": "Profile status"
},
"operators": {
"negate": "Excludes what matches",
@@ -1718,6 +1719,29 @@
"upgraded": "This address also serves encrypted DNS, so Donut uses it. Start with tcp:// to keep plain DNS."
}
},
"profileStatuses": {
"column": "Status",
"none": "No status",
"search": "Search statuses…",
"noMatch": "No matching status",
"manage": "Manage statuses…",
"title": "Profile statuses",
"description": "Label each profile by where its account stands. Statuses sync to your other devices and to your team.",
"empty": "No statuses yet. Start with a common set, or add your own below.",
"addStarter": "Add Active, Warming up, Needs review and Banned",
"starter": {
"active": "Active",
"warming": "Warming up",
"review": "Needs review",
"banned": "Banned"
},
"namePlaceholder": "Status name",
"color": "Status color",
"customColor": "Custom color",
"delete": "Delete status",
"deleteTitle": "Delete \"{{name}}\"?",
"deleteDescription": "Profiles with this status will have no status. You cannot undo this."
},
"vpns": {
"form": {
"titleEdit": "Edit VPN",
@@ -2441,7 +2465,12 @@
"feedbackRateLimited": "You sent a lot of feedback in a short time. Try again in a few minutes.",
"feedbackSendFailed": "The feedback could not be sent. Try again later.",
"feedbackLogsUnreadable": "The logs could not be read. Turn off \"Include recent logs\" and send again.",
"dnsServerInvalid": "This DNS server is not valid. Enter an IP address or host name, tls://host for DNS over TLS, or an https:// URL for DNS over HTTPS."
"dnsServerInvalid": "This DNS server is not valid. Enter an IP address or host name, tls://host for DNS over TLS, or an https:// URL for DNS over HTTPS.",
"profileStatusExists": "A status with this name already exists.",
"profileStatusNotFound": "This status no longer exists.",
"profileStatusColorInvalid": "Pick a color for the status.",
"profileStatusNameTooLong": "A status name can have at most 40 characters.",
"profileStatusLimitReached": "You have 100 statuses, the maximum. Delete one to add another."
},
"rail": {
"profiles": "Profiles",
+31 -2
View File
@@ -296,7 +296,8 @@
"locked": "Protegido con contraseña",
"ephemeral": "Perfil efímero",
"created": "Fecha de creación",
"launched": "Fecha del último inicio"
"launched": "Fecha del último inicio",
"state": "Estado del perfil"
},
"operators": {
"negate": "Excluye lo que coincide",
@@ -1724,6 +1725,29 @@
"upgraded": "Esta dirección también ofrece DNS cifrado, así que Donut lo usa. Empieza con tcp:// para mantener DNS simple."
}
},
"profileStatuses": {
"column": "Estado",
"none": "Sin estado",
"search": "Buscar estados…",
"noMatch": "Ningún estado coincide",
"manage": "Gestionar estados…",
"title": "Estados de perfil",
"description": "Etiqueta cada perfil según la situación de su cuenta. Los estados se sincronizan con tus otros dispositivos y con tu equipo.",
"empty": "Aún no hay estados. Empieza con un conjunto habitual o añade los tuyos abajo.",
"addStarter": "Añadir Activo, Calentando, Revisar y Baneado",
"starter": {
"active": "Activo",
"warming": "Calentando",
"review": "Revisar",
"banned": "Baneado"
},
"namePlaceholder": "Nombre del estado",
"color": "Color del estado",
"customColor": "Color personalizado",
"delete": "Eliminar estado",
"deleteTitle": "¿Eliminar «{{name}}»?",
"deleteDescription": "Los perfiles con este estado quedarán sin estado. No se puede deshacer."
},
"vpns": {
"form": {
"titleEdit": "Editar VPN",
@@ -2451,7 +2475,12 @@
"feedbackRateLimited": "Enviaste muchos comentarios en poco tiempo. Inténtalo de nuevo en unos minutos.",
"feedbackSendFailed": "No se pudieron enviar los comentarios. Inténtalo más tarde.",
"feedbackLogsUnreadable": "No se pudieron leer los registros. Desactiva «Incluir registros recientes» y envía de nuevo.",
"dnsServerInvalid": "Este servidor DNS no es válido. Introduce una dirección IP o un nombre de host, tls://host para DNS sobre TLS o una URL https:// para DNS sobre HTTPS."
"dnsServerInvalid": "Este servidor DNS no es válido. Introduce una dirección IP o un nombre de host, tls://host para DNS sobre TLS o una URL https:// para DNS sobre HTTPS.",
"profileStatusExists": "Ya existe un estado con este nombre.",
"profileStatusNotFound": "Este estado ya no existe.",
"profileStatusColorInvalid": "Elige un color para el estado.",
"profileStatusNameTooLong": "El nombre de un estado puede tener como máximo 40 caracteres.",
"profileStatusLimitReached": "Tienes 100 estados, el máximo. Elimina uno para añadir otro."
},
"rail": {
"profiles": "Perfiles",
+31 -2
View File
@@ -296,7 +296,8 @@
"locked": "Protégé par mot de passe",
"ephemeral": "Profil éphémère",
"created": "Date de création",
"launched": "Date du dernier lancement"
"launched": "Date du dernier lancement",
"state": "Statut du profil"
},
"operators": {
"negate": "Exclut ce qui correspond",
@@ -1724,6 +1725,29 @@
"upgraded": "Cette adresse propose aussi un DNS chiffré, Donut l'utilise donc. Commencez par tcp:// pour garder un DNS simple."
}
},
"profileStatuses": {
"column": "Statut",
"none": "Aucun statut",
"search": "Rechercher un statut…",
"noMatch": "Aucun statut correspondant",
"manage": "Gérer les statuts…",
"title": "Statuts des profils",
"description": "Indiquez pour chaque profil où en est son compte. Les statuts se synchronisent avec vos autres appareils et votre équipe.",
"empty": "Aucun statut pour l'instant. Commencez par un jeu courant ou ajoutez les vôtres ci-dessous.",
"addStarter": "Ajouter Actif, En préchauffe, À vérifier et Banni",
"starter": {
"active": "Actif",
"warming": "En préchauffe",
"review": "À vérifier",
"banned": "Banni"
},
"namePlaceholder": "Nom du statut",
"color": "Couleur du statut",
"customColor": "Couleur personnalisée",
"delete": "Supprimer le statut",
"deleteTitle": "Supprimer « {{name}} » ?",
"deleteDescription": "Les profils ayant ce statut n'auront plus de statut. Cette action est irréversible."
},
"vpns": {
"form": {
"titleEdit": "Modifier le VPN",
@@ -2451,7 +2475,12 @@
"feedbackRateLimited": "Vous avez envoyé beaucoup d'avis en peu de temps. Réessayez dans quelques minutes.",
"feedbackSendFailed": "L'avis n'a pas pu être envoyé. Réessayez plus tard.",
"feedbackLogsUnreadable": "Les journaux n'ont pas pu être lus. Désactivez « Joindre les journaux récents » et renvoyez.",
"dnsServerInvalid": "Ce serveur DNS n'est pas valide. Saisissez une adresse IP ou un nom d'hôte, tls://hôte pour DNS over TLS, ou une URL https:// pour DNS over HTTPS."
"dnsServerInvalid": "Ce serveur DNS n'est pas valide. Saisissez une adresse IP ou un nom d'hôte, tls://hôte pour DNS over TLS, ou une URL https:// pour DNS over HTTPS.",
"profileStatusExists": "Un statut portant ce nom existe déjà.",
"profileStatusNotFound": "Ce statut n'existe plus.",
"profileStatusColorInvalid": "Choisissez une couleur pour le statut.",
"profileStatusNameTooLong": "Le nom d'un statut ne peut pas dépasser 40 caractères.",
"profileStatusLimitReached": "Vous avez 100 statuts, le maximum. Supprimez-en un pour en ajouter un autre."
},
"rail": {
"profiles": "Profils",
+31 -2
View File
@@ -295,7 +295,8 @@
"locked": "パスワード保護",
"ephemeral": "一時プロファイル",
"created": "作成日",
"launched": "最終起動日"
"launched": "最終起動日",
"state": "プロファイルのステータス"
},
"operators": {
"negate": "一致するものを除外します",
@@ -1718,6 +1719,29 @@
"upgraded": "このアドレスは暗号化 DNS にも対応しているため、Donut はそれを使用します。通常の DNS を使うには先頭に tcp:// を付けてください。"
}
},
"profileStatuses": {
"column": "ステータス",
"none": "ステータスなし",
"search": "ステータスを検索…",
"noMatch": "一致するステータスはありません",
"manage": "ステータスを管理…",
"title": "プロファイルのステータス",
"description": "各プロファイルにアカウントの状況を示すラベルを付けます。ステータスは他のデバイスとチームに同期されます。",
"empty": "ステータスはまだありません。よく使うセットから始めるか、下で独自に追加してください。",
"addStarter": "アクティブ、ウォームアップ中、要確認、BAN を追加",
"starter": {
"active": "アクティブ",
"warming": "ウォームアップ中",
"review": "要確認",
"banned": "BAN"
},
"namePlaceholder": "ステータス名",
"color": "ステータスの色",
"customColor": "カスタムカラー",
"delete": "ステータスを削除",
"deleteTitle": "「{{name}}」を削除しますか?",
"deleteDescription": "このステータスのプロファイルはステータスなしになります。元に戻すことはできません。"
},
"vpns": {
"form": {
"titleEdit": "VPN を編集",
@@ -2441,7 +2465,12 @@
"feedbackRateLimited": "短時間に多くのフィードバックが送信されました。数分後にもう一度お試しください。",
"feedbackSendFailed": "フィードバックを送信できませんでした。しばらくしてからもう一度お試しください。",
"feedbackLogsUnreadable": "ログを読み込めませんでした。「最近のログを添付」をオフにして、もう一度送信してください。",
"dnsServerInvalid": "この DNS サーバーは無効です。IP アドレスまたはホスト名、DNS over TLS の場合は tls://host、DNS over HTTPS の場合は https:// URL を入力してください。"
"dnsServerInvalid": "この DNS サーバーは無効です。IP アドレスまたはホスト名、DNS over TLS の場合は tls://host、DNS over HTTPS の場合は https:// URL を入力してください。",
"profileStatusExists": "この名前のステータスはすでに存在します。",
"profileStatusNotFound": "このステータスはもう存在しません。",
"profileStatusColorInvalid": "ステータスの色を選択してください。",
"profileStatusNameTooLong": "ステータス名は 40 文字以内にしてください。",
"profileStatusLimitReached": "ステータスが上限の 100 個に達しています。追加するには 1 つ削除してください。"
},
"rail": {
"profiles": "プロファイル",
+31 -2
View File
@@ -295,7 +295,8 @@
"locked": "비밀번호 보호",
"ephemeral": "임시 프로필",
"created": "생성 날짜",
"launched": "마지막 실행 날짜"
"launched": "마지막 실행 날짜",
"state": "프로필 상태"
},
"operators": {
"negate": "일치하는 항목을 제외합니다",
@@ -1718,6 +1719,29 @@
"upgraded": "이 주소는 암호화된 DNS도 제공하므로 Donut이 이를 사용합니다. 일반 DNS를 유지하려면 tcp://로 시작하세요."
}
},
"profileStatuses": {
"column": "상태",
"none": "상태 없음",
"search": "상태 검색…",
"noMatch": "일치하는 상태가 없습니다",
"manage": "상태 관리…",
"title": "프로필 상태",
"description": "각 프로필에 계정 상황을 나타내는 라벨을 붙입니다. 상태는 다른 기기 및 팀과 동기화됩니다.",
"empty": "아직 상태가 없습니다. 일반적인 세트로 시작하거나 아래에서 직접 추가하세요.",
"addStarter": "활성, 워밍업, 검토 필요, 차단됨 추가",
"starter": {
"active": "활성",
"warming": "워밍업",
"review": "검토 필요",
"banned": "차단됨"
},
"namePlaceholder": "상태 이름",
"color": "상태 색상",
"customColor": "사용자 지정 색상",
"delete": "상태 삭제",
"deleteTitle": "\"{{name}}\"을(를) 삭제할까요?",
"deleteDescription": "이 상태의 프로필은 상태가 없어집니다. 되돌릴 수 없습니다."
},
"vpns": {
"form": {
"titleEdit": "VPN 편집",
@@ -2441,7 +2465,12 @@
"feedbackRateLimited": "짧은 시간에 피드백을 너무 많이 보냈습니다. 몇 분 후에 다시 시도하세요.",
"feedbackSendFailed": "피드백을 보낼 수 없습니다. 나중에 다시 시도하세요.",
"feedbackLogsUnreadable": "로그를 읽을 수 없습니다. '최근 로그 첨부'를 끄고 다시 보내세요.",
"dnsServerInvalid": "유효하지 않은 DNS 서버입니다. IP 주소 또는 호스트 이름, DNS over TLS의 경우 tls://host, DNS over HTTPS의 경우 https:// URL을 입력하세요."
"dnsServerInvalid": "유효하지 않은 DNS 서버입니다. IP 주소 또는 호스트 이름, DNS over TLS의 경우 tls://host, DNS over HTTPS의 경우 https:// URL을 입력하세요.",
"profileStatusExists": "같은 이름의 상태가 이미 있습니다.",
"profileStatusNotFound": "이 상태는 더 이상 존재하지 않습니다.",
"profileStatusColorInvalid": "상태 색상을 선택하세요.",
"profileStatusNameTooLong": "상태 이름은 최대 40자까지 가능합니다.",
"profileStatusLimitReached": "상태가 최대치인 100개입니다. 추가하려면 하나를 삭제하세요."
},
"rail": {
"profiles": "프로필",
+31 -2
View File
@@ -296,7 +296,8 @@
"locked": "Protegido por senha",
"ephemeral": "Perfil efêmero",
"created": "Data de criação",
"launched": "Data da última execução"
"launched": "Data da última execução",
"state": "Status do perfil"
},
"operators": {
"negate": "Exclui o que corresponde",
@@ -1724,6 +1725,29 @@
"upgraded": "Este endereço também oferece DNS criptografado, então o Donut o usa. Comece com tcp:// para manter o DNS simples."
}
},
"profileStatuses": {
"column": "Status",
"none": "Sem status",
"search": "Buscar status…",
"noMatch": "Nenhum status corresponde",
"manage": "Gerenciar status…",
"title": "Status dos perfis",
"description": "Marque cada perfil conforme a situação da conta. Os status são sincronizados com seus outros dispositivos e com sua equipe.",
"empty": "Ainda não há status. Comece com um conjunto comum ou adicione os seus abaixo.",
"addStarter": "Adicionar Ativo, Aquecendo, Revisar e Banido",
"starter": {
"active": "Ativo",
"warming": "Aquecendo",
"review": "Revisar",
"banned": "Banido"
},
"namePlaceholder": "Nome do status",
"color": "Cor do status",
"customColor": "Cor personalizada",
"delete": "Excluir status",
"deleteTitle": "Excluir \"{{name}}\"?",
"deleteDescription": "Os perfis com este status ficarão sem status. Não é possível desfazer."
},
"vpns": {
"form": {
"titleEdit": "Editar VPN",
@@ -2451,7 +2475,12 @@
"feedbackRateLimited": "Você enviou muito feedback em pouco tempo. Tente novamente em alguns minutos.",
"feedbackSendFailed": "Não foi possível enviar o feedback. Tente novamente mais tarde.",
"feedbackLogsUnreadable": "Não foi possível ler os logs. Desative \"Incluir logs recentes\" e envie novamente.",
"dnsServerInvalid": "Este servidor DNS não é válido. Digite um endereço IP ou nome de host, tls://host para DNS sobre TLS ou uma URL https:// para DNS sobre HTTPS."
"dnsServerInvalid": "Este servidor DNS não é válido. Digite um endereço IP ou nome de host, tls://host para DNS sobre TLS ou uma URL https:// para DNS sobre HTTPS.",
"profileStatusExists": "Já existe um status com este nome.",
"profileStatusNotFound": "Este status não existe mais.",
"profileStatusColorInvalid": "Escolha uma cor para o status.",
"profileStatusNameTooLong": "O nome de um status pode ter no máximo 40 caracteres.",
"profileStatusLimitReached": "Você tem 100 status, o máximo. Exclua um para adicionar outro."
},
"rail": {
"profiles": "Perfis",
+31 -2
View File
@@ -297,7 +297,8 @@
"locked": "Защищён паролем",
"ephemeral": "Временный профиль",
"created": "Дата создания",
"launched": "Дата последнего запуска"
"launched": "Дата последнего запуска",
"state": "Статус профиля"
},
"operators": {
"negate": "Исключает совпадения",
@@ -1730,6 +1731,29 @@
"upgraded": "Этот адрес также поддерживает зашифрованный DNS, поэтому Donut использует его. Начните с tcp://, чтобы оставить обычный DNS."
}
},
"profileStatuses": {
"column": "Статус",
"none": "Без статуса",
"search": "Поиск статусов…",
"noMatch": "Нет подходящего статуса",
"manage": "Управление статусами…",
"title": "Статусы профилей",
"description": "Отмечайте каждый профиль по состоянию его аккаунта. Статусы синхронизируются с другими устройствами и командой.",
"empty": "Статусов пока нет. Начните с обычного набора или добавьте свои ниже.",
"addStarter": "Добавить «Активен», «Прогрев», «Проверить» и «Забанен»",
"starter": {
"active": "Активен",
"warming": "Прогрев",
"review": "Проверить",
"banned": "Забанен"
},
"namePlaceholder": "Название статуса",
"color": "Цвет статуса",
"customColor": "Свой цвет",
"delete": "Удалить статус",
"deleteTitle": "Удалить «{{name}}»?",
"deleteDescription": "Профили с этим статусом останутся без статуса. Это действие нельзя отменить."
},
"vpns": {
"form": {
"titleEdit": "Изменить VPN",
@@ -2461,7 +2485,12 @@
"feedbackRateLimited": "Вы отправили много отзывов за короткое время. Попробуйте снова через несколько минут.",
"feedbackSendFailed": "Не удалось отправить отзыв. Попробуйте позже.",
"feedbackLogsUnreadable": "Не удалось прочитать логи. Выключите «Приложить последние логи» и отправьте снова.",
"dnsServerInvalid": "Недопустимый DNS-сервер. Введите IP-адрес или имя хоста, tls://хост для DNS over TLS или URL https:// для DNS over HTTPS."
"dnsServerInvalid": "Недопустимый DNS-сервер. Введите IP-адрес или имя хоста, tls://хост для DNS over TLS или URL https:// для DNS over HTTPS.",
"profileStatusExists": "Статус с таким названием уже существует.",
"profileStatusNotFound": "Этого статуса больше нет.",
"profileStatusColorInvalid": "Выберите цвет статуса.",
"profileStatusNameTooLong": "Название статуса может содержать не более 40 символов.",
"profileStatusLimitReached": "У вас 100 статусов, это максимум. Удалите один, чтобы добавить другой."
},
"rail": {
"profiles": "Профили",
+31 -2
View File
@@ -295,7 +295,8 @@
"locked": "Parola korumalı",
"ephemeral": "Geçici profil",
"created": "Oluşturulma tarihi",
"launched": "Son başlatma tarihi"
"launched": "Son başlatma tarihi",
"state": "Profil durumu"
},
"operators": {
"negate": "Eşleşenleri hariç tutar",
@@ -1718,6 +1719,29 @@
"upgraded": "Bu adres şifreli DNS de sunduğu için Donut onu kullanır. Düz DNS kullanmak için tcp:// ile başlayın."
}
},
"profileStatuses": {
"column": "Durum",
"none": "Durum yok",
"search": "Durum ara…",
"noMatch": "Eşleşen durum yok",
"manage": "Durumları yönet…",
"title": "Profil durumları",
"description": "Her profili hesabının durumuna göre etiketleyin. Durumlar diğer cihazlarınızla ve ekibinizle eşitlenir.",
"empty": "Henüz durum yok. Yaygın bir setle başlayın veya aşağıdan kendinizinkini ekleyin.",
"addStarter": "Aktif, Isınıyor, İncelenmeli ve Yasaklı ekle",
"starter": {
"active": "Aktif",
"warming": "Isınıyor",
"review": "İncelenmeli",
"banned": "Yasaklı"
},
"namePlaceholder": "Durum adı",
"color": "Durum rengi",
"customColor": "Özel renk",
"delete": "Durumu sil",
"deleteTitle": "\"{{name}}\" silinsin mi?",
"deleteDescription": "Bu durumdaki profillerin durumu kalmaz. Bu işlem geri alınamaz."
},
"vpns": {
"form": {
"titleEdit": "VPN'i Düzenle",
@@ -2441,7 +2465,12 @@
"feedbackRateLimited": "Kısa sürede çok fazla geri bildirim gönderdiniz. Birkaç dakika sonra tekrar deneyin.",
"feedbackSendFailed": "Geri bildirim gönderilemedi. Daha sonra tekrar deneyin.",
"feedbackLogsUnreadable": "Günlükler okunamadı. \"Son günlükleri ekle\" seçeneğini kapatıp tekrar gönderin.",
"dnsServerInvalid": "Bu DNS sunucusu geçerli değil. Bir IP adresi veya ana bilgisayar adı, DNS over TLS için tls://host ya da DNS over HTTPS için bir https:// URL'si girin."
"dnsServerInvalid": "Bu DNS sunucusu geçerli değil. Bir IP adresi veya ana bilgisayar adı, DNS over TLS için tls://host ya da DNS over HTTPS için bir https:// URL'si girin.",
"profileStatusExists": "Bu adda bir durum zaten var.",
"profileStatusNotFound": "Bu durum artık yok.",
"profileStatusColorInvalid": "Durum için bir renk seçin.",
"profileStatusNameTooLong": "Durum adı en fazla 40 karakter olabilir.",
"profileStatusLimitReached": "En fazla sayı olan 100 durumunuz var. Yenisini eklemek için birini silin."
},
"rail": {
"profiles": "Profiller",
+31 -2
View File
@@ -295,7 +295,8 @@
"locked": "Được bảo vệ bằng mật khẩu",
"ephemeral": "Hồ sơ tạm thời",
"created": "Ngày tạo",
"launched": "Ngày khởi chạy gần nhất"
"launched": "Ngày khởi chạy gần nhất",
"state": "Trạng thái profile"
},
"operators": {
"negate": "Loại trừ những gì khớp",
@@ -1718,6 +1719,29 @@
"upgraded": "Địa chỉ này cũng hỗ trợ DNS mã hóa nên Donut sử dụng nó. Bắt đầu bằng tcp:// để giữ DNS thường."
}
},
"profileStatuses": {
"column": "Trạng thái",
"none": "Không có trạng thái",
"search": "Tìm trạng thái…",
"noMatch": "Không có trạng thái phù hợp",
"manage": "Quản lý trạng thái…",
"title": "Trạng thái profile",
"description": "Gắn nhãn cho mỗi profile theo tình trạng tài khoản. Trạng thái được đồng bộ với các thiết bị khác và nhóm của bạn.",
"empty": "Chưa có trạng thái nào. Hãy bắt đầu với một bộ phổ biến hoặc tự thêm bên dưới.",
"addStarter": "Thêm Hoạt động, Đang làm nóng, Cần xem lại và Bị cấm",
"starter": {
"active": "Hoạt động",
"warming": "Đang làm nóng",
"review": "Cần xem lại",
"banned": "Bị cấm"
},
"namePlaceholder": "Tên trạng thái",
"color": "Màu trạng thái",
"customColor": "Màu tùy chỉnh",
"delete": "Xóa trạng thái",
"deleteTitle": "Xóa \"{{name}}\"?",
"deleteDescription": "Các profile có trạng thái này sẽ không còn trạng thái. Không thể hoàn tác."
},
"vpns": {
"form": {
"titleEdit": "Chỉnh sửa VPN",
@@ -2441,7 +2465,12 @@
"feedbackRateLimited": "Bạn đã gửi nhiều phản hồi trong thời gian ngắn. Hãy thử lại sau vài phút.",
"feedbackSendFailed": "Không thể gửi phản hồi. Hãy thử lại sau.",
"feedbackLogsUnreadable": "Không thể đọc nhật ký. Hãy tắt \"Đính kèm nhật ký gần đây\" rồi gửi lại.",
"dnsServerInvalid": "Máy chủ DNS này không hợp lệ. Nhập địa chỉ IP hoặc tên máy chủ, tls://host cho DNS over TLS, hoặc URL https:// cho DNS over HTTPS."
"dnsServerInvalid": "Máy chủ DNS này không hợp lệ. Nhập địa chỉ IP hoặc tên máy chủ, tls://host cho DNS over TLS, hoặc URL https:// cho DNS over HTTPS.",
"profileStatusExists": "Đã có trạng thái với tên này.",
"profileStatusNotFound": "Trạng thái này không còn tồn tại.",
"profileStatusColorInvalid": "Hãy chọn màu cho trạng thái.",
"profileStatusNameTooLong": "Tên trạng thái tối đa 40 ký tự.",
"profileStatusLimitReached": "Bạn đã có 100 trạng thái, mức tối đa. Hãy xóa một trạng thái để thêm mới."
},
"rail": {
"profiles": "Profile",
+31 -2
View File
@@ -295,7 +295,8 @@
"locked": "密码保护",
"ephemeral": "临时配置文件",
"created": "创建日期",
"launched": "上次启动日期"
"launched": "上次启动日期",
"state": "配置文件状态"
},
"operators": {
"negate": "排除匹配的结果",
@@ -1718,6 +1719,29 @@
"upgraded": "此地址也提供加密 DNS,因此 Donut 会使用它。以 tcp:// 开头可保留普通 DNS。"
}
},
"profileStatuses": {
"column": "状态",
"none": "无状态",
"search": "搜索状态…",
"noMatch": "没有匹配的状态",
"manage": "管理状态…",
"title": "配置文件状态",
"description": "按账号情况为每个配置文件加上标签。状态会同步到你的其他设备和团队。",
"empty": "还没有状态。可以从常用的一组开始,或在下方自行添加。",
"addStarter": "添加“活跃”“养号中”“待检查”和“已封禁”",
"starter": {
"active": "活跃",
"warming": "养号中",
"review": "待检查",
"banned": "已封禁"
},
"namePlaceholder": "状态名称",
"color": "状态颜色",
"customColor": "自定义颜色",
"delete": "删除状态",
"deleteTitle": "删除“{{name}}”?",
"deleteDescription": "具有此状态的配置文件将不再有状态。此操作无法撤销。"
},
"vpns": {
"form": {
"titleEdit": "编辑 VPN",
@@ -2441,7 +2465,12 @@
"feedbackRateLimited": "你在短时间内发送了大量反馈。请几分钟后再试。",
"feedbackSendFailed": "无法发送反馈。请稍后再试。",
"feedbackLogsUnreadable": "无法读取日志。请关闭“附上最近的日志”后重新发送。",
"dnsServerInvalid": "此 DNS 服务器无效。请输入 IP 地址或主机名、用于 DNS over TLS 的 tls://host,或用于 DNS over HTTPS 的 https:// URL。"
"dnsServerInvalid": "此 DNS 服务器无效。请输入 IP 地址或主机名、用于 DNS over TLS 的 tls://host,或用于 DNS over HTTPS 的 https:// URL。",
"profileStatusExists": "已存在同名状态。",
"profileStatusNotFound": "此状态已不存在。",
"profileStatusColorInvalid": "请为状态选择颜色。",
"profileStatusNameTooLong": "状态名称最多 40 个字符。",
"profileStatusLimitReached": "状态数量已达上限 100 个。请删除一个后再添加。"
},
"rail": {
"profiles": "配置文件",
+15
View File
@@ -25,6 +25,11 @@ export type BackendErrorCode =
| "PASSWORD_TOO_SHORT"
| "INVALID_LAUNCH_HOOK_URL"
| "DNS_SERVER_INVALID"
| "PROFILE_STATUS_EXISTS"
| "PROFILE_STATUS_NOT_FOUND"
| "PROFILE_STATUS_COLOR_INVALID"
| "PROFILE_STATUS_NAME_TOO_LONG"
| "PROFILE_STATUS_LIMIT_REACHED"
| "COOKIE_DB_LOCKED"
| "COOKIE_DB_UNAVAILABLE"
| "COOKIE_IMPORT_BROWSER_RUNNING"
@@ -361,6 +366,16 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.invalidLaunchHookUrl");
case "DNS_SERVER_INVALID":
return t("backendErrors.dnsServerInvalid");
case "PROFILE_STATUS_EXISTS":
return t("backendErrors.profileStatusExists");
case "PROFILE_STATUS_NOT_FOUND":
return t("backendErrors.profileStatusNotFound");
case "PROFILE_STATUS_COLOR_INVALID":
return t("backendErrors.profileStatusColorInvalid");
case "PROFILE_STATUS_NAME_TOO_LONG":
return t("backendErrors.profileStatusNameTooLong");
case "PROFILE_STATUS_LIMIT_REACHED":
return t("backendErrors.profileStatusLimitReached");
case "COOKIE_DB_LOCKED":
return t("backendErrors.cookieDbLocked");
case "COOKIE_DB_UNAVAILABLE":
+19
View File
@@ -22,6 +22,10 @@ const ctx = {
proxyNames: new Map([["p1", "Frankfurt residential"]]),
vpnNames: new Map([["v1", "Office WireGuard"]]),
extensionGroupNames: new Map([["e1", "Ad blockers"]]),
statusNames: new Map([
["s1", "Warming up"],
["s2", "Banned"],
]),
runningProfiles: new Set(["shop"]),
now: NOW,
};
@@ -73,6 +77,21 @@ test("plain text also matches the id by prefix, so the table's short id works",
assert.equal(hit("2222", profile()), false);
});
test("state: matches the status name, and none finds profiles without one", () => {
const warming = profile({ status_id: "s1" });
const unlabelled = profile();
assert.equal(hit("state:warming", warming), true);
assert.equal(hit("stage:warm", warming), true);
assert.equal(hit("state:banned", warming), false);
assert.equal(hit("state:none", unlabelled), true);
assert.equal(hit("state:none", warming), false);
assert.equal(hit("state:any", warming), true);
// A status deleted on another device still counts as set until it syncs away.
assert.equal(hit("state:any", profile({ status_id: "gone" })), true);
// The running state keeps its own field.
assert.equal(hit("status:stopped", warming), true);
});
test("a colon inside an ordinary word stays free text", () => {
const noted = profile({
note: "check https://shop.example.com:8080 at 12:30",
+10
View File
@@ -69,6 +69,12 @@ export const PROFILE_SEARCH_FIELDS: readonly ProfileSearchField[] = [
kind: "lookup",
labelKey: "search.fields.group",
},
{
key: "state",
aliases: ["stage"],
kind: "lookup",
labelKey: "search.fields.state",
},
{
key: "proxy",
aliases: [],
@@ -217,6 +223,8 @@ export interface ProfileSearchContext {
readonly proxyNames: ReadonlyMap<string, string>;
readonly vpnNames: ReadonlyMap<string, string>;
readonly extensionGroupNames: ReadonlyMap<string, string>;
/** Profile status id to its name. */
readonly statusNames: ReadonlyMap<string, string>;
readonly runningProfiles: ReadonlySet<string>;
/** Epoch ms the relative dates count back from. Defaults to the wall clock. */
readonly now?: number;
@@ -635,6 +643,8 @@ function fieldValue(
}
case "group":
return lookupValue(profile.group_id, ctx.groupNames);
case "state":
return lookupValue(profile.status_id ?? undefined, ctx.statusNames);
case "proxy":
return lookupValue(profile.proxy_id, ctx.proxyNames);
case "vpn":
+12
View File
@@ -47,6 +47,8 @@ export interface BrowserProfile {
created_at?: number;
dns_blocklist?: string;
dns_server?: string | null;
status_id?: string | null;
start_urls?: string[];
password_protected?: boolean;
}
@@ -348,6 +350,16 @@ export interface ProfileGroup {
last_sync?: number;
}
/** A named, colored label for where a profile's account stands. */
export interface ProfileStatus {
id: string;
name: string;
/** `#rrggbb`, picked by the person. */
color: string;
position: number;
updated_at: number;
}
export interface GroupWithCount {
id: string;
name: string;