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; 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); 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'); proxySession.setUserAgent(app.userAgentFallback); enableBlockingInSession(proxySession); } return proxySession; } function getDirectSession() { if (!directSession) { directSession = session.fromPartition('persist:direct'); directSession.setUserAgent(app.userAgentFallback); 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; console.log(`[session] restoring ${sess.tabs.length} tab(s), active=${sess.activeName}`); // Spawn each saved tab by replaying create-view, sequentially with a small delay. // Concurrent create-view calls in v1.0.3 caused races: multiple setLoader/addChild // interleaved → some views ended up unmounted (white screen). Spacing them out // gives each view time to mount before the next steals currentView. const fakeEvent = { sender: mainWindow.webContents }; for (const tab of sess.tabs) { if (!tab?.name || !tab?.url) continue; ipcMain.emit('create-view', fakeEvent, tab.name, tab.url, tab.imageUrl || '', 1.0, !!tab.useProxy); await new Promise(r => setTimeout(r, 150)); } // Switch to saved active if it isn't already the last-spawned (currentView). if (sess.activeName === 'home') { ipcMain.emit('hide-view', fakeEvent); sendOpenedApps('home'); } else if (sess.activeName && sess.activeName !== currentView?.name) { ipcMain.emit('show-view', fakeEvent, sess.activeName); } } catch (e) { console.warn('[session] restore failed:', e.message); } } // --- IPC --- ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) => { 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 (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); currentView = appEntry; view.setBounds(getViewBounds()); attachDevToolsShortcut(view.webContents); view.webContents.on('did-finish-load', () => { removeLoader(); addChild(view); bringOverlaysToTop(); // Inject fade-in overlay so the page appears smoothly instead of blinking 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; } 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) => trackNavigation(u)); view.webContents.setWindowOpenHandler(({ url: newUrl, frameName, features }) => { let newHostname = ''; try { newHostname = new URL(newUrl).hostname; } catch (_) {} // Trusted domain → open as real popup BrowserWindow with same session. // This is what OAuth flows need: window.opener.postMessage() works, // popup can close itself when done, parent stays on the original page. if (newHostname && isTrustedDomain(newHostname)) { return { action: 'allow', overrideBrowserWindowOptions: { width: 520, height: 640, parent: mainWindow, autoHideMenuBar: true, webPreferences: { session: view.webContents.session, contextIsolation: true, nodeIntegration: false, }, }, }; } // Untrusted cross-domain → ask the user (original behavior). if (origHostname && newHostname && newHostname !== origHostname) { pendingNavigate = { view, url: newUrl }; setConfirm(`Перейти на "${newHostname}"?`, 'navigate-confirmed'); return { action: 'deny' }; } // Same-origin popup → just navigate the current view. trackNavigation(newUrl); view.webContents.loadURL(newUrl); return { action: 'deny' }; }); sendOpenedApps(name); mainWindow.webContents.send('updateWebButtons', { history: appEntry.history, historyPosition: appEntry.historyPosition }); scheduleSessionSave(); view.webContents.loadURL(url).catch(() => { removeView(name); 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 () => { // 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); // 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(); });