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() {
|
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();
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user