fix(1.0.11): Google login — OAuth popup in clean partition
Series 1.0.1 — 1.0.10 chased wrong hypotheses. Real causes of Google's
/v3/signin/rejected detector on Electron:
- WebContentsView embedded view shape
- Explicit session.setProxy({proxyRules}) — Chromium routes CONNECT
differently than through system proxy
- Cleaned UA paired with Sec-CH-UA="Chromium" — read as dishonesty
- DOM injections (chrome.* spoof, fade-overlay)
Fix:
- accounts.google.com opens in top-level BrowserWindow popup
- Dedicated persist:google-login partition with no setProxy/setUserAgent
(inherits Windows system proxy + default UA with honest Electron tag)
- After login, .google.com/.youtube.com cookies migrate to parent session,
parent view reloads to pick up logged-in state
- Removed global UA cleanup — all sessions use default UA now
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
156
main.js
156
main.js
@@ -214,7 +214,8 @@ let pendingNavigate = null; // { view, url } — cross-domain redirect awaiting
|
||||
function getProxySession() {
|
||||
if (!proxySession) {
|
||||
proxySession = session.fromPartition('persist:proxy');
|
||||
proxySession.setUserAgent(app.userAgentFallback);
|
||||
// No setUserAgent — partition uses app.userAgentFallback by default, and
|
||||
// calling setUserAgent appears to be a fingerprint signal Google reads.
|
||||
enableBlockingInSession(proxySession);
|
||||
}
|
||||
return proxySession;
|
||||
@@ -223,7 +224,6 @@ function getProxySession() {
|
||||
function getDirectSession() {
|
||||
if (!directSession) {
|
||||
directSession = session.fromPartition('persist:direct');
|
||||
directSession.setUserAgent(app.userAgentFallback);
|
||||
directSession.setProxy({ proxyRules: 'direct://' });
|
||||
enableBlockingInSession(directSession);
|
||||
}
|
||||
@@ -629,6 +629,111 @@ async function restoreSession() {
|
||||
}
|
||||
}
|
||||
|
||||
// Google's anti-abuse JS at /v3/signin/_/AccountsSignInUi/browserinfo rejects
|
||||
// any session that has an *explicit* setProxy({ proxyRules }) applied — the way
|
||||
// Chromium routes through such a proxy (explicit CONNECT etc.) is fingerprintable.
|
||||
// Sessions inheriting Windows' system proxy pass cleanly. So we open the OAuth
|
||||
// flow in a dedicated partition that we never call setProxy() on, then copy the
|
||||
// resulting .google.com / .youtube.com cookies into the parent view's session
|
||||
// so the user appears logged-in there too.
|
||||
//
|
||||
// The popup is a top-level BrowserWindow (not WebContentsView) — embedded view
|
||||
// shape is also a signal Google reads. No preload, no chrome.* spoof, no fade
|
||||
// overlay: anything we touch on the DOM/globals could trip the detector.
|
||||
const LOGIN_PARTITION = 'persist:google-login';
|
||||
|
||||
function isGoogleLoginUrl(u) {
|
||||
try {
|
||||
const h = new URL(u).hostname;
|
||||
return h === 'accounts.google.com' || h.endsWith('.accounts.google.com');
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
async function migrateGoogleCookies(fromSess, toSess) {
|
||||
// Copy .google.com and .youtube.com cookies so the parent view sees the
|
||||
// just-established login session. Domains must include both bare and
|
||||
// dot-prefixed so subdomain cookies are picked up.
|
||||
const domains = ['.google.com', 'accounts.google.com', '.youtube.com', 'www.youtube.com', '.googleusercontent.com'];
|
||||
for (const domain of domains) {
|
||||
let cookies = [];
|
||||
try { cookies = await fromSess.cookies.get({ domain }); } catch (_) { continue; }
|
||||
for (const c of cookies) {
|
||||
if (c.expirationDate && c.expirationDate * 1000 < Date.now()) continue;
|
||||
const hostOnly = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain;
|
||||
const url = `${c.secure ? 'https' : 'http'}://${hostOnly}${c.path}`;
|
||||
const opts = {
|
||||
url, name: c.name, value: c.value, path: c.path,
|
||||
secure: !!c.secure, httpOnly: !!c.httpOnly,
|
||||
};
|
||||
if (c.domain.startsWith('.')) opts.domain = c.domain;
|
||||
if (c.expirationDate) opts.expirationDate = c.expirationDate;
|
||||
if (c.sameSite && c.sameSite !== 'unspecified') opts.sameSite = c.sameSite;
|
||||
try { await toSess.cookies.set(opts); }
|
||||
catch (e) { console.warn('[oauth] copy cookie failed:', c.name, e.message); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeLoginPopups = new Set();
|
||||
|
||||
function openLoginPopup(parentView, url) {
|
||||
for (const p of activeLoginPopups) {
|
||||
if (p.parentView === parentView && !p.window.isDestroyed()) {
|
||||
p.window.focus();
|
||||
p.window.webContents.loadURL(url);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Dedicated login session — NEVER called setProxy() on, so Chromium uses Windows
|
||||
// system proxy (e.g. Clash) the way a real Chrome would.
|
||||
const loginSess = session.fromPartition(LOGIN_PARTITION);
|
||||
const popup = new BrowserWindow({
|
||||
width: 600,
|
||||
height: 750,
|
||||
title: 'Вход в Google',
|
||||
parent: mainWindow,
|
||||
modal: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
session: loginSess,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
// explicitly NO preload, NO injected UA
|
||||
},
|
||||
});
|
||||
attachDevToolsShortcut(popup.webContents);
|
||||
const entry = { window: popup, parentView };
|
||||
activeLoginPopups.add(entry);
|
||||
|
||||
let finalizing = false;
|
||||
const finishLogin = async (newUrl) => {
|
||||
if (finalizing) return;
|
||||
finalizing = true;
|
||||
try {
|
||||
await migrateGoogleCookies(loginSess, parentView.webContents.session);
|
||||
if (!parentView.webContents.isDestroyed()) parentView.webContents.reload();
|
||||
} finally {
|
||||
if (!popup.isDestroyed()) popup.close();
|
||||
}
|
||||
};
|
||||
|
||||
const checkRedirect = (newUrl) => {
|
||||
if (!newUrl || isGoogleLoginUrl(newUrl)) return;
|
||||
try {
|
||||
const h = new URL(newUrl).hostname;
|
||||
// Login flow handed control back to a non-Google host (youtube.com etc.) → success.
|
||||
if (h && !h.endsWith('.google.com') && h !== 'google.com') {
|
||||
finishLogin(newUrl);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
popup.webContents.on('will-redirect', (_e, u) => checkRedirect(u));
|
||||
popup.webContents.on('did-navigate', (_e, u) => checkRedirect(u));
|
||||
|
||||
popup.on('closed', () => { activeLoginPopups.delete(entry); });
|
||||
popup.loadURL(url);
|
||||
}
|
||||
|
||||
// --- IPC ---
|
||||
|
||||
ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) => {
|
||||
@@ -663,13 +768,6 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
|
||||
view.setBounds(getViewBounds());
|
||||
attachDevToolsShortcut(view.webContents);
|
||||
|
||||
// Experimental: spoof chrome.* JS objects on every page so Google's
|
||||
// "embedded browser" detector sees a real-Chrome-shaped global. Runs on
|
||||
// dom-ready which is AFTER <head> scripts, so detection scripts that ran
|
||||
// there have already seen the un-spoofed environment — this fix only
|
||||
// helps if Google's gate is re-checked on form submit / later events.
|
||||
// TLS fingerprint (JA3) is server-side and unaffected; if Google flags us
|
||||
// there, no client-side spoof helps. Best-effort attempt only.
|
||||
view.webContents.on('dom-ready', () => {
|
||||
view.webContents.executeJavaScript(CHROME_SPOOF_JS).catch(() => {});
|
||||
});
|
||||
@@ -714,6 +812,8 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
|
||||
|
||||
view.webContents.on('will-navigate', (e, newUrl) => {
|
||||
if (newUrl.startsWith('data:')) { trackNavigation(newUrl); return; }
|
||||
// accounts.google.com → top-level BrowserWindow popup (see openLoginPopup).
|
||||
if (isGoogleLoginUrl(newUrl)) { e.preventDefault(); openLoginPopup(view, newUrl); return; }
|
||||
let newHostname = '';
|
||||
try { newHostname = new URL(newUrl).hostname; } catch (_) { trackNavigation(newUrl); return; }
|
||||
if (origHostname && newHostname && newHostname !== origHostname && !isTrustedDomain(newHostname)) {
|
||||
@@ -724,17 +824,18 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
|
||||
}
|
||||
trackNavigation(newUrl);
|
||||
});
|
||||
view.webContents.on('will-redirect', (_e, u) => trackNavigation(u));
|
||||
view.webContents.on('will-redirect', (e, u) => {
|
||||
if (isGoogleLoginUrl(u)) { e.preventDefault(); openLoginPopup(view, u); return; }
|
||||
trackNavigation(u);
|
||||
});
|
||||
view.webContents.setWindowOpenHandler(({ url: newUrl }) => {
|
||||
// accounts.google.com → top-level BrowserWindow popup (see openLoginPopup).
|
||||
if (isGoogleLoginUrl(newUrl)) { openLoginPopup(view, newUrl); return { action: 'deny' }; }
|
||||
|
||||
let newHostname = '';
|
||||
try { newHostname = new URL(newUrl).hostname; } catch (_) {}
|
||||
|
||||
// Trusted domain (Google, Yandex, etc.) → navigate IN-PLACE, no popup.
|
||||
// 1.0.1 tried opening a real popup BrowserWindow here for OAuth postMessage
|
||||
// flows — turns out Google specifically detects popup-style embedded
|
||||
// browsers and blocks OAuth ("Возможно, этот браузер небезопасны").
|
||||
// YouTube-style login uses standard redirect flow, so in-place navigation
|
||||
// works AND avoids the popup fingerprint. 1.0.0 behavior, restored.
|
||||
// Trusted domain → navigate IN-PLACE. (Google login is handled above as popup.)
|
||||
if (newHostname && isTrustedDomain(newHostname)) {
|
||||
trackNavigation(newUrl);
|
||||
view.webContents.loadURL(newUrl);
|
||||
@@ -1286,18 +1387,16 @@ ipcMain.on('action', (_event, action) => {
|
||||
// --- App lifecycle ---
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Strip Electron/app tokens from User-Agent: Google blocks Electron's default UA
|
||||
// with "Поддержка JavaScript отключена" on accounts.google.com. We keep the Chrome
|
||||
// version Electron advertises (sufficient for modern features) but remove the
|
||||
// Electron/X.X.X and ESH-Media/X.X.X identifiers.
|
||||
const cleanUserAgent = app.userAgentFallback
|
||||
.replace(/Electron\/[\d.]+\s*/g, '')
|
||||
.replace(/ESH-Media\/[\d.]+\s*/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
console.log('[ua]', cleanUserAgent);
|
||||
app.userAgentFallback = cleanUserAgent;
|
||||
session.defaultSession.setUserAgent(cleanUserAgent);
|
||||
// UA: KEEP the default Electron-tagged UA. We previously stripped Electron/X.X.X
|
||||
// and ESH-Media/X.X.X (v1.0.2 fix) — but Google's anti-abuse JS at /browserinfo
|
||||
// treats an *honest* Electron UA as a legitimate non-Chrome client and lets the
|
||||
// login through. A spoofed-Chrome UA paired with Sec-CH-UA="Chromium" (no
|
||||
// "Google Chrome" brand, since Electron compiles without it) reads as
|
||||
// dishonesty → "Возможно, этот браузер небезопасны". Also, calling
|
||||
// setUserAgent() on a custom partition appears to be its own fingerprint signal
|
||||
// (bw-system.js test passes without it; bw-fresh.js with the same UA value but
|
||||
// explicit setUserAgent does not). So we do nothing here.
|
||||
console.log('[ua] (unchanged)', app.userAgentFallback);
|
||||
|
||||
// Add Referer to image requests so hotlink protection doesn't block them.
|
||||
// (Sec-CH-UA spoofing was tried in 1.0.4 and caused white pages — reverted.
|
||||
@@ -1333,6 +1432,7 @@ app.whenReady().then(async () => {
|
||||
enableBlockingInSession(session.defaultSession);
|
||||
getProxySession();
|
||||
getDirectSession();
|
||||
|
||||
await loadExtensions();
|
||||
await createWindow();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user