Keep browser sign-ins across tabs and restarts (#2089)

* feat(desktop): share persistent browser data across tabs

Keep browser sign-ins and site storage across tabs and app restarts, with a Settings action to clear the shared profile.

* fix(settings): prevent duplicate browser data clears

* fix(desktop): complete browser data cleanup

Clear legacy per-tab profiles after upgrades and localize the browser data settings in every supported language.

* fix(desktop): close browser profile cleanup races
This commit is contained in:
Mohamed Boudra
2026-07-16 00:40:24 +08:00
committed by GitHub
parent 9423b091b9
commit 328361667f
32 changed files with 1413 additions and 148 deletions
+245 -5
View File
@@ -1,7 +1,8 @@
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const http = require("node:http");
const path = require("node:path");
const { app, BrowserWindow, nativeImage, screen } = require("electron");
const { app, BrowserWindow, nativeImage, screen, session } = require("electron");
const ROOT = __dirname;
const OUT_DIR = process.env.PASEO_CAPTURE_HARNESS_OUT_DIR || path.join(ROOT, "out");
@@ -9,11 +10,15 @@ const VIEWPORT_WIDTH = 1280;
const VIEWPORT_HEIGHT = 800;
const FULL_PAGE_HEIGHT = 1600;
const CAPTURE_TIMEOUT_MS = 5000;
const BROWSER_PROFILE_TIMEOUT_MS = 15000;
const CAPTURE_RETRY_INTERVAL_MS = 200;
const REPEAT_COUNT = 5;
const FRESH_REPEAT_COUNT = 3;
const SOAK_MS = Number(process.env.PASEO_CAPTURE_HARNESS_SOAK_MS || 75000);
const HARNESS_GROUP = process.env.PASEO_CAPTURE_HARNESS_GROUP || "permanent-parking";
const BROWSER_PROFILE_PHASE = process.env.PASEO_CAPTURE_HARNESS_PHASE || "";
const BROWSER_PROFILE_ORIGIN_FILE = path.join(OUT_DIR, "browser-profile-origin.txt");
const BROWSER_PROFILE_VALUE_FILE = path.join(OUT_DIR, "browser-profile-value.txt");
const PERMANENT_STATE_FILTER = new Set(
(process.env.PASEO_CAPTURE_HARNESS_STATES || "P1")
.split(",")
@@ -121,6 +126,44 @@ function fileUrl(filePath, params = {}) {
return url.toString();
}
async function startBrowserProfileServer() {
let port = 0;
if (BROWSER_PROFILE_PHASE === "read") {
const previousOrigin = (await fsp.readFile(BROWSER_PROFILE_ORIGIN_FILE, "utf8")).trim();
port = Number(new URL(previousOrigin).port);
}
const server = http.createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end("<!doctype html><title>Shared browser profile</title><h1>Profile fixture</h1>");
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("browser profile fixture server has no TCP address");
}
const origin = `http://127.0.0.1:${address.port}`;
if (BROWSER_PROFILE_PHASE === "write") {
await fsp.writeFile(BROWSER_PROFILE_ORIGIN_FILE, `${origin}\n`);
}
return { origin, server };
}
async function closeServer(server) {
await new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
function ensureDirSync(dir) {
fs.mkdirSync(dir, { recursive: true });
}
@@ -160,12 +203,12 @@ async function waitForInactiveReveal(handle, label) {
await delay(250);
}
function withTimeout(promise, label) {
function withTimeout(promise, label, timeoutMs = CAPTURE_TIMEOUT_MS) {
let timeoutId;
const timeout = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`${label} timed out after ${CAPTURE_TIMEOUT_MS}ms`));
}, CAPTURE_TIMEOUT_MS);
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
clearTimeout(timeoutId);
@@ -434,6 +477,7 @@ function installHarnessWebviewGuards(win) {
function trackAttachedGuests(win, input = {}) {
const attachedGuests = [];
const waiters = [];
const countWaiters = [];
win.webContents.on("did-attach-webview", (_event, contents) => {
if (input.disableGuestBackgroundThrottlingAtAttach) {
contents.setBackgroundThrottling(false);
@@ -443,6 +487,13 @@ function trackAttachedGuests(win, input = {}) {
if (waiter) {
waiter(contents);
}
for (let index = countWaiters.length - 1; index >= 0; index -= 1) {
const countWaiter = countWaiters[index];
if (attachedGuests.length >= countWaiter.count) {
countWaiters.splice(index, 1);
countWaiter.resolve(attachedGuests.slice(0, countWaiter.count));
}
}
});
return {
attachedGuests,
@@ -451,6 +502,14 @@ function trackAttachedGuests(win, input = {}) {
waiters.push(resolve);
});
},
waitForAttachedGuests(count) {
if (attachedGuests.length >= count) {
return Promise.resolve(attachedGuests.slice(0, count));
}
return new Promise((resolve) => {
countWaiters.push({ count, resolve });
});
},
};
}
@@ -1889,12 +1948,193 @@ function assertAutomationSnapshot(snapshot) {
}
}
async function createBrowserProfileHarnessWindow(partition, sourceUrl) {
const handle = createInactiveHarnessWindow({
width: 640,
height: 480,
backgroundColor: "#202020",
webPreferences: {
webviewTag: true,
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
const { win } = handle;
installHarnessWebviewGuards(win);
const tracker = trackAttachedGuests(win);
const guestsPromise = tracker.waitForAttachedGuests(2);
await withTimeout(
win.loadFile(path.join(ROOT, "index.html"), {
query: {
webviewCount: "2",
targetUrl: sourceUrl,
profilePartition: partition,
profileBrowserIds: "browser-first,browser-second",
},
}),
"browser profile window loadFile",
);
await waitForInactiveReveal(handle, "browser profile window");
const [guests, identities] = await withTimeout(
Promise.all([guestsPromise, renderer(win, "window.captureHarness.profileIdentities()")]),
"browser profile did-attach",
BROWSER_PROFILE_TIMEOUT_MS,
);
return { handle, guests, identities };
}
async function readBrowserProfileFixture(guest) {
return await guest.executeJavaScript(`({
cookie: document.cookie,
localStorage: localStorage.getItem("paseo-browser-profile")
})`);
}
function assertBrowserProfileFixture(state, expectedValue, label) {
if (state.localStorage !== expectedValue) {
fail(`${label} localStorage mismatch ${JSON.stringify(state)}`);
}
if (!state.cookie.split("; ").includes(`paseo-browser-profile=${expectedValue}`)) {
fail(`${label} cookie mismatch ${JSON.stringify(state)}`);
}
}
function resolveBrowserProfileGuests(profileWindow, profileSession) {
if (profileWindow.identities.length !== 2 || profileWindow.guests.length !== 2) {
fail("browser profile harness did not attach exactly two guests");
}
const guestsById = new Map(profileWindow.guests.map((guest) => [guest.id, guest]));
const [firstIdentity, secondIdentity] = profileWindow.identities;
const firstGuest = guestsById.get(firstIdentity.webContentsId);
const secondGuest = guestsById.get(secondIdentity.webContentsId);
if (!firstGuest || !secondGuest) {
fail("browser profile renderer identities did not map to attached main-process guests");
}
if (
firstIdentity.browserId !== "browser-first" ||
firstIdentity.webContentsId !== firstGuest.id
) {
fail(
`browser profile first attach mismatch ${JSON.stringify(firstIdentity)} main=${firstGuest.id}`,
);
}
if (
secondIdentity.browserId !== "browser-second" ||
secondIdentity.webContentsId !== secondGuest.id
) {
fail(
`browser profile second attach mismatch ${JSON.stringify(secondIdentity)} main=${secondGuest.id}`,
);
}
if (
firstGuest.hostWebContents !== profileWindow.handle.win.webContents ||
secondGuest.hostWebContents !== profileWindow.handle.win.webContents
) {
fail("browser profile guests were not owned by their renderer");
}
if (firstGuest.session !== profileSession || secondGuest.session !== profileSession) {
fail("browser profile guests did not share the persistent session");
}
return [firstGuest, secondGuest];
}
async function prepareBrowserProfileValue(firstGuest, profileSession) {
if (BROWSER_PROFILE_PHASE === "read") {
return (await fsp.readFile(BROWSER_PROFILE_VALUE_FILE, "utf8")).trim();
}
const profileValue = `profile-${Date.now()}-${process.pid}`;
await firstGuest.executeJavaScript(`(() => {
const value = ${JSON.stringify(profileValue)};
localStorage.setItem("paseo-browser-profile", value);
document.cookie = "paseo-browser-profile=" + value + "; Max-Age=86400; SameSite=Lax";
})()`);
if (BROWSER_PROFILE_PHASE === "write") {
await fsp.writeFile(BROWSER_PROFILE_VALUE_FILE, `${profileValue}\n`);
await profileSession.cookies.flushStore();
}
return profileValue;
}
async function runBrowserProfileGroup() {
if (!["write", "read"].includes(BROWSER_PROFILE_PHASE)) {
fail(`unknown browser profile phase ${BROWSER_PROFILE_PHASE}`);
}
const partition = "persist:paseo-browser-profile-harness-restart";
const profileSession = session.fromPartition(partition);
const fixture = await startBrowserProfileServer();
const windows = [];
try {
if (BROWSER_PROFILE_PHASE === "write") {
await profileSession.clearStorageData();
await profileSession.clearCache();
}
const profileWindow = await createBrowserProfileHarnessWindow(partition, fixture.origin);
windows.push(profileWindow.handle);
const [firstGuest, secondGuest] = resolveBrowserProfileGuests(profileWindow, profileSession);
await Promise.all([waitForGuestLoad(firstGuest), waitForGuestLoad(secondGuest)]);
const profileValue = await prepareBrowserProfileValue(firstGuest, profileSession);
const firstState = await readBrowserProfileFixture(firstGuest);
const secondState = await readBrowserProfileFixture(secondGuest);
assertBrowserProfileFixture(firstState, profileValue, "browser profile first tab");
assertBrowserProfileFixture(secondState, profileValue, "browser profile second tab");
pass("browser profile renderer did-attach identities match their main-process guests");
pass("browser profile tabs share cookies, localStorage, and one persistent session");
if (BROWSER_PROFILE_PHASE === "read") {
pass("browser profile cookies and localStorage survived an Electron process restart");
}
const results = [
{ group: "browser-profile", check: "renderer-main-identity", pass: true },
{ group: "browser-profile", check: "shared-profile-data", pass: true },
];
if (BROWSER_PROFILE_PHASE === "read") {
results.push({
group: "browser-profile",
check: "process-restart-persistence",
pass: true,
});
}
return results;
} finally {
for (const handle of windows) {
await closeHarnessWindow(handle.win);
}
if (BROWSER_PROFILE_PHASE !== "write") {
await profileSession.clearStorageData();
await profileSession.clearCache();
}
await closeServer(fixture.server);
}
}
async function main() {
ensureDirSync(OUT_DIR);
if (!["all", "existing", "permanent-parking", "automation"].includes(HARNESS_GROUP)) {
if (
!["all", "existing", "permanent-parking", "automation", "browser-profile"].includes(
HARNESS_GROUP,
)
) {
fail(`unknown harness group ${HARNESS_GROUP}`);
}
if (HARNESS_GROUP === "browser-profile") {
const browserProfileResults = await runBrowserProfileGroup();
await fsp.writeFile(
path.join(OUT_DIR, "results.json"),
`${JSON.stringify(
{ generatedAt: new Date().toISOString(), browserProfileResults },
null,
2,
)}\n`,
);
pass(`capture harness browser-profile complete output=${OUT_DIR}`);
return;
}
if (HARNESS_GROUP === "automation") {
const automationResults = await runAutomationGroup();
await fsp.writeFile(