Files
ESH-Media/main.js
eshmeshek a171f62629 feat: seamless auto-update via electron-updater, multi-select filters, session restore
- electron-updater wired with Gitea API discovery: setFeedURL dynamically
  per release (Gitea 1.24.7 lacks /releases/latest/download/ shortcut).
  Differential download via .blockmap saves ~70 MB per patch. Renderer
  banner shows states: available → downloading X% → ready. User clicks
  "Установить и перезапустить" → quitAndInstall replaces files + relaunches.
- Multi-select filters per user spec: genres AND (TMDB with_genres comma-
  joined), countries OR (pipe-joined into with_origin_country /
  with_original_language), years OR (fan-out one request per year, merge
  by id since TMDB has no discrete-year OR). Rating stays single threshold.
- Session persistence: openedSession {tabs, activeName} saved to config
  on tab create/show/hide/remove/in-app navigation, plus before-quit.
  Restored after did-finish-load via ipcMain.emit('create-view',...) per
  tab. Survives auto-update relaunch — bring user back to the same page.
- electron-builder publish config (generic provider) so latest.yml is
  generated during build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:42:25 +03:00

1225 lines
46 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');
const CONFIG_PATH = path.join(os.homedir(), '.ESH-Media.json');
const BLOCKER_CACHE_PATH = path.join(os.homedir(), '.ESH-Media-adblock-v2.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 TMDB so the movie search API is not blocked
b.addFilters(['@@||api.themoviedb.org^', '@@||image.tmdb.org^', '@@||themoviedb.org^']);
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 ---
async function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
kiosk: true,
autoHideMenuBar: true,
webPreferences: {
preload: PRELOAD_PATH,
contextIsolation: true,
nodeIntegration: false,
},
});
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. ipcMain.emit triggers the handler
// synchronously; the view's loadURL is fire-and-forget. We chain via setTimeout to
// avoid stacking N loaders simultaneously.
for (const tab of sess.tabs) {
if (!tab?.name || !tab?.url) continue;
ipcMain.emit('create-view', { sender: mainWindow.webContents }, tab.name, tab.url, tab.imageUrl || '', 1.0, !!tab.useProxy);
}
// After all spawned, the last one is `currentView`. Switch to the saved active if different.
if (sess.activeName === 'home') {
ipcMain.emit('hide-view', { sender: mainWindow.webContents });
sendOpenedApps('home');
} else if (sess.activeName && sess.activeName !== currentView?.name) {
ipcMain.emit('show-view', { sender: mainWindow.webContents }, 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());
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
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 });
}
);
// 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();
});