From 4044c845c7915c158c4ab04609bedfa71022ca74 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:52:24 +0400 Subject: [PATCH] feat: profile start pages --- e2e/coverage-map.mjs | 1 + e2e/tests/browser.test.mjs | 101 +++++++++++++++ e2e/tests/entities.test.mjs | 40 ++++++ e2e/tests/ui.test.mjs | 35 +++++ src-tauri/src/api_server.rs | 51 ++++++++ src-tauri/src/lib.rs | 4 +- src-tauri/src/mcp_server.rs | 45 +++++++ src-tauri/src/profile/manager.rs | 110 +++++++++++++++- src-tauri/src/wayfern_manager.rs | 157 ++++++++++++++++++----- src/app/page.tsx | 2 + src/components/create-profile-dialog.tsx | 28 ++++ src/components/profile-info-dialog.tsx | 99 ++++++++++++++ src/i18n/locales/de.json | 11 +- src/i18n/locales/en.json | 11 +- src/i18n/locales/es.json | 11 +- src/i18n/locales/fr.json | 11 +- src/i18n/locales/ja.json | 11 +- src/i18n/locales/ko.json | 11 +- src/i18n/locales/pt.json | 11 +- src/i18n/locales/ru.json | 11 +- src/i18n/locales/tr.json | 11 +- src/i18n/locales/vi.json | 11 +- src/i18n/locales/zh.json | 11 +- src/lib/backend-errors.ts | 8 ++ src/lib/start-pages.ts | 7 + 25 files changed, 755 insertions(+), 54 deletions(-) create mode 100644 src/lib/start-pages.ts diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index b935d2ae..4bba576f 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -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", diff --git a/e2e/tests/browser.test.mjs b/e2e/tests/browser.test.mjs index ef437dba..a3252f5c 100644 --- a/e2e/tests/browser.test.mjs +++ b/e2e/tests/browser.test.mjs @@ -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", { diff --git a/e2e/tests/entities.test.mjs b/e2e/tests/entities.test.mjs index 1823c434..1a5f25d2 100644 --- a/e2e/tests/entities.test.mjs +++ b/e2e/tests/entities.test.mjs @@ -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: "", diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index d34e4116..98151506 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -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"); diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 1b60150f..2bab43c0 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -51,6 +51,9 @@ pub struct ApiProfile { pub dns_server: Option, /// The profile's status id (see `/v1/statuses`), or `null`. pub status_id: Option, + /// 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, /// 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, + /// Optional start pages, at most 20 http(s) URLs. A bare host gets `https://`. + #[serde(default)] + pub start_urls: Option>, } #[derive(Debug, Serialize, Deserialize, ToSchema)] @@ -206,6 +213,9 @@ pub struct UpdateProfileRequest { pub dns_server: Option, /// Omitted or `null` leaves the status unchanged; an empty string clears it. pub status_id: Option, + /// Replaces the start pages; an empty list removes them. Omitted or `null` + /// leaves them unchanged. + pub start_urls: Option>, } #[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"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8a8c74fc..ac50ea4f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 2f448fc5..0d5e4b06 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -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 diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index a0c1eae4..05464bbf 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -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, + ) -> Result> { + 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) -> Result, String> { + let urls: Vec = 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, +) -> Result { + 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, dns_server: Option, launch_hook: Option, + start_urls: Option>, ) -> Result { + // 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] diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index 5d6b5bc5..fdd7d9dd 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -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 { diff --git a/src/app/page.tsx b/src/app/page.tsx index ec590044..1fb4618e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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, }, ); diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index 40bf0ecb..c62d14b0 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -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; @@ -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(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} /> +
+ +