mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-20 06:28:19 +02:00
feat: profile start pages
This commit is contained in:
1 parent
3f07a41fac
commit
4044c845c7
25 files changed
+755
-54
No files matched your search
@@ -56,6 +56,7 @@ export const commandCoverage = {
|
||||
"update_profile_proxy_bypass_rules",
|
||||
"update_profile_dns_blocklist",
|
||||
"update_profile_dns_server",
|
||||
"update_profile_start_urls",
|
||||
"profile_statuses::list_profile_statuses",
|
||||
"profile_statuses::create_profile_status",
|
||||
"profile_statuses::update_profile_status",
|
||||
|
||||
@@ -1753,6 +1753,107 @@ test("a payload profile an older version stored launches and is stored as an ide
|
||||
}
|
||||
});
|
||||
|
||||
test("start pages open on a launch from the app, and a given URL replaces them", async () => {
|
||||
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
|
||||
const app = appFromEnvironment("browser-start-pages", {
|
||||
seedVersionCache:
|
||||
cachedFixtureVersion(process.env.DONUT_E2E_PROJECT_ROOT) ?? false,
|
||||
wayfernTermsAccepted: false,
|
||||
});
|
||||
let browserPid;
|
||||
try {
|
||||
const prepared = await prepareWayfern(
|
||||
app,
|
||||
process.env.DONUT_E2E_PROJECT_ROOT,
|
||||
);
|
||||
if (!app.session) await app.start();
|
||||
await app.invoke("accept_wayfern_terms");
|
||||
const profile = await createRealProfile(
|
||||
app,
|
||||
prepared.version,
|
||||
"Start Pages",
|
||||
);
|
||||
const one = `${fixtureUrl}/start-page-one`;
|
||||
const two = `${fixtureUrl}/start-page-two`;
|
||||
const withPages = await app.invoke("update_profile_start_urls", {
|
||||
profileId: profile.id,
|
||||
startUrls: [one, " ", two],
|
||||
});
|
||||
assert.deepEqual(withPages.start_urls, [one, two]);
|
||||
|
||||
const openPages = async (pid) => {
|
||||
const port = processCommandLine(pid).match(
|
||||
/--remote-debugging-port=(\d+)/,
|
||||
)?.[1];
|
||||
if (!port) return [];
|
||||
const targets = await fetch(`http://127.0.0.1:${port}/json`)
|
||||
.then((response) => response.json())
|
||||
.catch(() => []);
|
||||
return targets
|
||||
.filter((target) => target.type === "page")
|
||||
.map((target) => target.url);
|
||||
};
|
||||
const stop = async () => {
|
||||
const current = (await app.invoke("list_browser_profiles")).find(
|
||||
(p) => p.id === profile.id,
|
||||
);
|
||||
await app.invoke("kill_browser_profile", { profile: current });
|
||||
await waitForProcessExit(app, current.process_id);
|
||||
browserPid = undefined;
|
||||
};
|
||||
|
||||
const launched = await app.invoke("launch_browser_profile", {
|
||||
profile: withPages,
|
||||
});
|
||||
browserPid = launched.process_id;
|
||||
const pages = await app.waitFor(
|
||||
async () => {
|
||||
const urls = await openPages(browserPid);
|
||||
return urls.includes(one) && urls.includes(two) ? urls : false;
|
||||
},
|
||||
{ timeoutMs: 30_000, description: "both start pages open" },
|
||||
);
|
||||
assert.equal(pages.length, 2, `only the start pages: ${pages}`);
|
||||
await stop();
|
||||
|
||||
const given = `${fixtureUrl}/given-url`;
|
||||
const relaunched = await app.invoke("launch_browser_profile", {
|
||||
profile: (await app.invoke("list_browser_profiles")).find(
|
||||
(p) => p.id === profile.id,
|
||||
),
|
||||
url: given,
|
||||
});
|
||||
browserPid = relaunched.process_id;
|
||||
const replaced = await app.waitFor(
|
||||
async () => {
|
||||
const urls = await openPages(browserPid);
|
||||
return urls.includes(given) ? urls : false;
|
||||
},
|
||||
{ timeoutMs: 30_000, description: "the given URL open" },
|
||||
);
|
||||
assert.ok(
|
||||
!replaced.includes(one) && !replaced.includes(two),
|
||||
`a given URL replaces the start pages: ${replaced}`,
|
||||
);
|
||||
await stop();
|
||||
await app.invoke("delete_profile", { profileId: profile.id });
|
||||
} catch (error) {
|
||||
await app.capture("failure");
|
||||
throw error;
|
||||
} finally {
|
||||
if (app.session && browserPid && processExists(browserPid)) {
|
||||
const running = (
|
||||
await app.invoke("list_browser_profiles").catch(() => [])
|
||||
).find((item) => item.process_id === browserPid);
|
||||
if (running)
|
||||
await app
|
||||
.invoke("kill_browser_profile", { profile: running })
|
||||
.catch(() => {});
|
||||
}
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("custom launch arguments reach only their Wayfern profile", async () => {
|
||||
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
|
||||
const app = appFromEnvironment("browser-launch-args", {
|
||||
|
||||
@@ -463,6 +463,33 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a
|
||||
profileIds: [profile.id],
|
||||
statusId: warming.id,
|
||||
});
|
||||
|
||||
// Start pages: tidied, web pages only, at most twenty.
|
||||
assert.match(
|
||||
await app.invokeError("update_profile_start_urls", {
|
||||
profileId: profile.id,
|
||||
startUrls: ["file:///etc/passwd"],
|
||||
}),
|
||||
/START_URL_INVALID/,
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("update_profile_start_urls", {
|
||||
profileId: profile.id,
|
||||
startUrls: Array.from(
|
||||
{ length: 21 },
|
||||
(_, i) => `https://${i}.example/`,
|
||||
),
|
||||
}),
|
||||
/START_URLS_TOO_MANY/,
|
||||
);
|
||||
const withStartPages = await app.invoke("update_profile_start_urls", {
|
||||
profileId: profile.id,
|
||||
startUrls: [" https://mail.example/inbox ", "", "shop.example"],
|
||||
});
|
||||
assert.deepEqual(withStartPages.start_urls, [
|
||||
"https://mail.example/inbox",
|
||||
"https://shop.example/",
|
||||
]);
|
||||
await app.invoke("update_profile_clear_on_close", {
|
||||
profileId: profile.id,
|
||||
clearOnClose: true,
|
||||
@@ -481,6 +508,10 @@ 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.deepEqual(changed.start_urls, [
|
||||
"https://mail.example/inbox",
|
||||
"https://shop.example/",
|
||||
]);
|
||||
assert.equal(changed.clear_on_close, true);
|
||||
assert.deepEqual((await app.invoke("get_all_tags")).sort(), [
|
||||
"alpha",
|
||||
@@ -756,6 +787,13 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a
|
||||
}),
|
||||
/DNS_SERVER_INVALID/,
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("create_browser_profile_new", {
|
||||
...createDnsArgs,
|
||||
startUrls: ["chrome://settings"],
|
||||
}),
|
||||
/START_URL_INVALID/,
|
||||
);
|
||||
assert.ok(
|
||||
!(await app.invoke("list_browser_profiles")).some(
|
||||
(item) => item.name === "DNS Profile",
|
||||
@@ -765,8 +803,10 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a
|
||||
const dnsProfile = await app.invoke("create_browser_profile_new", {
|
||||
...createDnsArgs,
|
||||
dnsServer: "9.9.9.9",
|
||||
startUrls: ["https://start.example/"],
|
||||
});
|
||||
assert.equal(dnsProfile.dns_server, "9.9.9.9");
|
||||
assert.deepEqual(dnsProfile.start_urls, ["https://start.example/"]);
|
||||
const cleared = await app.invoke("update_profile_dns_server", {
|
||||
profileId: dnsProfile.id,
|
||||
dnsServer: "",
|
||||
|
||||
@@ -4240,6 +4240,41 @@ test("profile statuses start from a common set and are picked from the table", a
|
||||
});
|
||||
});
|
||||
|
||||
test("the start pages section saves one page per line and reports a bad page", async () => {
|
||||
await withApp("ui-start-pages", async (app) => {
|
||||
const profile = await createUiProfile(app, "Profile Start Pages");
|
||||
await app.waitForText(profile.name);
|
||||
const stored = async () =>
|
||||
(await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === profile.id,
|
||||
).start_urls ?? [];
|
||||
await app.clickSelector(
|
||||
`[data-slot="profile-inspect-trigger"][data-profile-id="${profile.id}"]`,
|
||||
);
|
||||
await app.clickSelector(
|
||||
'[data-slot="profile-info-section"][data-section="startPages"]',
|
||||
);
|
||||
await app.waitForText(en.startPages.description);
|
||||
const field = '[data-slot="profile-start-pages-input"]';
|
||||
await app.fillSelector(field, "mail.example\nfile:///etc/hosts");
|
||||
await app.clickText(en.common.buttons.save, { exact: true });
|
||||
await app.waitForText(
|
||||
en.backendErrors.startUrlInvalid.replace("{{url}}", "file:///etc/hosts"),
|
||||
);
|
||||
assert.deepEqual(await stored(), []);
|
||||
await app.fillSelector(field, "mail.example\nhttps://shop.example/cart");
|
||||
await app.clickText(en.common.buttons.save, { exact: true });
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
JSON.stringify(await stored()) ===
|
||||
JSON.stringify(["https://mail.example/", "https://shop.example/cart"]),
|
||||
{ description: "the saved start pages" },
|
||||
);
|
||||
await app.capture("profile-start-pages");
|
||||
await app.clickSelector('[data-slot="profile-info-close"]');
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -51,6 +51,9 @@ pub struct ApiProfile {
|
||||
pub dns_server: Option<String>,
|
||||
/// The profile's status id (see `/v1/statuses`), or `null`.
|
||||
pub status_id: Option<String>,
|
||||
/// Pages a launch from the app opens, in order, instead of the last
|
||||
/// session. A run with a `url` opens only that URL.
|
||||
pub start_urls: Vec<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.
|
||||
@@ -105,6 +108,7 @@ impl From<&crate::profile::types::BrowserProfile> for ApiProfile {
|
||||
vpn_id: profile.vpn_id.clone(),
|
||||
dns_server: profile.dns_server.clone(),
|
||||
status_id: profile.status_id.clone(),
|
||||
start_urls: profile.start_urls.clone(),
|
||||
extension_group_id: profile.extension_group_id.clone(),
|
||||
ephemeral: profile.ephemeral,
|
||||
temporary: profile.temporary,
|
||||
@@ -174,6 +178,9 @@ pub struct CreateProfileRequest {
|
||||
/// Optional status id from `/v1/statuses`.
|
||||
#[serde(default)]
|
||||
pub status_id: Option<String>,
|
||||
/// Optional start pages, at most 20 http(s) URLs. A bare host gets `https://`.
|
||||
#[serde(default)]
|
||||
pub start_urls: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
@@ -206,6 +213,9 @@ pub struct UpdateProfileRequest {
|
||||
pub dns_server: Option<String>,
|
||||
/// Omitted or `null` leaves the status unchanged; an empty string clears it.
|
||||
pub status_id: Option<String>,
|
||||
/// Replaces the start pages; an empty list removes them. Omitted or `null`
|
||||
/// leaves them unchanged.
|
||||
pub start_urls: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -1661,6 +1671,12 @@ async fn create_profile(
|
||||
)));
|
||||
}
|
||||
}
|
||||
let start_urls = request
|
||||
.start_urls
|
||||
.clone()
|
||||
.map(crate::profile::manager::checked_start_urls)
|
||||
.transpose()
|
||||
.map_err(manager_error_response)?;
|
||||
|
||||
// Create profile using the async create_profile_with_group method
|
||||
match profile_manager
|
||||
@@ -1690,6 +1706,12 @@ async fn create_profile(
|
||||
}
|
||||
profile.status_id = Some(status_id);
|
||||
}
|
||||
if let Some(start_urls) = start_urls.filter(|urls| !urls.is_empty()) {
|
||||
match profile_manager.update_profile_start_urls(&profile.id.to_string(), start_urls) {
|
||||
Ok(updated) => profile = updated,
|
||||
Err(e) => return Err(manager_error_response(e)),
|
||||
}
|
||||
}
|
||||
if request.temporary.unwrap_or(false) {
|
||||
match profile_manager.mark_profile_temporary(&profile.id.to_string()) {
|
||||
Ok(updated) => profile = updated,
|
||||
@@ -1787,6 +1809,10 @@ async fn update_profile(
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Some(start_urls) = &request.start_urls {
|
||||
crate::profile::manager::checked_start_urls(start_urls.clone())
|
||||
.map_err(manager_error_response)?;
|
||||
}
|
||||
|
||||
// Update profile fields
|
||||
if let Some(new_name) = request.name {
|
||||
@@ -1914,6 +1940,12 @@ async fn update_profile(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(start_urls) = request.start_urls {
|
||||
if let Err(e) = profile_manager.update_profile_start_urls(&id, start_urls) {
|
||||
return Err(manager_error_response(e));
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -5872,6 +5904,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_documents_start_pages() {
|
||||
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
|
||||
for schema in ["CreateProfileRequest", "UpdateProfileRequest"] {
|
||||
assert!(
|
||||
!schema_required(&spec, schema)
|
||||
.iter()
|
||||
.any(|f| f == "start_urls"),
|
||||
"{schema}.start_urls must be optional"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
schema_required(&spec, "ApiProfile")
|
||||
.iter()
|
||||
.any(|f| f == "start_urls"),
|
||||
"ApiProfile always reports its start pages"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_documents_profile_statuses() {
|
||||
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
|
||||
|
||||
@@ -171,7 +171,8 @@ use profile::manager::{
|
||||
list_browser_profiles, rename_profile, update_profile_clear_on_close,
|
||||
update_profile_dns_blocklist, update_profile_dns_server, update_profile_launch_hook,
|
||||
update_profile_note, update_profile_proxy, update_profile_proxy_bypass_rules,
|
||||
update_profile_tags, update_profile_vpn, update_profile_window_color, update_wayfern_config,
|
||||
update_profile_start_urls, update_profile_tags, update_profile_vpn, update_profile_window_color,
|
||||
update_wayfern_config,
|
||||
};
|
||||
|
||||
use profile::trash::{
|
||||
@@ -3332,6 +3333,7 @@ pub fn run_with_builder(
|
||||
update_profile_proxy_bypass_rules,
|
||||
update_profile_dns_blocklist,
|
||||
update_profile_dns_server,
|
||||
update_profile_start_urls,
|
||||
profile_dns::describe_dns_server,
|
||||
profile_statuses::list_profile_statuses,
|
||||
profile_statuses::create_profile_status,
|
||||
|
||||
@@ -2552,6 +2552,11 @@ impl McpServer {
|
||||
"type": "string",
|
||||
"description": "Optional status id from list_profile_statuses"
|
||||
},
|
||||
"start_urls": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Optional start pages (at most 20 http(s) URLs) a launch from the app opens instead of the last session"
|
||||
},
|
||||
"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."
|
||||
@@ -2674,6 +2679,11 @@ impl McpServer {
|
||||
"type": "string",
|
||||
"description": "Status id from list_profile_statuses (empty string to remove)"
|
||||
},
|
||||
"start_urls": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Start pages, replacing the current ones (empty array to remove). At most 20 http(s) URLs."
|
||||
},
|
||||
"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."
|
||||
@@ -5351,6 +5361,23 @@ impl McpServer {
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
let start_urls = arguments
|
||||
.get("start_urls")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|urls| {
|
||||
crate::profile::manager::checked_start_urls(
|
||||
urls
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(str::to_string))
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
.map_err(|e| McpError {
|
||||
code: -32602,
|
||||
message: format!("Invalid start pages: {e}"),
|
||||
data: None,
|
||||
})?;
|
||||
|
||||
let temporary = arguments
|
||||
.get("temporary")
|
||||
@@ -5405,6 +5432,11 @@ impl McpServer {
|
||||
})?;
|
||||
}
|
||||
|
||||
if let Some(start_urls) = start_urls.filter(|urls| !urls.is_empty()) {
|
||||
let _ =
|
||||
ProfileManager::instance().update_profile_start_urls(&profile.id.to_string(), start_urls);
|
||||
}
|
||||
|
||||
if let Some(status_id) = arguments
|
||||
.get("status_id")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -5582,6 +5614,19 @@ impl McpServer {
|
||||
})?;
|
||||
}
|
||||
|
||||
if let Some(urls) = arguments.get("start_urls").and_then(|v| v.as_array()) {
|
||||
let urls = urls
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(str::to_string))
|
||||
.collect();
|
||||
pm.update_profile_start_urls(profile_id, urls)
|
||||
.map_err(|e| McpError {
|
||||
code: -32602,
|
||||
message: format!("Failed to update start pages: {e}"),
|
||||
data: None,
|
||||
})?;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -1463,6 +1463,27 @@ impl ProfileManager {
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
/// Takes effect at the next launch, like every launch setting.
|
||||
pub fn update_profile_start_urls(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
start_urls: Vec<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
let start_urls = checked_start_urls(start_urls)?;
|
||||
let mut profile = self.find_profile(profile_id)?;
|
||||
if profile.start_urls == start_urls {
|
||||
return Ok(profile);
|
||||
}
|
||||
profile.start_urls = start_urls;
|
||||
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(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(
|
||||
@@ -2506,6 +2527,72 @@ mod tests {
|
||||
let result_none = super::validate_launch_hook(None).unwrap();
|
||||
assert!(result_none.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_pages_are_tidied_and_limited_to_web_pages() {
|
||||
assert_eq!(
|
||||
super::checked_start_urls(vec![
|
||||
" https://mail.example/inbox ".to_string(),
|
||||
"".to_string(),
|
||||
"shop.example".to_string(),
|
||||
])
|
||||
.unwrap(),
|
||||
["https://mail.example/inbox", "https://shop.example/"]
|
||||
);
|
||||
for bad in [
|
||||
"file:///etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"chrome://settings",
|
||||
"https://",
|
||||
"a b.example",
|
||||
] {
|
||||
let err = super::checked_start_urls(vec![bad.to_string()]).unwrap_err();
|
||||
assert!(err.contains("START_URL_INVALID"), "{bad}: {err}");
|
||||
}
|
||||
let many = (0..21).map(|i| format!("https://{i}.example/")).collect();
|
||||
assert!(super::checked_start_urls(many)
|
||||
.unwrap_err()
|
||||
.contains("START_URLS_TOO_MANY"));
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_START_URLS: usize = 20;
|
||||
const MAX_START_URL_CHARS: usize = 2000;
|
||||
|
||||
/// The start pages to store: trimmed, blanks dropped, a bare host given
|
||||
/// `https://`, and only http(s) pages, which is all a launch may open.
|
||||
pub fn checked_start_urls(urls: Vec<String>) -> Result<Vec<String>, String> {
|
||||
let urls: Vec<String> = urls
|
||||
.into_iter()
|
||||
.map(|url| url.trim().to_string())
|
||||
.filter(|url| !url.is_empty())
|
||||
.collect();
|
||||
if urls.len() > MAX_START_URLS {
|
||||
return Err(crate::backend_error("START_URLS_TOO_MANY"));
|
||||
}
|
||||
urls
|
||||
.into_iter()
|
||||
.map(|raw| {
|
||||
let candidate = if raw.contains("://") {
|
||||
raw.clone()
|
||||
} else {
|
||||
format!("https://{raw}")
|
||||
};
|
||||
match url::Url::parse(&candidate) {
|
||||
Ok(url)
|
||||
if matches!(url.scheme(), "http" | "https")
|
||||
&& url.host_str().is_some()
|
||||
&& candidate.chars().count() <= MAX_START_URL_CHARS
|
||||
&& !candidate.chars().any(char::is_whitespace) =>
|
||||
{
|
||||
Ok(url.to_string())
|
||||
}
|
||||
_ => Err(
|
||||
serde_json::json!({ "code": "START_URL_INVALID", "params": { "url": raw } }).to_string(),
|
||||
),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -2683,6 +2770,16 @@ pub fn update_profile_dns_blocklist(
|
||||
.map_err(|e| format!("Failed to update DNS blocklist: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_profile_start_urls(
|
||||
profile_id: String,
|
||||
start_urls: Vec<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
ProfileManager::instance()
|
||||
.update_profile_start_urls(&profile_id, start_urls)
|
||||
.map_err(|e| crate::wrap_backend_error(e, "Failed to update start pages"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_profile_dns_server(
|
||||
profile_id: String,
|
||||
@@ -2733,7 +2830,10 @@ pub async fn create_browser_profile_new(
|
||||
dns_blocklist: Option<String>,
|
||||
dns_server: Option<String>,
|
||||
launch_hook: Option<String>,
|
||||
start_urls: Option<Vec<String>>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
// Checked before anything is created, so a bad page leaves nothing behind.
|
||||
let start_urls = start_urls.map(checked_start_urls).transpose()?;
|
||||
let fingerprint_os = wayfern_config.as_ref().and_then(|c| c.os.as_deref());
|
||||
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
@@ -2749,7 +2849,7 @@ pub async fn create_browser_profile_new(
|
||||
|
||||
let browser_type =
|
||||
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
|
||||
create_browser_profile_with_group(
|
||||
let profile = create_browser_profile_with_group(
|
||||
app_handle,
|
||||
name,
|
||||
browser_type.as_str().to_string(),
|
||||
@@ -2764,7 +2864,13 @@ pub async fn create_browser_profile_new(
|
||||
dns_server,
|
||||
launch_hook,
|
||||
)
|
||||
.await
|
||||
.await?;
|
||||
match start_urls.filter(|urls| !urls.is_empty()) {
|
||||
Some(urls) => ProfileManager::instance()
|
||||
.update_profile_start_urls(&profile.id.to_string(), urls)
|
||||
.map_err(|e| crate::wrap_backend_error(e, "Failed to save start pages")),
|
||||
None => Ok(profile),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -441,10 +441,16 @@ pub fn session_restore_verdict(
|
||||
ephemeral: bool,
|
||||
clear_on_close: bool,
|
||||
identity_at_launch: bool,
|
||||
opens_start_pages: bool,
|
||||
) -> Result<(), &'static str> {
|
||||
if kind == LaunchKind::Automation {
|
||||
return Err("an automation run starts clean");
|
||||
}
|
||||
// As in Chrome: a set of start pages replaces the last session. Restoring
|
||||
// both would add the start pages to the restored tabs on every launch.
|
||||
if opens_start_pages {
|
||||
return Err("the profile opens its start pages instead");
|
||||
}
|
||||
if headless {
|
||||
return Err("a headless launch has no session to show");
|
||||
}
|
||||
@@ -468,6 +474,21 @@ pub fn session_restore_verdict(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a launch opens: the URL it was given, or else, for a person's own
|
||||
/// launch, the profile's start pages. An automation run without a URL starts
|
||||
/// on a blank tab, as its caller expects.
|
||||
pub fn launch_pages<'a>(
|
||||
url: Option<&'a str>,
|
||||
start_urls: &'a [String],
|
||||
kind: LaunchKind,
|
||||
) -> Vec<&'a str> {
|
||||
match (url, kind) {
|
||||
(Some(url), _) => vec![url],
|
||||
(None, LaunchKind::Interactive) => start_urls.iter().map(String::as_str).collect(),
|
||||
(None, LaunchKind::Automation) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The switches that shape the first window: session restore and the launch
|
||||
/// identity. Kept apart from the rest of the command line so a test can pin
|
||||
/// them per launch kind.
|
||||
@@ -2901,6 +2922,8 @@ impl WayfernManager {
|
||||
};
|
||||
let identity_at_launch =
|
||||
identity_file.is_some() || (config.identity_id.is_none() && config.fingerprint.is_none());
|
||||
let pages = launch_pages(url, &profile.start_urls, kind);
|
||||
let opens_start_pages = url.is_none() && !pages.is_empty();
|
||||
let restore_session = match session_restore_verdict(
|
||||
config,
|
||||
kind,
|
||||
@@ -2908,6 +2931,7 @@ impl WayfernManager {
|
||||
ephemeral,
|
||||
profile.clear_on_close,
|
||||
identity_at_launch,
|
||||
opens_start_pages,
|
||||
) {
|
||||
Ok(()) => true,
|
||||
Err(reason) => {
|
||||
@@ -3295,21 +3319,18 @@ impl WayfernManager {
|
||||
|
||||
// Geolocation is handled internally by the browser binary.
|
||||
|
||||
if let Some(url) = url {
|
||||
if restore_session {
|
||||
// The tabs the browser reopened are the user's; the URL gets a tab of
|
||||
// its own instead of replacing whichever one came first.
|
||||
if let Err(e) = self.open_url_on_port(port, url).await {
|
||||
log::warn!(
|
||||
"Launch URL tab open failed profile={} err=\"{}\"",
|
||||
profile.id,
|
||||
crate::log_redaction::text(&e.to_string())
|
||||
);
|
||||
}
|
||||
} else if let Some(target) = page_targets.first() {
|
||||
if let Some(ws_url) = &target.websocket_debugger_url {
|
||||
for (index, page) in pages.iter().enumerate() {
|
||||
// The first page takes the window's first tab. Every other page, and
|
||||
// any page next to tabs the browser reopened (those are the user's),
|
||||
// gets a tab of its own.
|
||||
let navigate_first_tab = index == 0 && !restore_session;
|
||||
let target = page_targets
|
||||
.first()
|
||||
.and_then(|t| t.websocket_debugger_url.as_ref());
|
||||
match (navigate_first_tab, target) {
|
||||
(true, Some(ws_url)) => {
|
||||
if let Err(e) = self
|
||||
.send_cdp_command(ws_url, "Page.navigate", json!({ "url": url }))
|
||||
.send_cdp_command(ws_url, "Page.navigate", json!({ "url": page }))
|
||||
.await
|
||||
{
|
||||
log::warn!(
|
||||
@@ -3319,6 +3340,16 @@ impl WayfernManager {
|
||||
);
|
||||
}
|
||||
}
|
||||
(true, None) => {}
|
||||
(false, _) => {
|
||||
if let Err(e) = self.open_url_on_port(port, page).await {
|
||||
log::warn!(
|
||||
"Launch URL tab open failed profile={} err=\"{}\"",
|
||||
profile.id,
|
||||
crate::log_redaction::text(&e.to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4476,28 +4507,96 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_pages_open_on_a_persons_launch_and_replace_the_last_session() {
|
||||
let pages = vec![
|
||||
"https://a.example/".to_string(),
|
||||
"https://b.example/".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
launch_pages(None, &pages, LaunchKind::Interactive),
|
||||
["https://a.example/", "https://b.example/"]
|
||||
);
|
||||
assert_eq!(
|
||||
launch_pages(
|
||||
Some("https://given.example/"),
|
||||
&pages,
|
||||
LaunchKind::Interactive
|
||||
),
|
||||
["https://given.example/"],
|
||||
"an explicit URL wins over the start pages"
|
||||
);
|
||||
assert!(launch_pages(None, &pages, LaunchKind::Automation).is_empty());
|
||||
assert!(session_restore_verdict(
|
||||
&WayfernConfig::default(),
|
||||
LaunchKind::Interactive,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_restore_is_only_for_a_person_on_a_device_committed_at_launch() {
|
||||
let config = WayfernConfig::default();
|
||||
let interactive = |c: &WayfernConfig| {
|
||||
session_restore_verdict(c, LaunchKind::Interactive, false, false, false, true)
|
||||
session_restore_verdict(c, LaunchKind::Interactive, false, false, false, true, false)
|
||||
};
|
||||
assert_eq!(interactive(&config), Ok(()));
|
||||
assert!(session_restore_verdict(
|
||||
&config,
|
||||
LaunchKind::Automation,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
)
|
||||
.is_err());
|
||||
assert!(session_restore_verdict(
|
||||
&config,
|
||||
LaunchKind::Interactive,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
)
|
||||
.is_err());
|
||||
assert!(session_restore_verdict(
|
||||
&config,
|
||||
LaunchKind::Interactive,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
)
|
||||
.is_err());
|
||||
assert!(session_restore_verdict(
|
||||
&config,
|
||||
LaunchKind::Interactive,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
)
|
||||
.is_err());
|
||||
assert!(
|
||||
session_restore_verdict(&config, LaunchKind::Automation, false, false, false, true).is_err()
|
||||
);
|
||||
assert!(
|
||||
session_restore_verdict(&config, LaunchKind::Interactive, true, false, false, true).is_err()
|
||||
);
|
||||
assert!(
|
||||
session_restore_verdict(&config, LaunchKind::Interactive, false, true, false, true).is_err()
|
||||
);
|
||||
assert!(
|
||||
session_restore_verdict(&config, LaunchKind::Interactive, false, false, true, true).is_err()
|
||||
);
|
||||
assert!(
|
||||
session_restore_verdict(&config, LaunchKind::Interactive, false, false, false, false)
|
||||
.is_err(),
|
||||
session_restore_verdict(
|
||||
&config,
|
||||
LaunchKind::Interactive,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
)
|
||||
.is_err(),
|
||||
"a device applied after the window opens loses the race with a restored tab"
|
||||
);
|
||||
let off = WayfernConfig {
|
||||
|
||||
@@ -1155,6 +1155,7 @@ export default function Home() {
|
||||
ephemeral?: boolean;
|
||||
dnsBlocklist?: string;
|
||||
dnsServer?: string;
|
||||
startUrls?: string[];
|
||||
launchHook?: string;
|
||||
password?: string;
|
||||
}) => {
|
||||
@@ -1178,6 +1179,7 @@ export default function Home() {
|
||||
dnsBlocklist: profileData.dnsBlocklist,
|
||||
dnsServer: profileData.dnsServer,
|
||||
launchHook: profileData.launchHook,
|
||||
startUrls: profileData.startUrls,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -74,6 +75,7 @@ import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { getOSDisplayName, getOSIcon } from "@/lib/browser-utils";
|
||||
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import { startPagesFromText } from "@/lib/start-pages";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BrowserReleaseTypes, WayfernConfig, WayfernOS } from "@/types";
|
||||
|
||||
@@ -113,6 +115,7 @@ interface CreateProfileDialogProps {
|
||||
ephemeral?: boolean;
|
||||
dnsBlocklist?: string;
|
||||
dnsServer?: string;
|
||||
startUrls?: string[];
|
||||
launchHook?: string;
|
||||
password?: string;
|
||||
}) => Promise<void>;
|
||||
@@ -146,6 +149,7 @@ export function CreateProfileDialog({
|
||||
const [dnsBlocklist, setDnsBlocklist] = useState("");
|
||||
const [dnsServer, setDnsServer] = useState("");
|
||||
const [dnsServerValid, setDnsServerValid] = useState(true);
|
||||
const [startPages, setStartPages] = useState("");
|
||||
const [launchHook, setLaunchHook] = useState("");
|
||||
const [wayfernConfig, setWayfernConfig] =
|
||||
useState<WayfernConfig>(defaultWayfernConfig);
|
||||
@@ -306,6 +310,7 @@ export function CreateProfileDialog({
|
||||
setShowProxyForm(false);
|
||||
setDnsBlocklist("");
|
||||
setDnsServer("");
|
||||
setStartPages("");
|
||||
setLaunchHook("");
|
||||
setSelectedExtensionGroupId(undefined);
|
||||
setReleaseTypes({});
|
||||
@@ -351,6 +356,7 @@ export function CreateProfileDialog({
|
||||
ephemeral,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
dnsServer: dnsServer.trim() || undefined,
|
||||
startUrls: startPagesFromText(startPages),
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
password: enablePassword && !ephemeral ? password : undefined,
|
||||
});
|
||||
@@ -1006,6 +1012,28 @@ export function CreateProfileDialog({
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="profile-start-pages"
|
||||
className={FIELD_LABEL_CLASS}
|
||||
>
|
||||
{t("profileInfo.sections.startPages")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="profile-start-pages"
|
||||
variant="soft"
|
||||
value={startPages}
|
||||
onChange={(event) => setStartPages(event.target.value)}
|
||||
placeholder={t("startPages.placeholder")}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
disabled={isCreating}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("startPages.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4 pt-1">
|
||||
{creatableVersion && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
LuFingerprint,
|
||||
LuGlobe,
|
||||
LuGroup,
|
||||
LuHouse,
|
||||
LuKey,
|
||||
LuLink,
|
||||
LuLock,
|
||||
@@ -84,6 +85,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { LaunchArgumentsEditor } from "@/components/wayfern-config-form";
|
||||
import { WindowDragArea } from "@/components/window-drag-area";
|
||||
import { useInputModality } from "@/hooks/use-input-modality";
|
||||
@@ -94,6 +96,7 @@ import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import { rotateProxyIps } from "@/lib/proxy-rotation";
|
||||
import type { RemoteHandoffState } from "@/lib/remote-sessions";
|
||||
import { startPagesFromText } from "@/lib/start-pages";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
@@ -961,6 +964,7 @@ type ProfileSection =
|
||||
| "overview"
|
||||
| "fingerprint"
|
||||
| "network"
|
||||
| "startPages"
|
||||
| "cookies"
|
||||
| "extensions"
|
||||
| "sync"
|
||||
@@ -1081,6 +1085,14 @@ function ProfileInfoLayout({
|
||||
label: t("profileInfo.sections.network"),
|
||||
badge: profile.proxy_id || profile.vpn_id ? networkLabel : undefined,
|
||||
},
|
||||
{
|
||||
id: "startPages",
|
||||
icon: <LuHouse className="size-3.5" />,
|
||||
label: t("profileInfo.sections.startPages"),
|
||||
badge: profile.start_urls?.length
|
||||
? String(profile.start_urls.length)
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "cookies",
|
||||
icon: <LuCookie className="size-3.5" />,
|
||||
@@ -1393,6 +1405,10 @@ function ProfileInfoLayout({
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === "startPages" && (
|
||||
<StartPagesEditor profile={profile} isDisabled={isDisabled} t={t} />
|
||||
)}
|
||||
|
||||
{section === "cookies" &&
|
||||
(isEphemeral ? (
|
||||
<EphemeralSectionNotice
|
||||
@@ -1647,6 +1663,89 @@ function LaunchHookEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function StartPagesEditor({
|
||||
profile,
|
||||
isDisabled,
|
||||
t,
|
||||
}: {
|
||||
profile: BrowserProfile;
|
||||
isDisabled: boolean;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
const { t: tFn } = useTranslation();
|
||||
const initial = (profile.start_urls ?? []).join("\n");
|
||||
const [value, setValue] = React.useState(initial);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const dirty = value.trim() !== initial;
|
||||
|
||||
React.useEffect(() => {
|
||||
setValue(initial);
|
||||
}, [initial]);
|
||||
|
||||
const onSave = async () => {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await invoke<BrowserProfile>("update_profile_start_urls", {
|
||||
profileId: profile.id,
|
||||
startUrls: startPagesFromText(value),
|
||||
});
|
||||
setValue((saved.start_urls ?? []).join("\n"));
|
||||
} catch (e) {
|
||||
setError(translateBackendError(tFn, e));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<LuHouse className="size-4" />
|
||||
{t("profileInfo.sections.startPages")}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("startPages.description")}
|
||||
</p>
|
||||
<Textarea
|
||||
data-slot="profile-start-pages-input"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={t("startPages.placeholder")}
|
||||
rows={5}
|
||||
spellCheck={false}
|
||||
disabled={isDisabled || isSaving}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{error && <p className="text-xs text-destructive-text">{error}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={!dirty || isSaving || isDisabled}
|
||||
onClick={() => void onSave()}
|
||||
>
|
||||
{isSaving ? t("common.buttons.saving") : t("common.buttons.save")}
|
||||
</Button>
|
||||
{dirty && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
setValue(initial);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncSectionInline({
|
||||
profile,
|
||||
syncMode,
|
||||
|
||||
@@ -1511,7 +1511,8 @@
|
||||
"delete": "Profil löschen",
|
||||
"activity": "Aktivität",
|
||||
"launchHook": "Start-Hook",
|
||||
"transfer": "Übertragung"
|
||||
"transfer": "Übertragung",
|
||||
"startPages": "Startseiten"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Legen Sie fest, wie dieses Profil für Fingerprinting-Skripte erscheint.",
|
||||
@@ -1754,6 +1755,10 @@
|
||||
"deleteTitle": "„{{name}}“ löschen?",
|
||||
"deleteDescription": "Profile mit diesem Status haben danach keinen Status. Dies lässt sich nicht rückgängig machen."
|
||||
},
|
||||
"startPages": {
|
||||
"description": "Seiten, die jeder Start aus Donut öffnet, eine pro Zeile, statt der letzten Sitzung. Ein Lauf mit eigener URL öffnet nur diese URL.",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "VPN bearbeiten",
|
||||
@@ -2487,7 +2492,9 @@
|
||||
"proxyRotationUrlMissing": "Dieser Proxy hat keinen Link zum IP-Wechsel. Fügen Sie einen beim Bearbeiten des Proxys hinzu.",
|
||||
"proxyRotationFailed": "Der Anbieter hat den IP-Wechsel abgelehnt (HTTP {{status}}).",
|
||||
"proxyRotationTimeout": "Der Anbieter hat innerhalb von 30 Sekunden nicht auf den IP-Wechsel geantwortet.",
|
||||
"proxyRotationUnreachable": "Donut konnte den Link zum IP-Wechsel des Anbieters nicht erreichen."
|
||||
"proxyRotationUnreachable": "Donut konnte den Link zum IP-Wechsel des Anbieters nicht erreichen.",
|
||||
"startUrlInvalid": "„{{url}}“ ist keine Webseite. Startseiten müssen http://- oder https://-Adressen sein.",
|
||||
"startUrlsTooMany": "Ein Profil kann höchstens 20 Startseiten haben."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
|
||||
@@ -1511,7 +1511,8 @@
|
||||
"delete": "Delete profile",
|
||||
"activity": "Activity",
|
||||
"launchHook": "Launch hook",
|
||||
"transfer": "Transfer"
|
||||
"transfer": "Transfer",
|
||||
"startPages": "Start pages"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Configure how this profile appears to fingerprinting scripts.",
|
||||
@@ -1754,6 +1755,10 @@
|
||||
"deleteTitle": "Delete \"{{name}}\"?",
|
||||
"deleteDescription": "Profiles with this status will have no status. You cannot undo this."
|
||||
},
|
||||
"startPages": {
|
||||
"description": "Pages that every launch from Donut opens, one per line, instead of the last session. A run with its own URL opens only that URL.",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Edit VPN",
|
||||
@@ -2487,7 +2492,9 @@
|
||||
"proxyRotationUrlMissing": "This proxy has no change IP link. Add one when you edit the proxy.",
|
||||
"proxyRotationFailed": "The provider refused the IP change (HTTP {{status}}).",
|
||||
"proxyRotationTimeout": "The provider did not answer the IP change within 30 seconds.",
|
||||
"proxyRotationUnreachable": "Donut could not reach the provider's change IP link."
|
||||
"proxyRotationUnreachable": "Donut could not reach the provider's change IP link.",
|
||||
"startUrlInvalid": "\"{{url}}\" is not a web page. Start pages must be http:// or https:// addresses.",
|
||||
"startUrlsTooMany": "A profile can have at most 20 start pages."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
|
||||
@@ -1518,7 +1518,8 @@
|
||||
"delete": "Eliminar perfil",
|
||||
"activity": "Actividad",
|
||||
"launchHook": "Hook de inicio",
|
||||
"transfer": "Transferir"
|
||||
"transfer": "Transferir",
|
||||
"startPages": "Páginas de inicio"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Configura cómo aparece este perfil para los scripts de fingerprinting.",
|
||||
@@ -1761,6 +1762,10 @@
|
||||
"deleteTitle": "¿Eliminar «{{name}}»?",
|
||||
"deleteDescription": "Los perfiles con este estado quedarán sin estado. No se puede deshacer."
|
||||
},
|
||||
"startPages": {
|
||||
"description": "Páginas que abre cada inicio desde Donut, una por línea, en lugar de la última sesión. Una ejecución con su propia URL abre solo esa URL.",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Editar VPN",
|
||||
@@ -2498,7 +2503,9 @@
|
||||
"proxyRotationUrlMissing": "Este proxy no tiene enlace para cambiar IP. Añade uno al editar el proxy.",
|
||||
"proxyRotationFailed": "El proveedor rechazó el cambio de IP (HTTP {{status}}).",
|
||||
"proxyRotationTimeout": "El proveedor no respondió al cambio de IP en 30 segundos.",
|
||||
"proxyRotationUnreachable": "Donut no pudo acceder al enlace para cambiar IP del proveedor."
|
||||
"proxyRotationUnreachable": "Donut no pudo acceder al enlace para cambiar IP del proveedor.",
|
||||
"startUrlInvalid": "«{{url}}» no es una página web. Las páginas de inicio deben ser direcciones http:// o https://.",
|
||||
"startUrlsTooMany": "Un perfil puede tener como máximo 20 páginas de inicio."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfiles",
|
||||
|
||||
@@ -1518,7 +1518,8 @@
|
||||
"delete": "Supprimer le profil",
|
||||
"activity": "Activité",
|
||||
"launchHook": "Hook de lancement",
|
||||
"transfer": "Transfert"
|
||||
"transfer": "Transfert",
|
||||
"startPages": "Pages de démarrage"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Configurez l’apparence de ce profil pour les scripts de fingerprinting.",
|
||||
@@ -1761,6 +1762,10 @@
|
||||
"deleteTitle": "Supprimer « {{name}} » ?",
|
||||
"deleteDescription": "Les profils ayant ce statut n'auront plus de statut. Cette action est irréversible."
|
||||
},
|
||||
"startPages": {
|
||||
"description": "Pages que chaque lancement depuis Donut ouvre, une par ligne, à la place de la dernière session. Une exécution avec sa propre URL n'ouvre que cette URL.",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Modifier le VPN",
|
||||
@@ -2498,7 +2503,9 @@
|
||||
"proxyRotationUrlMissing": "Ce proxy n'a pas de lien de changement d'IP. Ajoutez-en un en modifiant le proxy.",
|
||||
"proxyRotationFailed": "Le fournisseur a refusé le changement d'IP (HTTP {{status}}).",
|
||||
"proxyRotationTimeout": "Le fournisseur n'a pas répondu au changement d'IP en 30 secondes.",
|
||||
"proxyRotationUnreachable": "Donut n'a pas pu joindre le lien de changement d'IP du fournisseur."
|
||||
"proxyRotationUnreachable": "Donut n'a pas pu joindre le lien de changement d'IP du fournisseur.",
|
||||
"startUrlInvalid": "« {{url}} » n'est pas une page web. Les pages de démarrage doivent être des adresses http:// ou https://.",
|
||||
"startUrlsTooMany": "Un profil peut avoir au plus 20 pages de démarrage."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
|
||||
@@ -1511,7 +1511,8 @@
|
||||
"delete": "プロファイルを削除",
|
||||
"activity": "アクティビティ",
|
||||
"launchHook": "起動フック",
|
||||
"transfer": "転送"
|
||||
"transfer": "転送",
|
||||
"startPages": "スタートページ"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "フィンガープリント対策スクリプトに対するこのプロファイルの表示を設定します。",
|
||||
@@ -1754,6 +1755,10 @@
|
||||
"deleteTitle": "「{{name}}」を削除しますか?",
|
||||
"deleteDescription": "このステータスのプロファイルはステータスなしになります。元に戻すことはできません。"
|
||||
},
|
||||
"startPages": {
|
||||
"description": "Donut から起動するたびに前回のセッションの代わりに開くページです。1 行に 1 つ入力します。独自の URL を指定した実行では、その URL だけが開きます。",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "VPN を編集",
|
||||
@@ -2487,7 +2492,9 @@
|
||||
"proxyRotationUrlMissing": "このプロキシには IP 変更リンクがありません。プロキシの編集時に追加してください。",
|
||||
"proxyRotationFailed": "プロバイダーが IP 変更を拒否しました (HTTP {{status}})。",
|
||||
"proxyRotationTimeout": "プロバイダーが 30 秒以内に IP 変更に応答しませんでした。",
|
||||
"proxyRotationUnreachable": "プロバイダーの IP 変更リンクに接続できませんでした。"
|
||||
"proxyRotationUnreachable": "プロバイダーの IP 変更リンクに接続できませんでした。",
|
||||
"startUrlInvalid": "「{{url}}」はウェブページではありません。スタートページは http:// または https:// のアドレスにしてください。",
|
||||
"startUrlsTooMany": "1 つのプロファイルに設定できるスタートページは 20 件までです。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "プロファイル",
|
||||
|
||||
@@ -1511,7 +1511,8 @@
|
||||
"delete": "프로필 삭제",
|
||||
"activity": "활동",
|
||||
"launchHook": "실행 후크",
|
||||
"transfer": "전송"
|
||||
"transfer": "전송",
|
||||
"startPages": "시작 페이지"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "이 프로필이 핑거프린팅 스크립트에 어떻게 표시되는지 구성합니다.",
|
||||
@@ -1754,6 +1755,10 @@
|
||||
"deleteTitle": "\"{{name}}\"을(를) 삭제할까요?",
|
||||
"deleteDescription": "이 상태의 프로필은 상태가 없어집니다. 되돌릴 수 없습니다."
|
||||
},
|
||||
"startPages": {
|
||||
"description": "Donut에서 실행할 때마다 마지막 세션 대신 여는 페이지입니다. 한 줄에 하나씩 입력하세요. 자체 URL이 있는 실행은 그 URL만 엽니다.",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "VPN 편집",
|
||||
@@ -2487,7 +2492,9 @@
|
||||
"proxyRotationUrlMissing": "이 프록시에는 IP 변경 링크가 없습니다. 프록시를 편집할 때 추가하세요.",
|
||||
"proxyRotationFailed": "공급자가 IP 변경을 거부했습니다 (HTTP {{status}}).",
|
||||
"proxyRotationTimeout": "공급자가 30초 안에 IP 변경에 응답하지 않았습니다.",
|
||||
"proxyRotationUnreachable": "공급자의 IP 변경 링크에 연결할 수 없습니다."
|
||||
"proxyRotationUnreachable": "공급자의 IP 변경 링크에 연결할 수 없습니다.",
|
||||
"startUrlInvalid": "\"{{url}}\"은(는) 웹 페이지가 아닙니다. 시작 페이지는 http:// 또는 https:// 주소여야 합니다.",
|
||||
"startUrlsTooMany": "프로필 하나에 시작 페이지는 최대 20개까지 가능합니다."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "프로필",
|
||||
|
||||
@@ -1518,7 +1518,8 @@
|
||||
"delete": "Excluir perfil",
|
||||
"activity": "Atividade",
|
||||
"launchHook": "Hook de inicialização",
|
||||
"transfer": "Transferência"
|
||||
"transfer": "Transferência",
|
||||
"startPages": "Páginas iniciais"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Configure como este perfil aparece para scripts de fingerprinting.",
|
||||
@@ -1761,6 +1762,10 @@
|
||||
"deleteTitle": "Excluir \"{{name}}\"?",
|
||||
"deleteDescription": "Os perfis com este status ficarão sem status. Não é possível desfazer."
|
||||
},
|
||||
"startPages": {
|
||||
"description": "Páginas que cada inicialização pelo Donut abre, uma por linha, em vez da última sessão. Uma execução com URL própria abre só essa URL.",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Editar VPN",
|
||||
@@ -2498,7 +2503,9 @@
|
||||
"proxyRotationUrlMissing": "Este proxy não tem link de troca de IP. Adicione um ao editar o proxy.",
|
||||
"proxyRotationFailed": "O provedor recusou a troca de IP (HTTP {{status}}).",
|
||||
"proxyRotationTimeout": "O provedor não respondeu à troca de IP em 30 segundos.",
|
||||
"proxyRotationUnreachable": "O Donut não conseguiu acessar o link de troca de IP do provedor."
|
||||
"proxyRotationUnreachable": "O Donut não conseguiu acessar o link de troca de IP do provedor.",
|
||||
"startUrlInvalid": "\"{{url}}\" não é uma página da web. As páginas iniciais precisam ser endereços http:// ou https://.",
|
||||
"startUrlsTooMany": "Um perfil pode ter no máximo 20 páginas iniciais."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfis",
|
||||
|
||||
@@ -1525,7 +1525,8 @@
|
||||
"delete": "Удалить профиль",
|
||||
"activity": "Активность",
|
||||
"launchHook": "Хук запуска",
|
||||
"transfer": "Перенос"
|
||||
"transfer": "Перенос",
|
||||
"startPages": "Стартовые страницы"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Настройте, как этот профиль выглядит для скриптов отпечатков.",
|
||||
@@ -1768,6 +1769,10 @@
|
||||
"deleteTitle": "Удалить «{{name}}»?",
|
||||
"deleteDescription": "Профили с этим статусом останутся без статуса. Это действие нельзя отменить."
|
||||
},
|
||||
"startPages": {
|
||||
"description": "Страницы, которые открывает каждый запуск из Donut вместо прошлой сессии, по одной на строку. Запуск со своим URL открывает только этот URL.",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Изменить VPN",
|
||||
@@ -2509,7 +2514,9 @@
|
||||
"proxyRotationUrlMissing": "У этого прокси нет ссылки для смены IP. Добавьте её при редактировании прокси.",
|
||||
"proxyRotationFailed": "Провайдер отклонил смену IP (HTTP {{status}}).",
|
||||
"proxyRotationTimeout": "Провайдер не ответил на смену IP за 30 секунд.",
|
||||
"proxyRotationUnreachable": "Donut не удалось открыть ссылку провайдера для смены IP."
|
||||
"proxyRotationUnreachable": "Donut не удалось открыть ссылку провайдера для смены IP.",
|
||||
"startUrlInvalid": "«{{url}}» — не веб-страница. Стартовые страницы должны быть адресами http:// или https://.",
|
||||
"startUrlsTooMany": "У профиля может быть не более 20 стартовых страниц."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Профили",
|
||||
|
||||
@@ -1511,7 +1511,8 @@
|
||||
"delete": "Profili sil",
|
||||
"activity": "Etkinlik",
|
||||
"launchHook": "Başlatma kancası",
|
||||
"transfer": "Aktarım"
|
||||
"transfer": "Aktarım",
|
||||
"startPages": "Başlangıç sayfaları"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Bu profilin parmak izi tarama betiklerine nasıl görüneceğini yapılandırın.",
|
||||
@@ -1754,6 +1755,10 @@
|
||||
"deleteTitle": "\"{{name}}\" silinsin mi?",
|
||||
"deleteDescription": "Bu durumdaki profillerin durumu kalmaz. Bu işlem geri alınamaz."
|
||||
},
|
||||
"startPages": {
|
||||
"description": "Donut'tan her başlatmada son oturum yerine açılan sayfalar, satır başına bir tane. Kendi URL'si olan bir çalıştırma yalnızca o URL'yi açar.",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "VPN'i Düzenle",
|
||||
@@ -2487,7 +2492,9 @@
|
||||
"proxyRotationUrlMissing": "Bu proxy'nin IP değiştirme bağlantısı yok. Proxy'yi düzenlerken ekleyin.",
|
||||
"proxyRotationFailed": "Sağlayıcı IP değişikliğini reddetti (HTTP {{status}}).",
|
||||
"proxyRotationTimeout": "Sağlayıcı IP değişikliğine 30 saniye içinde yanıt vermedi.",
|
||||
"proxyRotationUnreachable": "Donut, sağlayıcının IP değiştirme bağlantısına ulaşamadı."
|
||||
"proxyRotationUnreachable": "Donut, sağlayıcının IP değiştirme bağlantısına ulaşamadı.",
|
||||
"startUrlInvalid": "\"{{url}}\" bir web sayfası değil. Başlangıç sayfaları http:// veya https:// adresleri olmalıdır.",
|
||||
"startUrlsTooMany": "Bir profilde en fazla 20 başlangıç sayfası olabilir."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiller",
|
||||
|
||||
@@ -1511,7 +1511,8 @@
|
||||
"delete": "Xóa profile",
|
||||
"activity": "Hoạt động",
|
||||
"launchHook": "Hook khởi chạy",
|
||||
"transfer": "Chuyển"
|
||||
"transfer": "Chuyển",
|
||||
"startPages": "Trang khởi động"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Cấu hình cách profile này hiển thị với các script vân tay.",
|
||||
@@ -1754,6 +1755,10 @@
|
||||
"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."
|
||||
},
|
||||
"startPages": {
|
||||
"description": "Các trang mà mỗi lần khởi chạy từ Donut sẽ mở thay cho phiên trước, mỗi dòng một trang. Lần chạy có URL riêng chỉ mở URL đó.",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Chỉnh sửa VPN",
|
||||
@@ -2487,7 +2492,9 @@
|
||||
"proxyRotationUrlMissing": "Proxy này chưa có liên kết đổi IP. Hãy thêm khi chỉnh sửa proxy.",
|
||||
"proxyRotationFailed": "Nhà cung cấp từ chối đổi IP (HTTP {{status}}).",
|
||||
"proxyRotationTimeout": "Nhà cung cấp không phản hồi yêu cầu đổi IP trong 30 giây.",
|
||||
"proxyRotationUnreachable": "Donut không thể truy cập liên kết đổi IP của nhà cung cấp."
|
||||
"proxyRotationUnreachable": "Donut không thể truy cập liên kết đổi IP của nhà cung cấp.",
|
||||
"startUrlInvalid": "\"{{url}}\" không phải là trang web. Trang khởi động phải là địa chỉ http:// hoặc https://.",
|
||||
"startUrlsTooMany": "Mỗi profile có tối đa 20 trang khởi động."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
|
||||
@@ -1511,7 +1511,8 @@
|
||||
"delete": "删除配置文件",
|
||||
"activity": "活动",
|
||||
"launchHook": "启动钩子",
|
||||
"transfer": "迁移"
|
||||
"transfer": "迁移",
|
||||
"startPages": "启动页"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "配置此配置文件如何对指纹脚本显示。",
|
||||
@@ -1754,6 +1755,10 @@
|
||||
"deleteTitle": "删除“{{name}}”?",
|
||||
"deleteDescription": "具有此状态的配置文件将不再有状态。此操作无法撤销。"
|
||||
},
|
||||
"startPages": {
|
||||
"description": "每次从 Donut 启动时打开的页面,每行一个,代替上次会话。带有自身 URL 的运行只打开该 URL。",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "编辑 VPN",
|
||||
@@ -2487,7 +2492,9 @@
|
||||
"proxyRotationUrlMissing": "此代理没有更换 IP 链接。请在编辑代理时添加。",
|
||||
"proxyRotationFailed": "服务商拒绝了更换 IP(HTTP {{status}})。",
|
||||
"proxyRotationTimeout": "服务商在 30 秒内未响应更换 IP。",
|
||||
"proxyRotationUnreachable": "Donut 无法访问服务商的更换 IP 链接。"
|
||||
"proxyRotationUnreachable": "Donut 无法访问服务商的更换 IP 链接。",
|
||||
"startUrlInvalid": "“{{url}}”不是网页。启动页必须是 http:// 或 https:// 地址。",
|
||||
"startUrlsTooMany": "一个配置文件最多可以有 20 个启动页。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "配置文件",
|
||||
|
||||
@@ -35,6 +35,8 @@ export type BackendErrorCode =
|
||||
| "PROXY_ROTATION_TIMEOUT"
|
||||
| "PROXY_ROTATION_UNREACHABLE"
|
||||
| "PROXY_ROTATION_FAILED"
|
||||
| "START_URL_INVALID"
|
||||
| "START_URLS_TOO_MANY"
|
||||
| "COOKIE_DB_LOCKED"
|
||||
| "COOKIE_DB_UNAVAILABLE"
|
||||
| "COOKIE_IMPORT_BROWSER_RUNNING"
|
||||
@@ -393,6 +395,12 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.proxyRotationFailed", {
|
||||
status: parsed.params?.status ?? "",
|
||||
});
|
||||
case "START_URL_INVALID":
|
||||
return t("backendErrors.startUrlInvalid", {
|
||||
url: parsed.params?.url ?? "",
|
||||
});
|
||||
case "START_URLS_TOO_MANY":
|
||||
return t("backendErrors.startUrlsTooMany");
|
||||
case "COOKIE_DB_LOCKED":
|
||||
return t("backendErrors.cookieDbLocked");
|
||||
case "COOKIE_DB_UNAVAILABLE":
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/** One page per line, as the start-pages fields show and take them. */
|
||||
export function startPagesFromText(text: string): string[] {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
Reference in new issue
Block a user