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:
2026-05-17 00:36:49 +03:00
parent 103c2d1e09
commit 779d621dd1
2 changed files with 129 additions and 29 deletions

156
main.js
View File

@@ -214,7 +214,8 @@ let pendingNavigate = null; // { view, url } — cross-domain redirect awaiting
function getProxySession() { function getProxySession() {
if (!proxySession) { if (!proxySession) {
proxySession = session.fromPartition('persist:proxy'); 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); enableBlockingInSession(proxySession);
} }
return proxySession; return proxySession;
@@ -223,7 +224,6 @@ function getProxySession() {
function getDirectSession() { function getDirectSession() {
if (!directSession) { if (!directSession) {
directSession = session.fromPartition('persist:direct'); directSession = session.fromPartition('persist:direct');
directSession.setUserAgent(app.userAgentFallback);
directSession.setProxy({ proxyRules: 'direct://' }); directSession.setProxy({ proxyRules: 'direct://' });
enableBlockingInSession(directSession); 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 --- // --- IPC ---
ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) => { 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()); view.setBounds(getViewBounds());
attachDevToolsShortcut(view.webContents); 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.on('dom-ready', () => {
view.webContents.executeJavaScript(CHROME_SPOOF_JS).catch(() => {}); 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) => { view.webContents.on('will-navigate', (e, newUrl) => {
if (newUrl.startsWith('data:')) { trackNavigation(newUrl); return; } 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 = ''; let newHostname = '';
try { newHostname = new URL(newUrl).hostname; } catch (_) { trackNavigation(newUrl); return; } try { newHostname = new URL(newUrl).hostname; } catch (_) { trackNavigation(newUrl); return; }
if (origHostname && newHostname && newHostname !== origHostname && !isTrustedDomain(newHostname)) { if (origHostname && newHostname && newHostname !== origHostname && !isTrustedDomain(newHostname)) {
@@ -724,17 +824,18 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
} }
trackNavigation(newUrl); 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 }) => { 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 = ''; let newHostname = '';
try { newHostname = new URL(newUrl).hostname; } catch (_) {} try { newHostname = new URL(newUrl).hostname; } catch (_) {}
// Trusted domain (Google, Yandex, etc.) → navigate IN-PLACE, no popup. // Trusted domain → navigate IN-PLACE. (Google login is handled above as 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.
if (newHostname && isTrustedDomain(newHostname)) { if (newHostname && isTrustedDomain(newHostname)) {
trackNavigation(newUrl); trackNavigation(newUrl);
view.webContents.loadURL(newUrl); view.webContents.loadURL(newUrl);
@@ -1286,18 +1387,16 @@ ipcMain.on('action', (_event, action) => {
// --- App lifecycle --- // --- App lifecycle ---
app.whenReady().then(async () => { app.whenReady().then(async () => {
// Strip Electron/app tokens from User-Agent: Google blocks Electron's default UA // UA: KEEP the default Electron-tagged UA. We previously stripped Electron/X.X.X
// with "Поддержка JavaScript отключена" on accounts.google.com. We keep the Chrome // and ESH-Media/X.X.X (v1.0.2 fix) — but Google's anti-abuse JS at /browserinfo
// version Electron advertises (sufficient for modern features) but remove the // treats an *honest* Electron UA as a legitimate non-Chrome client and lets the
// Electron/X.X.X and ESH-Media/X.X.X identifiers. // login through. A spoofed-Chrome UA paired with Sec-CH-UA="Chromium" (no
const cleanUserAgent = app.userAgentFallback // "Google Chrome" brand, since Electron compiles without it) reads as
.replace(/Electron\/[\d.]+\s*/g, '') // dishonesty → "Возможно, этот браузер небезопасны". Also, calling
.replace(/ESH-Media\/[\d.]+\s*/g, '') // setUserAgent() on a custom partition appears to be its own fingerprint signal
.replace(/\s+/g, ' ') // (bw-system.js test passes without it; bw-fresh.js with the same UA value but
.trim(); // explicit setUserAgent does not). So we do nothing here.
console.log('[ua]', cleanUserAgent); console.log('[ua] (unchanged)', app.userAgentFallback);
app.userAgentFallback = cleanUserAgent;
session.defaultSession.setUserAgent(cleanUserAgent);
// Add Referer to image requests so hotlink protection doesn't block them. // 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. // (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); enableBlockingInSession(session.defaultSession);
getProxySession(); getProxySession();
getDirectSession(); getDirectSession();
await loadExtensions(); await loadExtensions();
await createWindow(); await createWindow();

View File

@@ -1,6 +1,6 @@
{ {
"name": "ESH-Media", "name": "ESH-Media",
"version": "1.0.10", "version": "1.0.11",
"private": true, "private": true,
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {