Files
ESH-Media/main.js
eshmeshek 1c7bb75a05 ESH-Media v1.0.11 — kiosk media browser for elderly users
Electron-based kiosk desktop app: large-tile launcher for YouTube, RuTube,
movie sites and Google services, designed for low-tech grandparent use.

Features:
  - WebContentsView-per-app tabbed browsing with session persistence
  - per-app proxy routing (Clash/V2Ray friendly, useProxy flag)
  - cliqz-electron adblocker with whitelist for OAuth/integrity domains
  - TMDB-backed movie search across kinogo / hdrezka / filmix
  - bookmark posters auto-fetched from og:image / JSON-LD
  - electron-updater wired to Gitea releases API (latest.yml + .blockmap)
  - cross-domain navigation confirms via custom WebContentsView dialogs
  - kiosk window with hidden menu, Ctrl+Shift+I devtools shortcut
  - Trusted Types disabled engine-wide so adblocker scriptlets work on YouTube

Google OAuth handling (the hard-won part):
  Google's anti-abuse JS rejects WebContentsView + custom session settings
  as "embedded browser". So accounts.google.com opens in a top-level
  BrowserWindow popup in a dedicated persist:google-login partition that
  we never call setProxy/setUserAgent on — it inherits Windows system
  proxy and the default Electron-tagged UA, both of which Google accepts.
  After login, .google.com/.youtube.com cookies migrate into the parent
  view's session and the view reloads to pick up the logged-in state.

Session restore: only the last-active tab attaches to the window; other
tabs load silently in the background and become instantly visible when
the user clicks them in the sidebar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:46:02 +03:00

1463 lines
58 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const { app, BrowserWindow, WebContentsView, ipcMain, session } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
const cheerio = require('cheerio');
const { ElectronBlocker, adsAndTrackingLists } = require('@cliqz/adblocker-electron');
const { autoUpdater } = require('electron-updater');
// Disable Trusted Types CSP enforcement engine-wide.
// YouTube sends `Content-Security-Policy: require-trusted-types-for 'script'`,
// which blocks the cliqz adblocker's scriptlet injection (it uses plain
// `script.text = ...`) → 52+ console errors and broken anti-adblock neutralizers.
// Stripping the CSP header via webRequest doesn't work — the adblocker's own
// onHeadersReceived hook overwrites ours (Electron allows only one listener
// per session). Disabling the Blink feature is the cleanest fix; safe in a
// kiosk single-user context.
app.commandLine.appendSwitch('disable-blink-features', 'TrustedDOMTypes');
const CONFIG_PATH = path.join(os.homedir(), '.ESH-Media.json');
const BLOCKER_CACHE_PATH = path.join(os.homedir(), '.ESH-Media-adblock-v3.bin');
const DEFAULT_TRUSTED_DOMAINS = [
// Google ecosystem (OAuth)
'google.com', 'accounts.google.com', 'googleapis.com', 'googleusercontent.com',
'gstatic.com', 'youtube.com', 'ytimg.com', 'googlevideo.com',
// Yandex
'yandex.ru', 'yandex.com', 'passport.yandex.ru', 'passport.yandex.com', 'yastatic.net',
// GitHub
'github.com', 'github.io', 'githubassets.com', 'githubusercontent.com',
// VK / Mail.ru
'vk.com', 'vk.ru', 'vkuser.net', 'mail.ru', 'my.mail.ru',
// Microsoft (login.live.com etc., некоторые сайты через них)
'live.com', 'microsoft.com', 'microsoftonline.com', 'office.com',
// Apple
'apple.com', 'icloud.com',
// Facebook (для соцлогина)
'facebook.com', 'fb.com',
];
const DEFAULT_CONFIG = { apps: [], proxy: { host: '127.0.0.1', port: '7890' }, trustedDomains: DEFAULT_TRUSTED_DOMAINS };
let blockerPromise = null;
let cachedTrustedDomains = DEFAULT_TRUSTED_DOMAINS;
// chrome.* spoof: injected via executeJavaScript on every page's dom-ready.
// Goal is to look like real Chrome to JS-based "embedded browser" detectors
// (Google login, etc.). Cannot fix TLS-fingerprint detection — that's server-side.
const CHROME_SPOOF_JS = `(function(){
try {
if (!window.chrome) window.chrome = {};
var c = window.chrome;
if (!c.app) c.app = {
isInstalled: false,
InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
getDetails: function(){ return null; },
getIsInstalled: function(){ return false; },
runningState: function(){ return 'cannot_run'; }
};
if (!c.runtime) c.runtime = {
PlatformOs: { MAC:'mac', WIN:'win', ANDROID:'android', CROS:'cros', LINUX:'linux', OPENBSD:'openbsd' },
PlatformArch: { ARM:'arm', X86_32:'x86-32', X86_64:'x86-64' },
PlatformNaclArch: { ARM:'arm', X86_32:'x86-32', X86_64:'x86-64' },
RequestUpdateCheckStatus: { NO_UPDATE:'no_update', THROTTLED:'throttled', UPDATE_AVAILABLE:'update_available' },
OnInstalledReason: { CHROME_UPDATE:'chrome_update', INSTALL:'install', SHARED_MODULE_UPDATE:'shared_module_update', UPDATE:'update' },
OnRestartRequiredReason: { APP_UPDATE:'app_update', OS_UPDATE:'os_update', PERIODIC:'periodic' },
sendMessage: function(){},
connect: function(){
return {
postMessage: function(){}, disconnect: function(){},
onDisconnect: { addListener: function(){}, removeListener: function(){} },
onMessage: { addListener: function(){}, removeListener: function(){} }
};
}
};
if (!c.csi) c.csi = function(){ return { startE: Date.now()-1000, onloadT: Date.now()-500, pageT: 1000, tran: 15 }; };
if (!c.loadTimes) c.loadTimes = function(){
var t = performance.timing;
return {
commitLoadTime: t.responseStart/1000, connectionInfo: 'http/1.1',
finishDocumentLoadTime: t.domContentLoadedEventEnd/1000,
finishLoadTime: (t.loadEventEnd/1000) || 0,
firstPaintAfterLoadTime: 0, firstPaintTime: t.responseEnd/1000,
navigationType: 'Other', npnNegotiatedProtocol: 'h2',
requestTime: t.requestStart/1000, startLoadTime: t.fetchStart/1000,
wasAlternateProtocolAvailable: false, wasFetchedViaSpdy: true, wasNpnNegotiated: true
};
};
// navigator.permissions.query: Notification permission must agree with Notification.permission
if (navigator.permissions && navigator.permissions.query) {
var origQuery = navigator.permissions.query.bind(navigator.permissions);
navigator.permissions.query = function(p){
if (p && p.name === 'notifications') return Promise.resolve({ state: Notification.permission, onchange: null });
return origQuery(p);
};
}
} catch (_) {}
})();`;
function loadTrustedDomainsFromDisk() {
try {
if (fs.existsSync(CONFIG_PATH)) {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
if (Array.isArray(cfg.trustedDomains) && cfg.trustedDomains.length) {
cachedTrustedDomains = cfg.trustedDomains;
}
}
} catch (_) {}
}
function isTrustedDomain(hostname) {
if (!hostname) return false;
const h = hostname.toLowerCase();
return cachedTrustedDomains.some(d => {
const dom = d.toLowerCase().replace(/^\./, '');
return h === dom || h.endsWith('.' + dom);
});
}
function getBlocker() {
if (blockerPromise) return blockerPromise;
blockerPromise = (async () => {
// Load from cache first (avoids re-downloading on every startup)
if (fs.existsSync(BLOCKER_CACHE_PATH)) {
try {
const data = fs.readFileSync(BLOCKER_CACHE_PATH);
const b = ElectronBlocker.deserialize(new Uint8Array(data));
console.log('[adblock] loaded from cache');
return b;
} catch (e) {
console.warn('[adblock] cache invalid, re-downloading:', e.message);
}
}
// Download filter lists (EasyList + EasyPrivacy + uBlock Origin + Russian ad networks)
console.log('[adblock] downloading filter lists...');
const fetchFn = (url, opts) => getProxySession().fetch(url, opts);
const russianLists = [
'https://filters.adtidy.org/extension/ublock/filters/1.txt', // AdGuard Russian
'https://easylist-downloads.adblockplus.org/ruadlist+easylist.txt', // RuAdList
];
const b = await ElectronBlocker.fromLists(fetchFn, [...adsAndTrackingLists, ...russianLists]);
// Whitelist domains that need ALL requests passed through unfiltered.
// Tracking-list false positives on these break critical functionality:
// • Google: OAuth/login integrity checks fail without gstatic + analytics endpoints
// → "Возможно, этот браузер или приложение небезопасны" error
// • Yandex/Mail/Microsoft/Apple: same OAuth-style integrity flows
// • TMDB: movie search API and poster CDN
const whitelist = [
'@@||api.themoviedb.org^', '@@||image.tmdb.org^', '@@||themoviedb.org^',
'@@||google.com^', '@@||googleapis.com^', '@@||googleusercontent.com^',
'@@||gstatic.com^', '@@||youtube.com^', '@@||ytimg.com^', '@@||googlevideo.com^',
'@@||google-analytics.com^', '@@||googletagmanager.com^',
'@@||yandex.ru^', '@@||yandex.com^', '@@||yastatic.net^', '@@||mc.yandex.ru^',
'@@||github.com^', '@@||githubassets.com^', '@@||githubusercontent.com^',
'@@||vk.com^', '@@||vk.ru^', '@@||vkuser.net^',
'@@||mail.ru^', '@@||my.mail.ru^', '@@||imgsmail.ru^',
'@@||microsoft.com^', '@@||microsoftonline.com^', '@@||live.com^', '@@||office.com^',
'@@||apple.com^', '@@||icloud.com^',
'@@||facebook.com^', '@@||fbcdn.net^',
];
b.updateFromDiff({ added: whitelist });
fs.writeFileSync(BLOCKER_CACHE_PATH, Buffer.from(b.serialize()));
console.log('[adblock] filter lists downloaded and cached');
return b;
})();
return blockerPromise;
}
function enableBlockingInSession(sess) {
getBlocker()
.then(b => {
b.enableBlockingInSession(sess);
// Remove the cliqz preload script that the blocker just registered on this
// session. The preload injects inline <script> elements (via createTextNode +
// appendChild) to neutralize anti-adblock scripts, but:
// • Strict-CSP sites (kinogo via Cloudflare, etc.) reject inline scripts
// without a matching nonce → "Refused to execute inline script".
// • Trusted-Types sites (YouTube, Gmail) reject `script.appendChild(text)`
// → "HTMLScriptElement was directly modified" (52 errors).
// We keep the adblocker's network blocking and CSP filtering (via the still-
// attached webRequest hooks), losing only the niche scriptlet/cosmetic-DOM
// injection layer that breaks more sites than it helps.
const before = sess.getPreloads();
const after = before.filter(p => !/adblocker-electron-preload/i.test(p));
if (after.length !== before.length) {
sess.setPreloads(after);
console.log('[adblock] enabled for session (preload script disabled)');
} else {
console.log('[adblock] enabled for session');
}
})
.catch(e => console.warn('[adblock] failed to enable:', e.message));
}
const isDev = !app.isPackaged;
const RENDERER_URL = 'http://localhost:5173';
const PRELOAD_PATH = path.join(__dirname, 'preload.js');
const EXTENSIONS_PATH = path.join(__dirname, 'extensions');
const HEADER_H = 50;
const SIDEBAR_COLLAPSED_W = 75;
let mainWindow = null;
let currentView = null;
let loaderView = null;
let openedApps = [];
const errorViews = [];
const confirmViews = [];
let proxySession = null;
let directSession = null;
let pendingNavigate = null; // { view, url } — cross-domain redirect awaiting confirmation
// --- Sessions ---
function getProxySession() {
if (!proxySession) {
proxySession = session.fromPartition('persist:proxy');
// No setUserAgent — partition uses app.userAgentFallback by default, and
// calling setUserAgent appears to be a fingerprint signal Google reads.
enableBlockingInSession(proxySession);
}
return proxySession;
}
function getDirectSession() {
if (!directSession) {
directSession = session.fromPartition('persist:direct');
directSession.setProxy({ proxyRules: 'direct://' });
enableBlockingInSession(directSession);
}
return directSession;
}
async function applyProxy(host, port) {
const proxyRules = `http=${host}:${port};https=${host}:${port};socks=socks5://${host}:${port}`;
await getProxySession().setProxy({ proxyRules });
await session.defaultSession.setProxy({ proxyRules });
}
// --- Extensions ---
async function loadExtensions() {
if (!fs.existsSync(EXTENSIONS_PATH)) return;
const entries = fs.readdirSync(EXTENSIONS_PATH, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const extPath = path.join(EXTENSIONS_PATH, entry.name);
// Fix Windows-style backslash paths in declarative_net_request manifest entries
const manifestPath = path.join(extPath, 'manifest.json');
if (fs.existsSync(manifestPath)) {
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
let changed = false;
// Remove rule_resources entries whose files don't exist on disk
if (manifest.declarative_net_request?.rule_resources) {
const valid = manifest.declarative_net_request.rule_resources.filter(r => {
const rPath = r.path.replace(/^\//, '').split('/').join(path.sep);
return fs.existsSync(path.join(extPath, rPath));
});
if (valid.length !== manifest.declarative_net_request.rule_resources.length) {
manifest.declarative_net_request.rule_resources = valid;
changed = true;
console.log(`Removed ${manifest.declarative_net_request.rule_resources.length === 0 ? 'all' : 'missing'} DNR rule_resources for: ${entry.name}`);
}
}
// Remove service_worker — Electron doesn't support MV3 service workers for extensions
if (manifest.background?.service_worker) {
delete manifest.background.service_worker;
if (!Object.keys(manifest.background).length) delete manifest.background;
changed = true;
}
// Remove permissions unsupported by Electron to suppress warnings
const UNSUPPORTED_PERMS = new Set(['contextMenus', 'notifications', 'webNavigation', 'management']);
if (manifest.permissions) {
const filtered = manifest.permissions.filter(p => !UNSUPPORTED_PERMS.has(p));
if (filtered.length !== manifest.permissions.length) {
manifest.permissions = filtered;
changed = true;
}
}
if (changed) fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
} catch (e) {
console.warn('Failed to patch manifest for', entry.name, e.message);
}
}
// Load into all sessions so content scripts run in WebContentsViews too
const sessionsToLoad = [
session.defaultSession,
session.fromPartition('persist:proxy'),
session.fromPartition('persist:direct'),
];
for (const sess of sessionsToLoad) {
try {
await sess.loadExtension(extPath, { allowFileAccess: true });
} catch (e) {
// only log once (defaultSession gives the meaningful error)
if (sess === session.defaultSession)
console.warn('Failed to load extension', entry.name, e.message);
}
}
console.log('Loaded extension:', entry.name);
}
}
// --- Updates ---
function compareSemver(a, b) {
// Returns 1 if a > b, -1 if a < b, 0 if equal. Numeric per-segment, missing → 0.
const pa = a.split('.').map(n => parseInt(n, 10) || 0);
const pb = b.split('.').map(n => parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const x = pa[i] || 0, y = pb[i] || 0;
if (x > y) return 1;
if (x < y) return -1;
}
return 0;
}
// --- Auto-update (electron-updater + Gitea API discovery) ---
//
// Gitea 1.24.7 doesn't expose /releases/latest/download/ as a stable URL shortcut,
// so we use the API to find the latest release at runtime, then point setFeedURL
// at THAT release's download directory. electron-updater fetches latest.yml from
// there and uses .blockmap files for differential downloads (saves ~70 MB per
// minor patch since most of the 80 MB installer is unchanged Electron runtime).
autoUpdater.autoDownload = true; // download in background as soon as we detect an update
autoUpdater.autoInstallOnAppQuit = false; // we'll manually trigger install — kiosk shouldn't surprise-restart mid-video
autoUpdater.allowDowngrade = false;
autoUpdater.logger = {
info: (m) => console.log('[updater]', m),
warn: (m) => console.warn('[updater]', m),
error: (m) => console.error('[updater]', m),
debug: () => {},
};
function sendUpdateStatus(payload) {
if (mainWindow && !mainWindow.webContents.isDestroyed()) {
mainWindow.webContents.send('update-status', payload);
}
}
autoUpdater.on('update-available', (info) => {
sendUpdateStatus({ state: 'available', version: info.version, currentVersion: app.getVersion() });
});
autoUpdater.on('download-progress', (p) => {
sendUpdateStatus({ state: 'downloading', percent: Math.round(p.percent), transferred: p.transferred, total: p.total, bytesPerSecond: p.bytesPerSecond });
});
autoUpdater.on('update-downloaded', (info) => {
sendUpdateStatus({ state: 'ready', version: info.version, currentVersion: app.getVersion() });
});
autoUpdater.on('error', (err) => {
console.warn('[updater] error:', err?.message || err);
sendUpdateStatus({ state: 'error', message: err?.message || String(err) });
});
let updateCheckInFlight = false;
async function checkForUpdates() {
if (updateCheckInFlight) return;
if (!app.isPackaged) {
console.log('[updater] dev mode, skipping');
return;
}
updateCheckInFlight = true;
try {
// Discover latest release via Gitea API (no auth, public endpoint)
const res = await getDirectSession().fetch(
'https://gitea.esh-service.ru/api/v1/repos/public/ESH-Media/releases/latest'
);
if (!res.ok) return;
const data = await res.json();
const latestTag = (data.tag_name || '').replace(/^v/, '');
const current = app.getVersion();
if (!latestTag || compareSemver(latestTag, current) <= 0) {
console.log(`[updater] up to date (current=${current}, latest=${latestTag || 'none'})`);
return;
}
// Verify latest.yml is present in this release's assets — without it autoUpdater can't proceed.
const hasYml = (data.assets || []).some(a => a.name === 'latest.yml');
if (!hasYml) {
console.warn(`[updater] release v${latestTag} has no latest.yml — falling back to manual download link`);
const installer = (data.assets || []).find(a => a.name.toLowerCase().endsWith('.exe'));
sendUpdateStatus({
state: 'manual',
version: latestTag,
currentVersion: current,
installerUrl: installer?.browser_download_url || data.html_url,
installerName: installer?.name || '',
});
return;
}
// Point feedURL at this release's download directory
const feedUrl = `https://gitea.esh-service.ru/public/ESH-Media/releases/download/v${latestTag}/`;
console.log(`[updater] setFeedURL ${feedUrl}`);
autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl });
await autoUpdater.checkForUpdates();
} catch (e) {
console.warn('[updater] check failed:', e?.message || e);
} finally {
updateCheckInFlight = false;
}
}
ipcMain.handle('install-update', () => {
// Quits the app and runs the downloaded installer with NSIS /S silent flag.
// Installer waits for the running process to exit, replaces files, then relaunches.
try {
setImmediate(() => autoUpdater.quitAndInstall(true, true));
return true;
} catch (e) {
console.warn('[updater] quitAndInstall failed:', e?.message);
return false;
}
});
ipcMain.handle('check-update-now', async () => {
await checkForUpdates();
return true;
});
// --- Window ---
function attachDevToolsShortcut(webContents) {
// Ctrl+Shift+I / F12 open DevTools on this webContents.
// Always available so a kiosk machine can be debugged without un-kiosking.
webContents.on('before-input-event', (_e, input) => {
if (input.type !== 'keyDown') return;
const isDevToolsCombo =
(input.control && input.shift && (input.key === 'I' || input.key === 'i')) ||
input.key === 'F12';
if (isDevToolsCombo) {
try { webContents.openDevTools({ mode: 'detach' }); } catch (_) {}
}
});
}
async function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
kiosk: true,
autoHideMenuBar: true,
webPreferences: {
preload: PRELOAD_PATH,
contextIsolation: true,
nodeIntegration: false,
},
});
attachDevToolsShortcut(mainWindow.webContents);
if (isDev) {
mainWindow.loadURL(RENDERER_URL);
} else {
mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html'));
}
}
// --- View helpers ---
function getViewBounds(sidebarWidth) {
const w = sidebarWidth !== undefined ? sidebarWidth : SIDEBAR_COLLAPSED_W;
const { width, height } = mainWindow.getBounds();
return { x: w, y: HEADER_H, width: width - w, height: height - HEADER_H };
}
function addChild(view) {
mainWindow.contentView.addChildView(view);
}
function removeChild(view) {
try { mainWindow.contentView.removeChildView(view); } catch (_) {}
}
function bringOverlaysToTop() {
confirmViews.forEach(c => { removeChild(c.view); addChild(c.view); });
errorViews.forEach(v => { removeChild(v); addChild(v); });
}
function sendOpenedApps(activeName) {
mainWindow.webContents.send(
'update-opened-apps',
openedApps.map(a => ({ name: a.name, url: a.url, imageUrl: a.imageUrl })),
activeName
);
}
// --- Loader ---
function setLoader() {
if (loaderView) return;
const { width, height } = mainWindow.getBounds();
loaderView = new WebContentsView({ webPreferences: { contextIsolation: true, nodeIntegration: false } });
addChild(loaderView);
loaderView.setBounds({ x: 0, y: HEADER_H, width, height: height - HEADER_H });
if (isDev) {
loaderView.webContents.loadURL(`${RENDERER_URL}/loader.html`);
} else {
loaderView.webContents.loadFile(path.join(__dirname, 'dist', 'loader.html'));
}
}
function removeLoader() {
if (!loaderView) return;
const lv = loaderView;
loaderView = null;
lv.webContents.executeJavaScript(`document.body.style.opacity='0'`).catch(() => {});
setTimeout(() => { try { removeChild(lv); lv.webContents.destroy(); } catch (_) {} }, 260);
}
// --- Dialogs (WebContentsView overlays) ---
function dialogFadeOut(view, cb) {
view.webContents.executeJavaScript(
`document.body.classList.remove('visible');document.body.classList.add('hiding')`
).catch(() => {});
setTimeout(cb, 230);
}
function makeDialogView() {
const { width, height } = mainWindow.getBounds();
const view = new WebContentsView({
webPreferences: { contextIsolation: true, nodeIntegration: false, preload: PRELOAD_PATH },
});
view.setBackgroundColor('#00000000');
view.setBounds({ x: 0, y: 0, width, height });
return view;
}
function setError(title, text) {
const view = makeDialogView();
errorViews.push(view);
const query = new URLSearchParams({ title: title || '', text: text || '' }).toString();
view.webContents.once('did-finish-load', () => { addChild(view); });
if (isDev) {
view.webContents.loadURL(`${RENDERER_URL}/dialog-error.html?${query}`);
} else {
view.webContents.loadFile(path.join(__dirname, 'dist', 'dialog-error.html'), { query: { title: title || '', text: text || '' } });
}
}
function removeError() {
if (!errorViews.length) return;
const view = errorViews.pop();
dialogFadeOut(view, () => { try { removeChild(view); view.webContents.destroy(); } catch (_) {} });
}
function setConfirm(text, actionOnYes) {
const view = makeDialogView();
confirmViews.push({ view, actionOnYes });
const query = new URLSearchParams({ text: text || '' }).toString();
view.webContents.once('did-finish-load', () => { addChild(view); });
if (isDev) {
view.webContents.loadURL(`${RENDERER_URL}/dialog-confirm.html?${query}`);
} else {
view.webContents.loadFile(path.join(__dirname, 'dist', 'dialog-confirm.html'), { query: { text: text || '' } });
}
}
function removeConfirm() {
if (!confirmViews.length) return;
const { view } = confirmViews.pop();
dialogFadeOut(view, () => { try { removeChild(view); view.webContents.destroy(); } catch (_) {} });
}
function removeView(name) {
const idx = openedApps.findIndex(a => a.name === name);
if (idx === -1) return;
const [app] = openedApps.splice(idx, 1);
removeChild(app.view);
app.view.webContents.destroy();
if (currentView && currentView.name === name) currentView = null;
sendOpenedApps('home');
scheduleSessionSave();
}
// --- Session persistence (preserves opened tabs across restart, incl. auto-update relaunch) ---
let sessionSaveTimer = null;
function scheduleSessionSave() {
if (sessionSaveTimer) clearTimeout(sessionSaveTimer);
sessionSaveTimer = setTimeout(saveSessionNow, 500);
}
function saveSessionNow() {
sessionSaveTimer = null;
try {
const tabs = openedApps.map(a => ({
name: a.name,
url: a.history[a.historyPosition] || a.url, // latest URL the user was on, not the initial one
imageUrl: a.imageUrl || '',
useProxy: !!a.useProxy,
}));
const activeName = currentView?.name || 'home';
const cfg = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) : {};
cfg.openedSession = { tabs, activeName, savedAt: Date.now() };
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8');
} catch (e) {
console.warn('[session] save failed:', e.message);
}
}
async function restoreSession() {
try {
if (!fs.existsSync(CONFIG_PATH)) return;
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
const sess = cfg.openedSession;
if (!sess || !Array.isArray(sess.tabs) || !sess.tabs.length) return;
const activeName = sess.activeName || 'home';
console.log(`[session] restoring ${sess.tabs.length} tab(s), active=${activeName}`);
// Spawn each saved tab via create-view, sequentially with a small delay.
// Only the saved-active tab gets `becomeActive=true` — it becomes currentView
// and will attach to the window when it finishes loading. All other tabs
// load in the background; they're ready to show instantly when the user
// clicks them in the sidebar but never flash on top of the home screen.
const fakeEvent = { sender: mainWindow.webContents };
for (const tab of sess.tabs) {
if (!tab?.name || !tab?.url) continue;
const isActive = activeName !== 'home' && tab.name === activeName;
ipcMain.emit('create-view', fakeEvent, tab.name, tab.url, tab.imageUrl || '', 1.0, !!tab.useProxy, isActive);
await new Promise(r => setTimeout(r, 150));
}
if (activeName === 'home') sendOpenedApps('home');
} catch (e) {
console.warn('[session] restore failed:', e.message);
}
}
// 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, becomeActive = true) => {
if (!url || !name) return;
const existing = openedApps.find(a => a.name === name);
if (existing) {
if (currentView && currentView.view) removeChild(currentView.view);
addChild(existing.view);
bringOverlaysToTop();
currentView = existing;
currentView.view.setBounds(getViewBounds());
sendOpenedApps(name);
mainWindow.webContents.send('updateWebButtons', { history: existing.history, historyPosition: existing.historyPosition });
return;
}
if (becomeActive) {
if (currentView && currentView.view) removeChild(currentView.view);
setLoader();
}
const view = new WebContentsView({
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
session: useProxy ? getProxySession() : getDirectSession(),
},
});
const appEntry = { name, url, imageUrl, useProxy: !!useProxy, view, history: [url], historyPosition: 0 };
openedApps.push(appEntry);
if (becomeActive) currentView = appEntry;
view.setBounds(getViewBounds());
attachDevToolsShortcut(view.webContents);
view.webContents.on('dom-ready', () => {
view.webContents.executeJavaScript(CHROME_SPOOF_JS).catch(() => {});
});
view.webContents.on('did-finish-load', () => {
// Attach to the window ONLY if this view is the currently active one.
// Otherwise it's a background-loaded tab (restored from session or user
// switched away while loading) — keep it ready in memory but invisible.
if (currentView !== appEntry) return;
removeLoader();
addChild(view);
bringOverlaysToTop();
view.webContents.executeJavaScript(`
(function(){
if(document.__nfFade)return; document.__nfFade=true;
const o=document.createElement('div');
o.style.cssText='position:fixed;inset:0;background:#111;z-index:2147483647;pointer-events:none;transition:opacity 0.35s ease;';
document.documentElement.appendChild(o);
requestAnimationFrame(()=>requestAnimationFrame(()=>{
o.style.opacity='0';
setTimeout(()=>o.remove(),370);
}));
})()
`).catch(()=>{});
});
const trackNavigation = (navigatingUrl) => {
const app = openedApps.find(a => a.name === name);
if (!app) return;
if (navigatingUrl === app.history[app.historyPosition]) {
mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition });
return;
}
if (app.historyPosition < app.history.length - 1) {
app.history = app.history.slice(0, app.historyPosition + 1);
}
app.history.push(navigatingUrl);
app.historyPosition = app.history.length - 1;
mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition });
scheduleSessionSave();
};
let origHostname = '';
try { origHostname = new URL(url).hostname; } catch (_) {}
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)) {
e.preventDefault();
pendingNavigate = { view, url: newUrl };
setConfirm(`Перейти на "${newHostname}"?`, 'navigate-confirmed');
return;
}
trackNavigation(newUrl);
});
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 → navigate IN-PLACE. (Google login is handled above as popup.)
if (newHostname && isTrustedDomain(newHostname)) {
trackNavigation(newUrl);
view.webContents.loadURL(newUrl);
return { action: 'deny' };
}
// Untrusted cross-domain → ask the user.
if (origHostname && newHostname && newHostname !== origHostname) {
pendingNavigate = { view, url: newUrl };
setConfirm(`Перейти на "${newHostname}"?`, 'navigate-confirmed');
return { action: 'deny' };
}
// Same-origin popup → navigate the current view.
trackNavigation(newUrl);
view.webContents.loadURL(newUrl);
return { action: 'deny' };
});
sendOpenedApps(becomeActive ? name : (currentView?.name || 'home'));
if (becomeActive) {
mainWindow.webContents.send('updateWebButtons', { history: appEntry.history, historyPosition: appEntry.historyPosition });
}
scheduleSessionSave();
view.webContents.loadURL(url).catch(() => {
removeView(name);
if (becomeActive) removeLoader();
setError('Ошибка', `Не удалось загрузить: ${name}`);
});
});
ipcMain.on('remove-view', (_event, name) => removeView(name || (currentView && currentView.name)));
ipcMain.on('hide-view', () => {
if (currentView && currentView.view) removeChild(currentView.view);
currentView = null;
scheduleSessionSave();
});
ipcMain.on('show-view', (_event, name) => {
const app = openedApps.find(a => a.name === name);
if (!app) return;
if (currentView && currentView.view) removeChild(currentView.view);
currentView = app;
addChild(app.view);
bringOverlaysToTop();
mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition });
scheduleSessionSave();
});
let sidebarAnim = null;
const SIDEBAR_ANIM_MS = 250;
function animateSidebarResize(targetX) {
if (!currentView || !currentView.view) return;
if (sidebarAnim) { clearInterval(sidebarAnim); sidebarAnim = null; }
const fromX = currentView.view.getBounds().x;
if (fromX === targetX) return;
const startTime = Date.now();
// easeInOut
const ease = t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
sidebarAnim = setInterval(() => {
const t = Math.min((Date.now() - startTime) / SIDEBAR_ANIM_MS, 1);
const x = Math.round(fromX + (targetX - fromX) * ease(t));
if (currentView && currentView.view) {
const { width, height } = mainWindow.getBounds();
currentView.view.setBounds({ x, y: HEADER_H, width: width - x, height: height - HEADER_H });
}
if (t >= 1) { clearInterval(sidebarAnim); sidebarAnim = null; }
}, 16);
}
ipcMain.on('adjust-view', (_event, expanded) => {
animateSidebarResize(expanded ? 200 : SIDEBAR_COLLAPSED_W);
});
ipcMain.on('backwardPage', () => {
const app = openedApps.find(a => a.name === (currentView && currentView.name));
if (!app || app.historyPosition <= 0) return;
app.historyPosition--;
currentView.view.webContents.loadURL(app.history[app.historyPosition])
.catch(() => setError('Ошибка', 'Страница не найдена'));
mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition });
});
ipcMain.on('forwardPage', () => {
const app = openedApps.find(a => a.name === (currentView && currentView.name));
if (!app || app.historyPosition >= app.history.length - 1) return;
app.historyPosition++;
currentView.view.webContents.loadURL(app.history[app.historyPosition])
.catch(() => setError('Ошибка', 'Страница не найдена'));
mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition });
});
ipcMain.on('refreshPage', () => {
const app = openedApps.find(a => a.name === (currentView && currentView.name));
if (!app) return;
currentView.view.webContents.loadURL(app.history[app.historyPosition])
.catch(() => setError('Ошибка', 'Страница не найдена'));
});
ipcMain.on('collapseWithHeader', () => {
if (!currentView || !currentView.view) return;
const { width, height } = mainWindow.getBounds();
currentView.view.setBounds({ x: 0, y: 1, width, height: height - 1 });
});
ipcMain.on('expandWithHeader', () => {
if (!currentView || !currentView.view) return;
currentView.view.setBounds(getViewBounds());
});
ipcMain.on('set-proxy', async (_event, host, port) => applyProxy(host, port));
// --- Movie Search ---
const MOVIE_PARSERS = {
dle: {
buildUrl: (domain, query) =>
`https://${domain}/?do=search&subaction=search&story=${encodeURIComponent(query)}`,
parse: (html, domain) => {
const $ = cheerio.load(html);
const results = [];
const toAbs = (src) => {
if (!src) return '';
if (src.startsWith('http')) return src;
return src.startsWith('/') ? `https://${domain}${src}` : `https://${domain}/${src}`;
};
$('.short, .movie-item, .th-item, .card, article.item, .shortstory, article.shortStory, .shortStory').each((_, el) => {
const $el = $(el);
const $link = $el.find('h2 a, .th-title a, .title a, .short-title a, .card-title a, .name a, .hTitle a').first();
const title = $link.text().trim();
let href = $link.attr('href') || '';
if (href && !href.startsWith('http')) href = `https://${domain}${href}`;
const rawSrc = $el.find('img[data-src]').first().attr('data-src') || $el.find('img[src]').first().attr('src') || '';
const poster = rawSrc.startsWith('data:') ? '' : toAbs(rawSrc);
const year = $el.text().match(/\b(19|20)\d{2}\b/)?.[0];
if (title && href) results.push({ title, url: href, poster, year, source: domain });
});
return results;
},
},
hdrezka: {
buildUrl: (domain, query) =>
`https://${domain}/?do=search&subaction=search&story=${encodeURIComponent(query)}`,
parse: (html, domain) => {
const $ = cheerio.load(html);
const results = [];
const toAbs = (src) => {
if (!src) return '';
if (src.startsWith('http')) return src;
return src.startsWith('/') ? `https://${domain}${src}` : `https://${domain}/${src}`;
};
$('.b-content__inline_item').each((_, el) => {
const $el = $(el);
const $link = $el.find('.b-content__inline_item-link a').first();
const title = $link.text().trim();
const href = $link.attr('href') || '';
const poster = toAbs($el.find('img').first().attr('src') || '');
const year = $el.find('.b-content__inline_item-link div').text().match(/\b(19|20)\d{2}\b/)?.[0];
if (title && href) results.push({ title, url: href, poster, year, source: domain });
});
return results;
},
},
filmix: {
buildUrl: (domain, query) =>
`https://${domain}/search/${encodeURIComponent(query)}/`,
parse: (html, domain) => {
const $ = cheerio.load(html);
const results = [];
const toAbs = (src) => {
if (!src) return '';
if (src.startsWith('http')) return src;
return src.startsWith('/') ? `https://${domain}${src}` : `https://${domain}/${src}`;
};
$('.post-item, .movie-item, .item').each((_, el) => {
const $el = $(el);
const $link = $el.find('a.title, h2 a, .name a').first();
const title = $link.text().trim();
let href = $link.attr('href') || '';
if (href && !href.startsWith('http')) href = `https://${domain}${href}`;
const poster = toAbs($el.find('img').first().attr('src') || '');
const year = $el.text().match(/\b(19|20)\d{2}\b/)?.[0];
if (title && href) results.push({ title, url: href, poster, year, source: domain });
});
return results;
},
},
};
const SEARCH_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.8',
};
async function searchHdrezkaAjax(site, query) {
try {
const url = `https://${site.domain}/engine/ajax/search.php`;
console.log(`[search] ${site.domain} -> AJAX`);
const resp = await getProxySession().fetch(url, {
method: 'POST',
body: `q=${encodeURIComponent(query)}`,
headers: {
...SEARCH_HEADERS,
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': `https://${site.domain}/`,
},
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return [];
const html = await resp.text();
const $ = cheerio.load(html);
const results = [];
$('ul li a').each((_, el) => {
const $el = $(el);
const href = $el.attr('href') || '';
const title = $el.find('.enty').text().trim();
if (title && href) results.push({ title, url: href, poster: '', year: '', source: site.domain });
});
console.log(`[search] ${site.domain} AJAX found: ${results.length}`);
return results;
} catch (e) {
console.warn(`[search] ${site.domain} AJAX error:`, e.message);
return [];
}
}
function searchWithView(site, query) {
const parser = MOVIE_PARSERS[site.type];
if (!parser) return Promise.resolve([]);
const url = parser.buildUrl(site.domain, query);
console.log(`[search] ${site.domain} -> ${url} (browser)`);
return new Promise((resolve) => {
const view = new WebContentsView({
webPreferences: { contextIsolation: true, nodeIntegration: false, session: getProxySession() },
});
let extractTimer = null;
const cleanup = (results) => {
if (extractTimer) clearTimeout(extractTimer);
try { view.webContents.destroy(); } catch (_) {}
resolve(results);
};
const globalTimer = setTimeout(() => {
console.warn(`[search] ${site.domain} timeout`);
cleanup([]);
}, 25000);
const tryExtract = () => {
if (extractTimer) clearTimeout(extractTimer);
extractTimer = setTimeout(() => {
clearTimeout(globalTimer);
// Poll every 400ms until results found or 5s elapsed (handles AJAX-loaded content)
const deadline = Date.now() + 5000;
const poll = async () => {
try {
const html = await view.webContents.executeJavaScript('document.documentElement.outerHTML');
const results = parser.parse(html, site.domain);
if (results.length > 0 || Date.now() >= deadline) {
console.log(`[search] ${site.domain} browser found: ${results.length}`);
cleanup(results);
} else {
setTimeout(poll, 400);
}
} catch (e) {
console.warn(`[search] ${site.domain} extract error:`, e.message);
cleanup([]);
}
};
poll();
}, 800); // initial wait for JS redirects / challenge pages
};
view.webContents.on('did-finish-load', tryExtract);
view.webContents.on('did-fail-load', (_e, code, desc) => {
clearTimeout(globalTimer);
console.warn(`[search] ${site.domain} load failed: ${code} ${desc}`);
cleanup([]);
});
view.webContents.loadURL(url, { userAgent: SEARCH_HEADERS['User-Agent'] }).catch(e => {
clearTimeout(globalTimer);
console.warn(`[search] ${site.domain} loadURL error:`, e.message);
cleanup([]);
});
});
}
async function searchOneSite(site, query) {
if (site.type === 'hdrezka') return searchHdrezkaAjax(site, query);
return searchWithView(site, query);
}
ipcMain.handle('search-tmdb', async (_event, query, apiKey) => {
console.log(`[tmdb] searching: "${query}", key type: ${apiKey ? (apiKey.startsWith('eyJ') ? 'bearer' : 'api_key') : 'none'}`);
try {
const isBearer = apiKey.startsWith('eyJ');
const url = isBearer
? `https://api.themoviedb.org/3/search/multi?query=${encodeURIComponent(query)}&language=ru-RU&include_adult=false`
: `https://api.themoviedb.org/3/search/multi?api_key=${apiKey}&query=${encodeURIComponent(query)}&language=ru-RU&include_adult=false`;
const headers = isBearer
? { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }
: {};
const resp = await getProxySession().fetch(url, { headers, signal: AbortSignal.timeout(8000) });
console.log(`[tmdb] status: ${resp.status}`);
if (!resp.ok) {
const body = await resp.text().catch(() => '');
console.warn(`[tmdb] error body:`, body.slice(0, 200));
return { error: `TMDB error ${resp.status}`, results: [] };
}
const data = await resp.json();
const results = (data.results || [])
.filter(r => r.media_type === 'movie' || r.media_type === 'tv')
.slice(0, 20)
.map(r => ({
id: r.id,
mediaType: r.media_type,
title: r.title || r.name || '',
originalTitle: r.original_title || r.original_name || '',
year: (r.release_date || r.first_air_date || '').slice(0, 4),
poster: r.poster_path ? `https://image.tmdb.org/t/p/w300${r.poster_path}` : '',
overview: r.overview || '',
rating: r.vote_average ? r.vote_average.toFixed(1) : '',
}));
console.log(`[tmdb] results: ${results.length}`);
return { results };
} catch (e) {
console.error(`[tmdb] exception:`, e.message);
return { error: e.message, results: [] };
}
});
ipcMain.handle('discover-tmdb', async (_event, { apiKey, mediaType, sortBy, genreIds, years, minRating, countries, page }) => {
// Multi-filter semantics (per user request):
// genres → AND (movie must match ALL selected genres) — TMDB comma-join in with_genres
// countries→ OR (movie matches ANY selected country/lang) — TMDB pipe-join
// years → OR (movie released in ANY selected year) — fan-out: one request per year, merge
// rating → min threshold (single) — vote_average.gte
try {
const isBearer = apiKey.startsWith('eyJ');
const type = mediaType === 'tv' ? 'tv' : 'movie';
const genreArr = Array.isArray(genreIds) ? genreIds.filter(Boolean) : [];
const countryArr = Array.isArray(countries) ? countries.filter(Boolean) : [];
const yearArr = Array.isArray(years) ? years.filter(Boolean) : [];
const buildParams = (yearOverride) => {
const params = new URLSearchParams({
language: 'ru-RU',
sort_by: sortBy || 'popularity.desc',
page: String(page || 1),
include_adult: 'false',
});
if (!isBearer) params.set('api_key', apiKey);
if (genreArr.length) params.set('with_genres', genreArr.join(',')); // AND
if (minRating) params.set('vote_average.gte', minRating);
if (countryArr.length) {
// Mix: codes that map to original_language go to with_original_language (pipe-OR),
// raw country codes go to with_origin_country (pipe-OR). Both fields can coexist.
const COUNTRY_LANG = {
RU: 'ru', JP: 'ja', KR: 'ko', CN: 'zh', FR: 'fr',
DE: 'de', IT: 'it', ES: 'es', SE: 'sv', DK: 'da', TR: 'tr', IN: 'hi',
};
const langs = [], origCountries = [];
for (const c of countryArr) {
if (COUNTRY_LANG[c]) langs.push(COUNTRY_LANG[c]);
else origCountries.push(c);
}
if (langs.length) params.set('with_original_language', langs.join('|'));
if (origCountries.length) params.set('with_origin_country', origCountries.join('|'));
}
if (yearOverride) {
if (type === 'movie') params.set('primary_release_year', String(yearOverride));
else params.set('first_air_date_year', String(yearOverride));
}
return params;
};
const fetchOnce = async (params) => {
const url = `https://api.themoviedb.org/3/discover/${type}?${params}`;
const headers = isBearer ? { 'Authorization': `Bearer ${apiKey}` } : {};
const resp = await getProxySession().fetch(url, { headers, signal: AbortSignal.timeout(8000) });
if (!resp.ok) return { results: [], totalPages: 0, status: resp.status };
const data = await resp.json();
return { results: data.results || [], totalPages: data.total_pages || 1, status: 200 };
};
let aggregated = [];
let maxPages = 1;
if (yearArr.length === 0) {
const r = await fetchOnce(buildParams(null));
if (r.status !== 200) return { error: `TMDB ${r.status}`, results: [], totalPages: 1 };
aggregated = r.results;
maxPages = r.totalPages;
} else {
// Fan out per year, merge by id (TMDB has no OR for discrete years)
const settled = await Promise.allSettled(yearArr.map(y => fetchOnce(buildParams(y))));
const seen = new Set();
for (const s of settled) {
if (s.status !== 'fulfilled' || s.value.status !== 200) continue;
maxPages = Math.max(maxPages, s.value.totalPages);
for (const r of s.value.results) {
if (seen.has(r.id)) continue;
seen.add(r.id);
aggregated.push(r);
}
}
// Re-sort merged set by popularity (since TMDB sorted within each year)
aggregated.sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
}
const results = aggregated.map(r => ({
id: r.id,
mediaType: type,
title: (type === 'movie' ? r.title : r.name) || '',
originalTitle: (type === 'movie' ? r.original_title : r.original_name) || '',
year: ((type === 'movie' ? r.release_date : r.first_air_date) || '').slice(0, 4),
poster: r.poster_path ? `https://image.tmdb.org/t/p/w300${r.poster_path}` : '',
overview: r.overview || '',
rating: r.vote_average ? r.vote_average.toFixed(1) : '',
}));
return { results, totalPages: Math.min(maxPages, 500) };
} catch (e) {
return { error: e.message, results: [], totalPages: 1 };
}
});
ipcMain.handle('search-movies', async (_event, query, sites) => {
const settled = await Promise.allSettled(sites.map(s => searchOneSite(s, query)));
return settled.flatMap(r => r.status === 'fulfilled' ? r.value : []);
});
ipcMain.handle('get-current-page', () => {
if (!currentView) return null;
return {
name: currentView.name,
url: currentView.history[currentView.historyPosition],
imageUrl: currentView.imageUrl || '',
};
});
ipcMain.handle('read-config', () => {
try {
if (fs.existsSync(CONFIG_PATH)) {
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
}
} catch (e) {
console.warn('Failed to read config:', e.message);
}
return DEFAULT_CONFIG;
});
ipcMain.on('write-config', (_event, data) => {
try {
fs.writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2), 'utf8');
if (Array.isArray(data?.trustedDomains)) cachedTrustedDomains = data.trustedDomains;
} catch (e) {
console.warn('Failed to write config:', e.message);
}
});
ipcMain.handle('get-page-meta', async () => {
if (!currentView || !currentView.view || currentView.view.webContents.isDestroyed()) return null;
try {
return await currentView.view.webContents.executeJavaScript(`
(function() {
const get = (sel, attr) => { const el = document.querySelector(sel); return el ? (el.getAttribute(attr) || '') : ''; };
const abs = (u) => { try { return new URL(u, location.href).href } catch (_) { return u } };
let poster = get('meta[property="og:image:secure_url"]', 'content')
|| get('meta[property="og:image"]', 'content')
|| get('meta[name="twitter:image"]', 'content')
|| get('meta[name="twitter:image:src"]', 'content');
// HDRezka has a higher-res poster in the sidebar
if (!poster || /hdrezka|rezka/i.test(location.hostname)) {
const side = document.querySelector('.b-sidecover img');
if (side && side.src) poster = side.src;
}
// JSON-LD fallback
if (!poster) {
for (const s of document.querySelectorAll('script[type="application/ld+json"]')) {
try {
const d = JSON.parse(s.textContent || 'null');
const arr = Array.isArray(d) ? d : [d];
for (const it of arr) {
if (!it) continue;
const img = it.image || it.poster || (it.thumbnailUrl);
if (img) { poster = Array.isArray(img) ? img[0] : (typeof img === 'string' ? img : img.url); if (poster) break; }
}
if (poster) break;
} catch (_) {}
}
}
const title = (get('meta[property="og:title"]', 'content') || document.title || '').trim();
return { poster: poster ? abs(poster) : '', title, url: location.href };
})()
`);
} catch (e) {
return null;
}
});
ipcMain.handle('is-kiosk', () => mainWindow.isKiosk());
ipcMain.handle('toggle-kiosk', () => {
if (mainWindow.isKiosk()) {
mainWindow.setKiosk(false);
mainWindow.maximize();
mainWindow.setMenuBarVisibility(false);
return false;
} else {
mainWindow.setKiosk(true);
return true;
}
});
ipcMain.on('confirm',(_event, text, actionOnYes) => setConfirm(text, actionOnYes));
ipcMain.on('action', (_event, action) => {
if (action === 'error') {
removeError();
} else if (action === 'confirmYes') {
const last = confirmViews[confirmViews.length - 1];
if (last) {
if (last.actionOnYes === 'navigate-confirmed' && pendingNavigate) {
const { view: pView, url: pUrl } = pendingNavigate;
pendingNavigate = null;
if (!pView.webContents.isDestroyed()) {
pView.webContents.loadURL(pUrl).catch(() => setError('Ошибка', `Не удалось загрузить: ${pUrl}`));
}
} else {
mainWindow.webContents.send(last.actionOnYes);
}
}
removeConfirm();
} else if (action === 'confirmNo') {
if (confirmViews.length && confirmViews[confirmViews.length - 1].actionOnYes === 'navigate-confirmed') {
pendingNavigate = null;
}
removeConfirm();
}
});
// --- App lifecycle ---
app.whenReady().then(async () => {
// 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.
// Google embedded-browser detection is now mitigated only via adblock whitelist
// of gstatic/google-analytics/etc., which previously was being eaten silently.)
session.defaultSession.webRequest.onBeforeSendHeaders(
{ urls: ['https://*/*', 'http://*/*'] },
(details, callback) => {
const headers = details.requestHeaders;
if (details.resourceType === 'image' && !headers['Referer'] && !headers['referer']) {
try {
const u = new URL(details.url);
headers['Referer'] = `${u.protocol}//${u.hostname}/`;
} catch (_) {}
}
callback({ requestHeaders: headers });
}
);
// (Trusted Types now handled engine-wide via --disable-blink-features
// command-line switch at file top. webRequest.onHeadersReceived strip
// was tried in 1.0.6 but the cliqz adblocker overwrites the listener.)
// Apply proxy from config before blocker tries to download filter lists
loadTrustedDomainsFromDisk();
try {
if (fs.existsSync(CONFIG_PATH)) {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
if (cfg?.proxy?.host && cfg?.proxy?.port) await applyProxy(cfg.proxy.host, cfg.proxy.port);
}
} catch (_) {}
enableBlockingInSession(session.defaultSession);
getProxySession();
getDirectSession();
await loadExtensions();
await createWindow();
mainWindow.webContents.once('did-finish-load', async () => {
await restoreSession();
setTimeout(checkForUpdates, 4000);
setInterval(checkForUpdates, 60 * 60 * 1000); // re-check hourly for long-running kiosk sessions
});
});
app.on('before-quit', () => {
// Final synchronous save (timer might be pending).
if (sessionSaveTimer) { clearTimeout(sessionSaveTimer); sessionSaveTimer = null; }
saveSessionNow();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0) await createWindow();
});