- Тематики: 14 курированных категорий (Зомби, Космос, Вампиры, Постапокалипсис, Шпионы и т.д.) поверх жанров TMDB. discover-tmdb принимает themeKeywords (pipe-OR по with_keywords); для musical/ superhero/martial добавлен extraGenre AND-связкой в movie-режиме. - Стриминг поиска по сайтам: search-movies переведён с invoke→return на event-emit — карточки появляются по мере ответа каждого сайта, не ждут самого медленного. Спиннер виден до первого результата. - Авто-детект логотипа сайта: поле "URL иконки" убрано из формы. Бэк IPC detect-logo пробует manifest.json → apple-touch-icon → link rel=icon ≥48px → JSON-LD → og:image → msapplication-TileImage → /favicon.ico (с проверкой content-type=image/*). Легаси-приложения без иконки догоняются тихо при открытии Settings.
1661 lines
66 KiB
JavaScript
1661 lines
66 KiB
JavaScript
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 OAuth flows
|
||
// in a dedicated partition that we never call setProxy() on, then copy the
|
||
// resulting auth cookies into the parent view's session so the user appears
|
||
// logged-in there too.
|
||
//
|
||
// The popup is a top-level BrowserWindow (not WebContentsView) — embedded view
|
||
// shape is also a signal anti-abuse engines read. No preload, no chrome.* spoof,
|
||
// no fade overlay: anything we touch on the DOM/globals could trip the detector.
|
||
//
|
||
// Yandex, Mail.ru, Microsoft Live, VK, Facebook, Apple, GitHub all run similar
|
||
// embedded-browser checks on their login pages (some more aggressive than
|
||
// Google's), so the popup is opened for every known OAuth provider host.
|
||
const LOGIN_PARTITION = 'persist:oauth-login';
|
||
|
||
// Hostname → list of cookie-domain suffixes to migrate back to the parent
|
||
// session after a successful login. Migrating extra domains is harmless, so
|
||
// each provider lists everything the auth handshake might set cookies on.
|
||
const OAUTH_PROVIDERS = {
|
||
'accounts.google.com': ['.google.com', '.youtube.com', '.googleusercontent.com'],
|
||
'passport.yandex.ru': ['.yandex.ru', '.yandex.com', '.passport.yandex.ru'],
|
||
'passport.yandex.com': ['.yandex.ru', '.yandex.com', '.passport.yandex.com'],
|
||
'oauth.yandex.ru': ['.yandex.ru', '.yandex.com'],
|
||
'login.live.com': ['.live.com', '.microsoft.com', '.microsoftonline.com'],
|
||
'login.microsoftonline.com': ['.microsoftonline.com', '.microsoft.com', '.live.com'],
|
||
'login.microsoft.com': ['.microsoft.com', '.live.com'],
|
||
'auth.mail.ru': ['.mail.ru'],
|
||
'login.mail.ru': ['.mail.ru'],
|
||
'oauth.mail.ru': ['.mail.ru'],
|
||
'oauth.vk.com': ['.vk.com', '.vk.ru'],
|
||
'login.vk.com': ['.vk.com', '.vk.ru'],
|
||
'oauth.vk.ru': ['.vk.ru', '.vk.com'],
|
||
'login.vk.ru': ['.vk.ru', '.vk.com'],
|
||
'appleid.apple.com': ['.apple.com', '.icloud.com'],
|
||
'idmsa.apple.com': ['.apple.com', '.icloud.com'],
|
||
};
|
||
|
||
// Hosts where the login subpath signals an OAuth flow (the rest of the host
|
||
// is regular browsing). Listed separately because the bare host is not a
|
||
// login page, only specific paths are.
|
||
const OAUTH_PATH_HOSTS = {
|
||
'github.com': { prefix: '/login', cookies: ['.github.com', '.githubusercontent.com'] },
|
||
'www.facebook.com': { prefix: '/login', cookies: ['.facebook.com'] },
|
||
'm.facebook.com': { prefix: '/login', cookies: ['.facebook.com'] },
|
||
};
|
||
|
||
function oauthProviderFor(u) {
|
||
try {
|
||
const url = new URL(u);
|
||
// YouTube and friends silently call window.open on accounts.google.com/
|
||
// ...?passive=true&... at page load to pick up an existing session via
|
||
// postMessage. That flow doesn't work in a top-level popup (no parent
|
||
// context → "JavaScript отключен" fallback). Skip popups for them; an
|
||
// active login click never has passive=true.
|
||
if (url.searchParams.get('passive') === 'true') return null;
|
||
if (OAUTH_PROVIDERS[url.hostname]) {
|
||
return { host: url.hostname, cookieDomains: OAUTH_PROVIDERS[url.hostname] };
|
||
}
|
||
const pathRule = OAUTH_PATH_HOSTS[url.hostname];
|
||
if (pathRule && url.pathname.startsWith(pathRule.prefix)) {
|
||
return { host: url.hostname, cookieDomains: pathRule.cookies };
|
||
}
|
||
return null;
|
||
} catch (_) { return null; }
|
||
}
|
||
|
||
function isOAuthLoginUrl(u) { return oauthProviderFor(u) !== null; }
|
||
|
||
async function migrateOAuthCookies(fromSess, toSess, cookieDomains) {
|
||
for (const domain of cookieDomains) {
|
||
let cookies = [];
|
||
try { cookies = await fromSess.cookies.get({ domain }); } catch (_) { continue; }
|
||
for (const c of cookies) {
|
||
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) {
|
||
const provider = oauthProviderFor(url);
|
||
if (!provider) return; // shouldn't be called for non-OAuth urls
|
||
for (const p of activeLoginPopups) {
|
||
if (p.parentView === parentView && !p.window.isDestroyed()) {
|
||
p.window.focus();
|
||
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: 'Вход',
|
||
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 () => {
|
||
if (finalizing) return;
|
||
finalizing = true;
|
||
try {
|
||
await migrateOAuthCookies(loginSess, parentView.webContents.session, provider.cookieDomains);
|
||
if (!parentView.webContents.isDestroyed()) parentView.webContents.reload();
|
||
} finally {
|
||
if (!popup.isDestroyed()) popup.close();
|
||
}
|
||
};
|
||
|
||
const checkRedirect = (newUrl) => {
|
||
if (!newUrl) return;
|
||
// Still on a login host (Google→Yandex→… cross-redirects are rare but allowed): stay.
|
||
if (isOAuthLoginUrl(newUrl)) return;
|
||
try {
|
||
const h = new URL(newUrl).hostname;
|
||
// Login flow handed control back to a non-login host (the service we
|
||
// came from, e.g. youtube.com) → success.
|
||
const loginHost = provider.host;
|
||
const isStillOnProviderApex = h === loginHost || h.endsWith('.' + loginHost.replace(/^[^.]+\./, ''));
|
||
if (!isStillOnProviderApex) finishLogin();
|
||
} catch (_) {}
|
||
};
|
||
popup.webContents.on('will-redirect', (_e, u) => checkRedirect(u));
|
||
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; }
|
||
// OAuth login URL → top-level BrowserWindow popup (see openLoginPopup).
|
||
if (isOAuthLoginUrl(newUrl)) { e.preventDefault(); openLoginPopup(view, newUrl); return; }
|
||
let newHostname = '';
|
||
try { newHostname = new URL(newUrl).hostname; } catch (_) { trackNavigation(newUrl); return; }
|
||
if (origHostname && newHostname && newHostname !== origHostname && !isTrustedDomain(newHostname)) {
|
||
e.preventDefault();
|
||
pendingNavigate = { view, url: newUrl };
|
||
setConfirm(`Перейти на "${newHostname}"?`, 'navigate-confirmed');
|
||
return;
|
||
}
|
||
trackNavigation(newUrl);
|
||
});
|
||
view.webContents.on('will-redirect', (e, u) => {
|
||
if (isOAuthLoginUrl(u)) { e.preventDefault(); openLoginPopup(view, u); return; }
|
||
trackNavigation(u);
|
||
});
|
||
view.webContents.setWindowOpenHandler(({ url: newUrl }) => {
|
||
// OAuth login URL → top-level BrowserWindow popup (see openLoginPopup).
|
||
if (isOAuthLoginUrl(newUrl)) { openLoginPopup(view, newUrl); return { action: 'deny' }; }
|
||
|
||
let newHostname = '';
|
||
try { newHostname = new URL(newUrl).hostname; } catch (_) {}
|
||
|
||
// 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, themeKeywords }) => {
|
||
// 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
|
||
// themeKeywords → OR within theme (curated keyword IDs) — TMDB pipe-join in with_keywords
|
||
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 keywordArr = Array.isArray(themeKeywords) ? themeKeywords.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 (keywordArr.length) params.set('with_keywords', keywordArr.join('|')); // OR within theme
|
||
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 };
|
||
}
|
||
});
|
||
|
||
// Streaming search: один сайт ответил → шлём результаты немедленно, не ждём остальных.
|
||
// Клиент передаёт searchId, чтобы можно было отменить устаревший поиск (token).
|
||
ipcMain.on('search-movies-start', (event, searchId, query, sites) => {
|
||
const wc = event.sender;
|
||
const send = (channel, payload) => {
|
||
if (wc.isDestroyed()) return;
|
||
wc.send(channel, { searchId, ...payload });
|
||
};
|
||
|
||
let pending = sites.length;
|
||
if (pending === 0) {
|
||
send('search-movies-done', {});
|
||
return;
|
||
}
|
||
|
||
sites.forEach(site => {
|
||
searchOneSite(site, query)
|
||
.then(results => send('search-movies-result', { source: site.domain, results: results || [] }))
|
||
.catch(err => {
|
||
console.warn(`[search] ${site.domain} stream error:`, err.message);
|
||
send('search-movies-result', { source: site.domain, results: [] });
|
||
})
|
||
.finally(() => {
|
||
pending -= 1;
|
||
if (pending === 0) send('search-movies-done', {});
|
||
});
|
||
});
|
||
});
|
||
|
||
// Авто-детект логотипа сайта. Возвращает абсолютный URL картинки или '' (если ничего не нашлось).
|
||
// Fallback chain (от лучшего к худшему):
|
||
// 1. /manifest.json → icons[] (PWA, обычно 192×192 / 512×512)
|
||
// 2. <link rel="apple-touch-icon"|"apple-touch-icon-precomposed"> (180×180 standard)
|
||
// 3. <link rel="icon" sizes="..."> с максимальным размером ≥48
|
||
// 4. JSON-LD Organization.logo
|
||
// 5. <meta property="og:image"> (часто hero, но всё лучше favicon)
|
||
// 6. <meta name="msapplication-TileImage">
|
||
// 7. /favicon.ico (last resort, низкое качество)
|
||
async function detectSiteLogo(siteUrl) {
|
||
let url;
|
||
try {
|
||
if (!/^https?:\/\//i.test(siteUrl)) siteUrl = 'https://' + siteUrl;
|
||
url = new URL(siteUrl);
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
const origin = url.origin;
|
||
const fetchOpts = { headers: { 'User-Agent': SEARCH_HEADERS['User-Agent'] }, signal: AbortSignal.timeout(6000) };
|
||
const abs = (href) => { try { return new URL(href, origin + '/').toString(); } catch { return ''; } };
|
||
const parseSizes = (s) => {
|
||
if (!s) return 0;
|
||
const m = String(s).match(/(\d+)\s*x\s*(\d+)/i);
|
||
return m ? parseInt(m[1], 10) : 0;
|
||
};
|
||
|
||
// 1. manifest.json
|
||
try {
|
||
const r = await getProxySession().fetch(origin + '/manifest.json', fetchOpts);
|
||
if (r.ok) {
|
||
const j = await r.json();
|
||
const icons = Array.isArray(j.icons) ? j.icons : [];
|
||
if (icons.length) {
|
||
icons.sort((a, b) => parseSizes(b.sizes) - parseSizes(a.sizes));
|
||
const best = icons[0];
|
||
if (best?.src) return abs(best.src);
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
|
||
// 2-6. HTML head
|
||
let html = '';
|
||
try {
|
||
const r = await getProxySession().fetch(origin + '/', fetchOpts);
|
||
if (r.ok) html = await r.text();
|
||
} catch (_) {}
|
||
|
||
if (html) {
|
||
try {
|
||
const $ = cheerio.load(html);
|
||
|
||
// apple-touch-icon
|
||
const apple = $('link[rel~="apple-touch-icon"], link[rel~="apple-touch-icon-precomposed"]')
|
||
.toArray()
|
||
.map(el => ({ href: $(el).attr('href'), size: parseSizes($(el).attr('sizes')) || 180 }))
|
||
.filter(x => x.href)
|
||
.sort((a, b) => b.size - a.size);
|
||
if (apple.length) return abs(apple[0].href);
|
||
|
||
// link rel=icon с явным размером ≥48
|
||
const icons = $('link[rel~="icon"], link[rel="shortcut icon"]')
|
||
.toArray()
|
||
.map(el => ({ href: $(el).attr('href'), size: parseSizes($(el).attr('sizes')) }))
|
||
.filter(x => x.href && x.size >= 48)
|
||
.sort((a, b) => b.size - a.size);
|
||
if (icons.length) return abs(icons[0].href);
|
||
|
||
// JSON-LD Organization.logo
|
||
const jsonLd = $('script[type="application/ld+json"]').toArray();
|
||
for (const el of jsonLd) {
|
||
try {
|
||
const data = JSON.parse($(el).contents().text());
|
||
const items = Array.isArray(data) ? data : [data];
|
||
for (const item of items) {
|
||
const logo = item?.logo || item?.publisher?.logo;
|
||
const logoUrl = typeof logo === 'string' ? logo : logo?.url;
|
||
if (logoUrl) return abs(logoUrl);
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
// og:image (может быть hero, но обычно крупная и фирменная)
|
||
const og = $('meta[property="og:image:secure_url"]').attr('content')
|
||
|| $('meta[property="og:image"]').attr('content');
|
||
if (og) return abs(og);
|
||
|
||
// msapplication-TileImage
|
||
const tile = $('meta[name="msapplication-TileImage"]').attr('content');
|
||
if (tile) return abs(tile);
|
||
|
||
// link rel=icon без указанного размера (обычно низкое качество — но всё ещё лучше /favicon.ico)
|
||
const anyIcon = $('link[rel~="icon"], link[rel="shortcut icon"]').first().attr('href');
|
||
if (anyIcon) return abs(anyIcon);
|
||
} catch (e) {
|
||
console.warn('[detect-logo] cheerio parse error:', e.message);
|
||
}
|
||
}
|
||
|
||
// 7. favicon.ico fallback — но только если реально картинка (часть сайтов отдаёт 403/HTML за бот-стеной).
|
||
try {
|
||
const r = await getProxySession().fetch(origin + '/favicon.ico', { method: 'HEAD', ...fetchOpts });
|
||
if (r.ok && (r.headers.get('content-type') || '').startsWith('image/')) {
|
||
return origin + '/favicon.ico';
|
||
}
|
||
} catch (_) {}
|
||
|
||
return ''; // ничего нормального не нашлось — UI покажет букву-заглушку
|
||
}
|
||
|
||
ipcMain.handle('detect-logo', async (_event, siteUrl) => {
|
||
try {
|
||
const logo = await detectSiteLogo(siteUrl);
|
||
console.log(`[detect-logo] ${siteUrl} → ${logo}`);
|
||
return logo;
|
||
} catch (e) {
|
||
console.warn('[detect-logo] error:', e.message);
|
||
return '';
|
||
}
|
||
});
|
||
|
||
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();
|
||
});
|