905 lines
34 KiB
JavaScript
905 lines
34 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 CONFIG_PATH = path.join(os.homedir(), '.ESH-Media.json');
|
||
const BLOCKER_CACHE_PATH = path.join(os.homedir(), '.ESH-Media-adblock-v2.bin');
|
||
const DEFAULT_CONFIG = { apps: [], proxy: { host: '127.0.0.1', port: '7890' } };
|
||
|
||
let blockerPromise = null;
|
||
|
||
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');
|
||
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);
|
||
}
|
||
}
|
||
|
||
// --- Window ---
|
||
|
||
async function createWindow() {
|
||
mainWindow = new BrowserWindow({
|
||
width: 1280,
|
||
height: 800,
|
||
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 });
|
||
const html = `<html><head><style>
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{background:#111;display:flex;align-items:center;justify-content:center;height:100vh;transition:opacity 0.25s ease;opacity:0}
|
||
body.visible{opacity:1}
|
||
.spinner{width:36px;height:36px;border:3px solid rgba(255,255,255,0.1);border-top-color:#E50914;border-radius:50%;animation:spin 0.7s linear infinite}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
</style></head><body>
|
||
<div class="spinner"></div>
|
||
<script>requestAnimationFrame(()=>requestAnimationFrame(()=>document.body.classList.add('visible')))</script>
|
||
</body></html>`;
|
||
loaderView.webContents.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(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) ---
|
||
|
||
const DIALOG_STYLES = `
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{
|
||
background:rgba(0,0,0,0);
|
||
display:flex;align-items:center;justify-content:center;
|
||
height:100vh;
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
transition:background 0.22s ease;
|
||
}
|
||
body.visible{background:rgba(0,0,0,0.78)}
|
||
body.hiding{background:rgba(0,0,0,0)}
|
||
.card{
|
||
background:#1c1c1c;border:1px solid rgba(255,255,255,0.1);
|
||
border-radius:12px;padding:32px 36px;text-align:center;
|
||
min-width:300px;max-width:420px;box-shadow:0 24px 64px rgba(0,0,0,0.8);
|
||
opacity:0;transform:scale(0.92) translateY(10px);
|
||
transition:opacity 0.22s ease,transform 0.22s cubic-bezier(0.34,1.56,0.64,1);
|
||
}
|
||
body.visible .card{opacity:1;transform:scale(1) translateY(0)}
|
||
body.hiding .card{opacity:0;transform:scale(0.95) translateY(6px)}
|
||
.title{font-size:17px;font-weight:700;color:#fff;margin-bottom:10px}
|
||
.msg{font-size:13px;color:#999;line-height:1.5;margin-bottom:26px}
|
||
.btns{display:flex;gap:10px;justify-content:center}
|
||
button{padding:10px 26px;border:none;border-radius:7px;font-size:13px;font-weight:600;cursor:pointer;transition:opacity 0.15s,transform 0.1s}
|
||
button:hover{opacity:0.85} button:active{transform:scale(0.97)}
|
||
.btn-yes{background:#E50914;color:#fff}
|
||
.btn-no,.btn-ok{background:rgba(255,255,255,0.1);color:#ccc}
|
||
`;
|
||
|
||
function dialogFadeIn(view) {
|
||
view.webContents.executeJavaScript(
|
||
`requestAnimationFrame(()=>requestAnimationFrame(()=>document.body.classList.add('visible')))`
|
||
).catch(() => {});
|
||
}
|
||
|
||
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 html = `<html><head><style>${DIALOG_STYLES}</style></head><body>
|
||
<div class="card">
|
||
<div class="title">${title}</div>
|
||
<div class="msg">${text}</div>
|
||
<div class="btns"><button class="btn-ok" onclick="window.electron&&window.electron.handleAction('error')">Закрыть</button></div>
|
||
</div>
|
||
</body></html>`;
|
||
view.webContents.once('did-finish-load', () => { addChild(view); dialogFadeIn(view); });
|
||
view.webContents.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html));
|
||
}
|
||
|
||
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 html = `<html><head><style>${DIALOG_STYLES}</style></head><body>
|
||
<div class="card">
|
||
<div class="msg">${text}</div>
|
||
<div class="btns">
|
||
<button class="btn-yes" onclick="window.electron&&window.electron.handleAction('confirmYes')">Да</button>
|
||
<button class="btn-no" onclick="window.electron&&window.electron.handleAction('confirmNo')">Нет</button>
|
||
</div>
|
||
</div>
|
||
</body></html>`;
|
||
view.webContents.once('did-finish-load', () => { addChild(view); dialogFadeIn(view); });
|
||
view.webContents.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html));
|
||
}
|
||
|
||
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');
|
||
}
|
||
|
||
// --- 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, 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 });
|
||
};
|
||
|
||
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) {
|
||
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 }) => {
|
||
let newHostname = '';
|
||
try { newHostname = new URL(newUrl).hostname; } catch (_) {}
|
||
if (origHostname && newHostname && newHostname !== origHostname) {
|
||
pendingNavigate = { view, url: newUrl };
|
||
setConfirm(`Перейти на "${newHostname}"?`, 'navigate-confirmed');
|
||
return { action: 'deny' };
|
||
}
|
||
trackNavigation(newUrl);
|
||
view.webContents.loadURL(newUrl);
|
||
return { action: 'deny' };
|
||
});
|
||
|
||
sendOpenedApps(name);
|
||
mainWindow.webContents.send('updateWebButtons', { history: appEntry.history, historyPosition: appEntry.historyPosition });
|
||
|
||
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;
|
||
});
|
||
|
||
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 });
|
||
});
|
||
|
||
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, genreId, year, minRating, country, page }) => {
|
||
try {
|
||
const isBearer = apiKey.startsWith('eyJ');
|
||
const type = mediaType === 'tv' ? 'tv' : 'movie';
|
||
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 (genreId) params.set('with_genres', String(genreId));
|
||
if (year) {
|
||
if (type === 'movie') params.set('primary_release_year', year);
|
||
else params.set('first_air_date_year', year);
|
||
}
|
||
if (minRating) params.set('vote_average.gte', minRating);
|
||
if (country) {
|
||
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 lang = COUNTRY_LANG[country];
|
||
if (lang) params.set('with_original_language', lang);
|
||
else params.set('with_origin_country', country);
|
||
}
|
||
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 { error: `TMDB ${resp.status}`, results: [], totalPages: 1 };
|
||
const data = await resp.json();
|
||
const results = (data.results || []).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(data.total_pages || 1, 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');
|
||
} catch (e) {
|
||
console.warn('Failed to write config:', e.message);
|
||
}
|
||
});
|
||
|
||
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 () => {
|
||
// 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
|
||
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);
|
||
enableBlockingInSession(session.fromPartition('persist:proxy'));
|
||
enableBlockingInSession(session.fromPartition('persist:direct'));
|
||
await loadExtensions();
|
||
await createWindow();
|
||
});
|
||
|
||
app.on('window-all-closed', () => {
|
||
if (process.platform !== 'darwin') app.quit();
|
||
});
|
||
|
||
app.on('activate', async () => {
|
||
if (BrowserWindow.getAllWindows().length === 0) await createWindow();
|
||
});
|