fix(1.0.12): generalize OAuth popup for all providers, skip passive flows

Two issues in 1.0.11:

  1. YouTube auto-opens window.open on accounts.google.com/...?passive=true
     at page load to silently pick up an existing Google session via
     postMessage. Our setWindowOpenHandler routed these to a top-level popup
     where the postMessage parent context is missing → Google falls back to
     "JavaScript отключен". Active "Войти" clicks don't carry passive=true,
     so they still need the popup.

  2. Only accounts.google.com was intercepted. Yandex, Mail.ru, Microsoft,
     VK, Apple and GitHub login pages run similar embedded-browser checks;
     in-place WebContentsView navigation to them would likely trip the same
     detectors.

Replaced isGoogleLoginUrl/migrateGoogleCookies with a provider table
(OAUTH_PROVIDERS by host, OAUTH_PATH_HOSTS by host+path-prefix) so any
known login domain routes through the same clean popup + cookie-migration
flow. passive=true URLs are filtered out so window.open auto-launches
silently fail instead of popping a broken popup.

LOGIN_PARTITION renamed persist:google-login → persist:oauth-login since
it now holds login state for all providers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 01:11:29 +03:00
parent 1c7bb75a05
commit 1030622e19
2 changed files with 82 additions and 31 deletions

111
main.js
View File

@@ -629,29 +629,75 @@ 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.
// Sessions inheriting Windows' system proxy pass cleanly. So we open OAuth flows
// in a dedicated partition that we never call setProxy() on, then copy the
// resulting auth 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';
// shape is also a signal anti-abuse engines read. No preload, no chrome.* spoof,
// no fade overlay: anything we touch on the DOM/globals could trip the detector.
//
// Yandex, Mail.ru, Microsoft Live, VK, Facebook, Apple, GitHub all run similar
// embedded-browser checks on their login pages (some more aggressive than
// Google's), so the popup is opened for every known OAuth provider host.
const LOGIN_PARTITION = 'persist:oauth-login';
function isGoogleLoginUrl(u) {
// Hostname → list of cookie-domain suffixes to migrate back to the parent
// session after a successful login. Migrating extra domains is harmless, so
// each provider lists everything the auth handshake might set cookies on.
const OAUTH_PROVIDERS = {
'accounts.google.com': ['.google.com', '.youtube.com', '.googleusercontent.com'],
'passport.yandex.ru': ['.yandex.ru', '.yandex.com', '.passport.yandex.ru'],
'passport.yandex.com': ['.yandex.ru', '.yandex.com', '.passport.yandex.com'],
'oauth.yandex.ru': ['.yandex.ru', '.yandex.com'],
'login.live.com': ['.live.com', '.microsoft.com', '.microsoftonline.com'],
'login.microsoftonline.com': ['.microsoftonline.com', '.microsoft.com', '.live.com'],
'login.microsoft.com': ['.microsoft.com', '.live.com'],
'auth.mail.ru': ['.mail.ru'],
'login.mail.ru': ['.mail.ru'],
'oauth.mail.ru': ['.mail.ru'],
'oauth.vk.com': ['.vk.com', '.vk.ru'],
'login.vk.com': ['.vk.com', '.vk.ru'],
'oauth.vk.ru': ['.vk.ru', '.vk.com'],
'login.vk.ru': ['.vk.ru', '.vk.com'],
'appleid.apple.com': ['.apple.com', '.icloud.com'],
'idmsa.apple.com': ['.apple.com', '.icloud.com'],
};
// Hosts where the login subpath signals an OAuth flow (the rest of the host
// is regular browsing). Listed separately because the bare host is not a
// login page, only specific paths are.
const OAUTH_PATH_HOSTS = {
'github.com': { prefix: '/login', cookies: ['.github.com', '.githubusercontent.com'] },
'www.facebook.com': { prefix: '/login', cookies: ['.facebook.com'] },
'm.facebook.com': { prefix: '/login', cookies: ['.facebook.com'] },
};
function oauthProviderFor(u) {
try {
const h = new URL(u).hostname;
return h === 'accounts.google.com' || h.endsWith('.accounts.google.com');
} catch (_) { return false; }
const url = new URL(u);
// YouTube and friends silently call window.open on accounts.google.com/
// ...?passive=true&... at page load to pick up an existing session via
// postMessage. That flow doesn't work in a top-level popup (no parent
// context → "JavaScript отключен" fallback). Skip popups for them; an
// active login click never has passive=true.
if (url.searchParams.get('passive') === 'true') return null;
if (OAUTH_PROVIDERS[url.hostname]) {
return { host: url.hostname, cookieDomains: OAUTH_PROVIDERS[url.hostname] };
}
const pathRule = OAUTH_PATH_HOSTS[url.hostname];
if (pathRule && url.pathname.startsWith(pathRule.prefix)) {
return { host: url.hostname, cookieDomains: pathRule.cookies };
}
return null;
} catch (_) { return null; }
}
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) {
function isOAuthLoginUrl(u) { return oauthProviderFor(u) !== null; }
async function migrateOAuthCookies(fromSess, toSess, cookieDomains) {
for (const domain of cookieDomains) {
let cookies = [];
try { cookies = await fromSess.cookies.get({ domain }); } catch (_) { continue; }
for (const c of cookies) {
@@ -674,6 +720,8 @@ async function migrateGoogleCookies(fromSess, toSess) {
const activeLoginPopups = new Set();
function openLoginPopup(parentView, url) {
const provider = oauthProviderFor(url);
if (!provider) return; // shouldn't be called for non-OAuth urls
for (const p of activeLoginPopups) {
if (p.parentView === parentView && !p.window.isDestroyed()) {
p.window.focus();
@@ -687,7 +735,7 @@ function openLoginPopup(parentView, url) {
const popup = new BrowserWindow({
width: 600,
height: 750,
title: 'Вход в Google',
title: 'Вход',
parent: mainWindow,
modal: false,
autoHideMenuBar: true,
@@ -703,11 +751,11 @@ function openLoginPopup(parentView, url) {
activeLoginPopups.add(entry);
let finalizing = false;
const finishLogin = async (newUrl) => {
const finishLogin = async () => {
if (finalizing) return;
finalizing = true;
try {
await migrateGoogleCookies(loginSess, parentView.webContents.session);
await migrateOAuthCookies(loginSess, parentView.webContents.session, provider.cookieDomains);
if (!parentView.webContents.isDestroyed()) parentView.webContents.reload();
} finally {
if (!popup.isDestroyed()) popup.close();
@@ -715,13 +763,16 @@ function openLoginPopup(parentView, url) {
};
const checkRedirect = (newUrl) => {
if (!newUrl || isGoogleLoginUrl(newUrl)) return;
if (!newUrl) return;
// Still on a login host (Google→Yandex→… cross-redirects are rare but allowed): stay.
if (isOAuthLoginUrl(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);
}
// Login flow handed control back to a non-login host (the service we
// came from, e.g. youtube.com) → success.
const loginHost = provider.host;
const isStillOnProviderApex = h === loginHost || h.endsWith('.' + loginHost.replace(/^[^.]+\./, ''));
if (!isStillOnProviderApex) finishLogin();
} catch (_) {}
};
popup.webContents.on('will-redirect', (_e, u) => checkRedirect(u));
@@ -814,8 +865,8 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy, b
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; }
// OAuth login URL → top-level BrowserWindow popup (see openLoginPopup).
if (isOAuthLoginUrl(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)) {
@@ -827,12 +878,12 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy, b
trackNavigation(newUrl);
});
view.webContents.on('will-redirect', (e, u) => {
if (isGoogleLoginUrl(u)) { e.preventDefault(); openLoginPopup(view, u); return; }
if (isOAuthLoginUrl(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' }; }
// OAuth login URL → top-level BrowserWindow popup (see openLoginPopup).
if (isOAuthLoginUrl(newUrl)) { openLoginPopup(view, newUrl); return { action: 'deny' }; }
let newHostname = '';
try { newHostname = new URL(newUrl).hostname; } catch (_) {}