diff --git a/main.js b/main.js index f2af83d..c4b6217 100644 --- a/main.js +++ b/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 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(); diff --git a/package.json b/package.json index 9502ef6..f661737 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ESH-Media", - "version": "1.0.10", + "version": "1.0.11", "private": true, "main": "main.js", "scripts": {