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
+50 -5
View File
@@ -52,29 +52,70 @@
? Math.max(0, requestedWebviewCount)
: 2;
const webviews = [];
const profileAttachPromises = [];
const activeTokens = new Set();
let nextTokenId = 0;
let permanentParkingState = params.get("permanentParkingState") || "";
function appendHarnessWebview(sourceUrl) {
function createHarnessWebview({ sourceUrl, browserId, partition }) {
const index = webviews.length;
const webview = document.createElement("webview");
webview.id = `target-webview-${index + 1}`;
webview.className = "capture-harness-webview";
webview.setAttribute("data-paseo-browser-id", `capture-harness-${index + 1}`);
webview.setAttribute("partition", `persist:paseo-capture-harness-${index + 1}`);
webview.setAttribute("data-paseo-browser-id", browserId);
webview.setAttribute("partition", partition);
webview.setAttribute("allowpopups", "true");
webview.setAttribute("spellcheck", "false");
webview.setAttribute("autosize", "on");
webview.src = sourceUrl;
applyStackedWebviewStyle(webview);
return webview;
}
function appendHarnessWebview(sourceUrl) {
const index = webviews.length;
const webview = createHarnessWebview({
sourceUrl,
browserId: `capture-harness-${index + 1}`,
partition: `persist:paseo-capture-harness-${index + 1}`,
});
host.appendChild(webview);
webviews.push(webview);
return index;
}
for (let index = 0; index < webviewCount; index += 1) {
appendHarnessWebview(params.get("targetUrl") || "bright.html");
function appendProfileHarnessWebview({ browserId, partition, sourceUrl }) {
const webview = createHarnessWebview({ browserId, partition, sourceUrl });
const attached = new Promise((resolve) => {
webview.addEventListener(
"did-attach",
() => resolve({ browserId, webContentsId: webview.getWebContentsId() }),
{ once: true },
);
});
host.appendChild(webview);
webviews.push(webview);
profileAttachPromises.push(attached);
return attached;
}
const profilePartition = params.get("profilePartition");
const profileBrowserIds = (params.get("profileBrowserIds") || "").split(",").filter(Boolean);
let profileAttachSequence = Promise.resolve();
if (profilePartition && profileBrowserIds.length > 0) {
profileAttachSequence = (async () => {
for (const browserId of profileBrowserIds) {
await appendProfileHarnessWebview({
browserId,
partition: profilePartition,
sourceUrl: params.get("targetUrl") || "bright.html",
});
}
})();
} else {
for (let index = 0; index < webviewCount; index += 1) {
appendHarnessWebview(params.get("targetUrl") || "bright.html");
}
}
function applyHostParking() {
@@ -318,6 +359,10 @@
addWebview(sourceUrl) {
return appendHarnessWebview(sourceUrl || params.get("targetUrl") || "bright.html");
},
async profileIdentities() {
await profileAttachSequence;
return await Promise.all(profileAttachPromises);
},
addPermanentWebview(sourceUrl, stateName) {
const index = appendHarnessWebview(sourceUrl || params.get("targetUrl") || "bright.html");
applyPermanentParkingState(stateName || permanentParkingState);
+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(
+10 -1
View File
@@ -3,5 +3,14 @@ set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
ELECTRON="$REPO_ROOT/node_modules/.bin/electron"
exec "$REPO_ROOT/node_modules/.bin/electron" "$SCRIPT_DIR/main.js"
if [ "${PASEO_CAPTURE_HARNESS_GROUP:-}" = "browser-profile" ] && [ -z "${PASEO_CAPTURE_HARNESS_PHASE:-}" ]; then
PASEO_CAPTURE_HARNESS_PHASE=write "$ELECTRON" "$SCRIPT_DIR/main.js"
# Give Chromium's profile helpers time to release the persistent session before reopening it.
sleep 1
PASEO_CAPTURE_HARNESS_PHASE=read "$ELECTRON" "$SCRIPT_DIR/main.js"
exit
fi
exec "$ELECTRON" "$SCRIPT_DIR/main.js"