8 Commits

Author SHA1 Message Date
82f7fa7545 fix(1.0.9): revert OAuth popup BrowserWindow — Google detects embedded popups
1.0.1 ("trusted-domains OAuth popups") changed setWindowOpenHandler to
return action:'allow' with overrideBrowserWindowOptions for trusted
domains (Google, Yandex, etc.), opening a real Electron BrowserWindow
as popup. The reasoning was that OAuth flows need window.opener +
postMessage. That's correct for some flows but wrong for YouTube-style
login, which uses straight redirect.

Worse: Google specifically detects popup-style embedded browsers
(Electron BrowserWindow has distinct fingerprint vs real Chrome popup)
and blocks them with "Возможно, этот браузер небезопасны". The user
reported this stopped working after 1.0.0 — that's why.

Restore the 1.0.0 behavior for trusted domains: deny the popup and call
view.webContents.loadURL(newUrl) in the same view. The OAuth flow now
happens as a normal in-place navigation: YouTube → accounts.google.com
→ (user logs in) → redirect back to YouTube. No popup, no fingerprint
mismatch. The only UX loss is the popup window aesthetic; behavior is
functionally identical and matches what worked in 1.0.0.

Untrusted cross-domain still asks for confirmation, same-origin popups
still navigate in-place — unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:31:37 +03:00
b5e1296a7a fix(1.0.8): strip cliqz adblocker preload — breaks CSP-strict sites
cliqz/adblocker-electron registers its preload at session level via
session.setPreloads([...preloads, PRELOAD_PATH]) inside
enableBlockingInSession. That preload injects inline <script> elements
via doc.createElement('script') + script.appendChild(textNode) +
parent.appendChild(script). On modern strict-CSP sites this breaks:

- Trusted Types (YouTube, Gmail): "An HTMLScriptElement was directly
  modified and will not be executed" — 52+ console errors.
- Nonce-required CSP (kinogo via Cloudflare): "Refused to execute inline
  script ... script-src 'nonce-...'" — competing with Cloudflare's
  challenge JS, likely the proximate cause of the 403 we see on kinogo
  (CF treats the broken page as bot).

Remove the cliqz preload from each session immediately after
enableBlockingInSession. The network/CSP/blockers attached via
webRequest hooks remain active — only the script-injection layer for
anti-anti-adblock scriptlets is lost, which is a niche feature that
breaks more sites than it fixes.

The 1.0.7 Blink TrustedDOMTypes disable stays (defense in depth, no
cost). The 1.0.6 CSP-header strip stays removed (adblocker overwrites
the webRequest listener anyway).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:24:47 +03:00
e80704c534 fix(1.0.7): disable Trusted Types engine-wide via Blink feature flag
The 1.0.6 fix (strip require-trusted-types-for from CSP via
onHeadersReceived) didn't take effect: cliqz/adblocker calls
session.webRequest.onHeadersReceived during enableBlockingInSession,
overwriting our hook (Electron permits only one listener per session).

Replace with engine-level kill switch:
  app.commandLine.appendSwitch('disable-blink-features', 'TrustedDOMTypes')

Makes the entire Trusted Types runtime feature inert, so
require-trusted-types-for CSP becomes a no-op site-wide. Safe in this
kiosk/single-user context; only relaxes one security boundary that
sites use to harden against XSS via adblocker-style script injection —
which is exactly what we need to neutralize for cliqz's anti-anti-adblock
scriptlets on YouTube.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:14:19 +03:00
c9c9e1171b fix(1.0.6): strip Trusted Types CSP on YouTube/Google to unbreak adblocker
YouTube response sends Content-Security-Policy: require-trusted-types-for
'script' which blocks the cliqz adblocker's inline-script injection used
to neutralize YT's anti-adblock detection (52 "HTMLScriptElement was
directly modified and will not be executed" console errors).

Strip require-trusted-types-for and trusted-types directives from CSP
and CSP-Report-Only headers for youtube.com / youtu.be / google.com /
gmail.com (and subdomains) via onHeadersReceived on all 3 sessions.
Other CSP directives stay intact so site-level security boundaries hold.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:01:26 +03:00
461e7ed737 fix(1.0.5): revert Sec-CH-UA spoof (white pages), add DevTools shortcut
- Sec-CH-UA / Sec-CH-UA-Mobile / Sec-CH-UA-Platform header overrides on
  every request in 1.0.4 broke page rendering (all views white). Reverted
  to image-Referer-only behavior from 1.0.3. The Google "embedded browser"
  fix in 1.0.4 came primarily from the adblock whitelist (which IS kept)
  — Sec-CH-UA spoofing was the suspect for the regression.
- Ctrl+Shift+I and F12 now open DevTools on the main shell and on every
  in-app browser view. Always-on so kiosk machines can be debugged
  without leaving kiosk mode.
- Restore session sequenced (await 150ms between tabs) to avoid concurrent
  create-view races where multiple setLoader/addChild interleaved.
- Update banner now shows error state with a "Повторить" button instead
  of hiding it, so install-update failures are visible to the user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:24:38 +03:00
542be8135a fix: whitelist Google/OAuth domains in adblock, spoof Sec-CH-UA brand
Two-part fix for Google login "Возможно, этот браузер небезопасны" error:

1. The adblocker was eating Google integrity-check resources (gstatic,
   google-analytics, googletagmanager — flagged by EasyPrivacy). Add @@
   whitelist filters for Google, Yandex, Microsoft, Apple, Facebook,
   GitHub, VK, Mail.ru ecosystems. Also switch from non-existent
   addFilters() to updateFromDiff({added}) — previous TMDB whitelist was
   silently failing in a then().catch() and never applied. Adblock cache
   bumped to v3 so the new filters take effect.
2. Sec-CH-UA client-hints branding was leaking Electron app name as the
   browser brand. Override sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform
   headers via webRequest.onBeforeSendHeaders on all 3 sessions so
   embedded-browser detectors see real-Chrome brand list.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:42:25 +03:00
10361cf3c0 fix: strip Electron UA tokens, refresh bookmark star on in-app navigation
- main.js: app.userAgentFallback cleaned of Electron/X.X and ESH-Media/X.X
  tokens at startup; applied to default/proxy/direct sessions. Google's
  accounts page blocked Electron UA with "Поддержка JavaScript отключена".
- Header.tsx: subscribe currentUrl to updateWebButtons (already fired on
  each in-app navigation). Bookmark star now updates as user clicks
  between movies inside the same opened site, not only on app switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:27:26 +03:00
8 changed files with 691 additions and 124 deletions

418
main.js
View File

@@ -4,9 +4,20 @@ const fs = require('fs');
const os = require('os'); const os = require('os');
const cheerio = require('cheerio'); const cheerio = require('cheerio');
const { ElectronBlocker, adsAndTrackingLists } = require('@cliqz/adblocker-electron'); 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 CONFIG_PATH = path.join(os.homedir(), '.ESH-Media.json');
const BLOCKER_CACHE_PATH = path.join(os.homedir(), '.ESH-Media-adblock-v2.bin'); const BLOCKER_CACHE_PATH = path.join(os.homedir(), '.ESH-Media-adblock-v3.bin');
const DEFAULT_TRUSTED_DOMAINS = [ const DEFAULT_TRUSTED_DOMAINS = [
// Google ecosystem (OAuth) // Google ecosystem (OAuth)
'google.com', 'accounts.google.com', 'googleapis.com', 'googleusercontent.com', 'google.com', 'accounts.google.com', 'googleapis.com', 'googleusercontent.com',
@@ -71,8 +82,26 @@ function getBlocker() {
'https://easylist-downloads.adblockplus.org/ruadlist+easylist.txt', // RuAdList 'https://easylist-downloads.adblockplus.org/ruadlist+easylist.txt', // RuAdList
]; ];
const b = await ElectronBlocker.fromLists(fetchFn, [...adsAndTrackingLists, ...russianLists]); const b = await ElectronBlocker.fromLists(fetchFn, [...adsAndTrackingLists, ...russianLists]);
// Whitelist TMDB so the movie search API is not blocked // Whitelist domains that need ALL requests passed through unfiltered.
b.addFilters(['@@||api.themoviedb.org^', '@@||image.tmdb.org^', '@@||themoviedb.org^']); // 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())); fs.writeFileSync(BLOCKER_CACHE_PATH, Buffer.from(b.serialize()));
console.log('[adblock] filter lists downloaded and cached'); console.log('[adblock] filter lists downloaded and cached');
return b; return b;
@@ -82,7 +111,27 @@ function getBlocker() {
function enableBlockingInSession(sess) { function enableBlockingInSession(sess) {
getBlocker() getBlocker()
.then(b => { b.enableBlockingInSession(sess); console.log('[adblock] enabled for session'); }) .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)); .catch(e => console.warn('[adblock] failed to enable:', e.message));
} }
@@ -110,6 +159,7 @@ let pendingNavigate = null; // { view, url } — cross-domain redirect awaiting
function getProxySession() { function getProxySession() {
if (!proxySession) { if (!proxySession) {
proxySession = session.fromPartition('persist:proxy'); proxySession = session.fromPartition('persist:proxy');
proxySession.setUserAgent(app.userAgentFallback);
enableBlockingInSession(proxySession); enableBlockingInSession(proxySession);
} }
return proxySession; return proxySession;
@@ -118,6 +168,7 @@ function getProxySession() {
function getDirectSession() { function getDirectSession() {
if (!directSession) { if (!directSession) {
directSession = session.fromPartition('persist:direct'); directSession = session.fromPartition('persist:direct');
directSession.setUserAgent(app.userAgentFallback);
directSession.setProxy({ proxyRules: 'direct://' }); directSession.setProxy({ proxyRules: 'direct://' });
enableBlockingInSession(directSession); enableBlockingInSession(directSession);
} }
@@ -209,39 +260,124 @@ function compareSemver(a, b) {
return 0; 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() { async function checkForUpdates() {
if (updateCheckInFlight) return;
if (!app.isPackaged) {
console.log('[updater] dev mode, skipping');
return;
}
updateCheckInFlight = true;
try { try {
// Discover latest release via Gitea API (no auth, public endpoint)
const res = await getDirectSession().fetch( const res = await getDirectSession().fetch(
'https://gitea.esh-service.ru/api/v1/repos/public/ESH-Media/releases/latest' 'https://gitea.esh-service.ru/api/v1/repos/public/ESH-Media/releases/latest'
); );
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const latest = (data.tag_name || '').replace(/^v/, ''); const latestTag = (data.tag_name || '').replace(/^v/, '');
const current = app.getVersion(); const current = app.getVersion();
if (!latest || compareSemver(latest, current) <= 0) return; if (!latestTag || compareSemver(latestTag, current) <= 0) {
const assets = (data.assets || []).map(a => ({ name: a.name, url: a.browser_download_url })); console.log(`[updater] up to date (current=${current}, latest=${latestTag || 'none'})`);
// Prefer Windows installer (.exe) on Windows, AppImage/deb on Linux. Fall back to zip. return;
const isWin = process.platform === 'win32'; }
const isLinux = process.platform === 'linux'; // Verify latest.yml is present in this release's assets — without it autoUpdater can't proceed.
const installer = assets.find(a => { const hasYml = (data.assets || []).some(a => a.name === 'latest.yml');
const n = a.name.toLowerCase(); if (!hasYml) {
if (isWin) return n.endsWith('.exe'); console.warn(`[updater] release v${latestTag} has no latest.yml — falling back to manual download link`);
if (isLinux) return n.endsWith('.appimage') || n.endsWith('.deb'); const installer = (data.assets || []).find(a => a.name.toLowerCase().endsWith('.exe'));
return false; sendUpdateStatus({
}) || assets.find(a => a.name.toLowerCase().endsWith('.zip')); state: 'manual',
mainWindow.webContents.send('update-available', { version: latestTag,
version: latest, currentVersion: current,
currentVersion: current, installerUrl: installer?.browser_download_url || data.html_url,
releaseUrl: data.html_url, installerName: installer?.name || '',
installerUrl: installer?.url || data.html_url, });
installerName: installer?.name || '', return;
assets, }
}); // Point feedURL at this release's download directory
} catch (_) {} 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 --- // --- 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() { async function createWindow() {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1280, width: 1280,
@@ -255,6 +391,8 @@ async function createWindow() {
}, },
}); });
attachDevToolsShortcut(mainWindow.webContents);
if (isDev) { if (isDev) {
mainWindow.loadURL(RENDERER_URL); mainWindow.loadURL(RENDERER_URL);
} else { } else {
@@ -378,6 +516,62 @@ function removeView(name) {
app.view.webContents.destroy(); app.view.webContents.destroy();
if (currentView && currentView.name === name) currentView = null; if (currentView && currentView.name === name) currentView = null;
sendOpenedApps('home'); sendOpenedApps('home');
scheduleSessionSave();
}
// --- Session persistence (preserves opened tabs across restart, incl. auto-update relaunch) ---
let sessionSaveTimer = null;
function scheduleSessionSave() {
if (sessionSaveTimer) clearTimeout(sessionSaveTimer);
sessionSaveTimer = setTimeout(saveSessionNow, 500);
}
function saveSessionNow() {
sessionSaveTimer = null;
try {
const tabs = openedApps.map(a => ({
name: a.name,
url: a.history[a.historyPosition] || a.url, // latest URL the user was on, not the initial one
imageUrl: a.imageUrl || '',
useProxy: !!a.useProxy,
}));
const activeName = currentView?.name || 'home';
const cfg = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) : {};
cfg.openedSession = { tabs, activeName, savedAt: Date.now() };
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8');
} catch (e) {
console.warn('[session] save failed:', e.message);
}
}
async function restoreSession() {
try {
if (!fs.existsSync(CONFIG_PATH)) return;
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
const sess = cfg.openedSession;
if (!sess || !Array.isArray(sess.tabs) || !sess.tabs.length) return;
console.log(`[session] restoring ${sess.tabs.length} tab(s), active=${sess.activeName}`);
// Spawn each saved tab by replaying create-view, sequentially with a small delay.
// Concurrent create-view calls in v1.0.3 caused races: multiple setLoader/addChild
// interleaved → some views ended up unmounted (white screen). Spacing them out
// gives each view time to mount before the next steals currentView.
const fakeEvent = { sender: mainWindow.webContents };
for (const tab of sess.tabs) {
if (!tab?.name || !tab?.url) continue;
ipcMain.emit('create-view', fakeEvent, tab.name, tab.url, tab.imageUrl || '', 1.0, !!tab.useProxy);
await new Promise(r => setTimeout(r, 150));
}
// Switch to saved active if it isn't already the last-spawned (currentView).
if (sess.activeName === 'home') {
ipcMain.emit('hide-view', fakeEvent);
sendOpenedApps('home');
} else if (sess.activeName && sess.activeName !== currentView?.name) {
ipcMain.emit('show-view', fakeEvent, sess.activeName);
}
} catch (e) {
console.warn('[session] restore failed:', e.message);
}
} }
// --- IPC --- // --- IPC ---
@@ -408,10 +602,11 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
}, },
}); });
const appEntry = { name, url, imageUrl, view, history: [url], historyPosition: 0 }; const appEntry = { name, url, imageUrl, useProxy: !!useProxy, view, history: [url], historyPosition: 0 };
openedApps.push(appEntry); openedApps.push(appEntry);
currentView = appEntry; currentView = appEntry;
view.setBounds(getViewBounds()); view.setBounds(getViewBounds());
attachDevToolsShortcut(view.webContents);
view.webContents.on('did-finish-load', () => { view.webContents.on('did-finish-load', () => {
removeLoader(); removeLoader();
@@ -445,6 +640,7 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
app.history.push(navigatingUrl); app.history.push(navigatingUrl);
app.historyPosition = app.history.length - 1; app.historyPosition = app.history.length - 1;
mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition }); mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition });
scheduleSessionSave();
}; };
let origHostname = ''; let origHostname = '';
@@ -463,37 +659,30 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
trackNavigation(newUrl); trackNavigation(newUrl);
}); });
view.webContents.on('will-redirect', (_e, u) => trackNavigation(u)); view.webContents.on('will-redirect', (_e, u) => trackNavigation(u));
view.webContents.setWindowOpenHandler(({ url: newUrl, frameName, features }) => { view.webContents.setWindowOpenHandler(({ url: newUrl }) => {
let newHostname = ''; let newHostname = '';
try { newHostname = new URL(newUrl).hostname; } catch (_) {} try { newHostname = new URL(newUrl).hostname; } catch (_) {}
// Trusted domain → open as real popup BrowserWindow with same session. // Trusted domain (Google, Yandex, etc.) → navigate IN-PLACE, no popup.
// This is what OAuth flows need: window.opener.postMessage() works, // 1.0.1 tried opening a real popup BrowserWindow here for OAuth postMessage
// popup can close itself when done, parent stays on the original page. // flows — turns out Google specifically detects popup-style embedded
// browsers and blocks OAuth ("Возможно, этот браузер небезопасны").
// YouTube-style login uses standard redirect flow, so in-place navigation
// works AND avoids the popup fingerprint. 1.0.0 behavior, restored.
if (newHostname && isTrustedDomain(newHostname)) { if (newHostname && isTrustedDomain(newHostname)) {
return { trackNavigation(newUrl);
action: 'allow', view.webContents.loadURL(newUrl);
overrideBrowserWindowOptions: { return { action: 'deny' };
width: 520, height: 640,
parent: mainWindow,
autoHideMenuBar: true,
webPreferences: {
session: view.webContents.session,
contextIsolation: true,
nodeIntegration: false,
},
},
};
} }
// Untrusted cross-domain → ask the user (original behavior). // Untrusted cross-domain → ask the user.
if (origHostname && newHostname && newHostname !== origHostname) { if (origHostname && newHostname && newHostname !== origHostname) {
pendingNavigate = { view, url: newUrl }; pendingNavigate = { view, url: newUrl };
setConfirm(`Перейти на "${newHostname}"?`, 'navigate-confirmed'); setConfirm(`Перейти на "${newHostname}"?`, 'navigate-confirmed');
return { action: 'deny' }; return { action: 'deny' };
} }
// Same-origin popup → just navigate the current view. // Same-origin popup → navigate the current view.
trackNavigation(newUrl); trackNavigation(newUrl);
view.webContents.loadURL(newUrl); view.webContents.loadURL(newUrl);
return { action: 'deny' }; return { action: 'deny' };
@@ -501,6 +690,7 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
sendOpenedApps(name); sendOpenedApps(name);
mainWindow.webContents.send('updateWebButtons', { history: appEntry.history, historyPosition: appEntry.historyPosition }); mainWindow.webContents.send('updateWebButtons', { history: appEntry.history, historyPosition: appEntry.historyPosition });
scheduleSessionSave();
view.webContents.loadURL(url).catch(() => { view.webContents.loadURL(url).catch(() => {
removeView(name); removeView(name);
@@ -514,6 +704,7 @@ ipcMain.on('remove-view', (_event, name) => removeView(name || (currentView && c
ipcMain.on('hide-view', () => { ipcMain.on('hide-view', () => {
if (currentView && currentView.view) removeChild(currentView.view); if (currentView && currentView.view) removeChild(currentView.view);
currentView = null; currentView = null;
scheduleSessionSave();
}); });
ipcMain.on('show-view', (_event, name) => { ipcMain.on('show-view', (_event, name) => {
@@ -524,6 +715,7 @@ ipcMain.on('show-view', (_event, name) => {
addChild(app.view); addChild(app.view);
bringOverlaysToTop(); bringOverlaysToTop();
mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition }); mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition });
scheduleSessionSave();
}); });
let sidebarAnim = null; let sidebarAnim = null;
@@ -816,38 +1008,85 @@ ipcMain.handle('search-tmdb', async (_event, query, apiKey) => {
} }
}); });
ipcMain.handle('discover-tmdb', async (_event, { apiKey, mediaType, sortBy, genreId, year, minRating, country, page }) => { ipcMain.handle('discover-tmdb', async (_event, { apiKey, mediaType, sortBy, genreIds, years, minRating, countries, page }) => {
// Multi-filter semantics (per user request):
// genres → AND (movie must match ALL selected genres) — TMDB comma-join in with_genres
// countries→ OR (movie matches ANY selected country/lang) — TMDB pipe-join
// years → OR (movie released in ANY selected year) — fan-out: one request per year, merge
// rating → min threshold (single) — vote_average.gte
try { try {
const isBearer = apiKey.startsWith('eyJ'); const isBearer = apiKey.startsWith('eyJ');
const type = mediaType === 'tv' ? 'tv' : 'movie'; const type = mediaType === 'tv' ? 'tv' : 'movie';
const params = new URLSearchParams({ const genreArr = Array.isArray(genreIds) ? genreIds.filter(Boolean) : [];
language: 'ru-RU', const countryArr = Array.isArray(countries) ? countries.filter(Boolean) : [];
sort_by: sortBy || 'popularity.desc', const yearArr = Array.isArray(years) ? years.filter(Boolean) : [];
page: String(page || 1),
include_adult: 'false', const buildParams = (yearOverride) => {
}); const params = new URLSearchParams({
if (!isBearer) params.set('api_key', apiKey); language: 'ru-RU',
if (genreId) params.set('with_genres', String(genreId)); sort_by: sortBy || 'popularity.desc',
if (year) { page: String(page || 1),
if (type === 'movie') params.set('primary_release_year', year); include_adult: 'false',
else params.set('first_air_date_year', year); });
if (!isBearer) params.set('api_key', apiKey);
if (genreArr.length) params.set('with_genres', genreArr.join(',')); // AND
if (minRating) params.set('vote_average.gte', minRating);
if (countryArr.length) {
// Mix: codes that map to original_language go to with_original_language (pipe-OR),
// raw country codes go to with_origin_country (pipe-OR). Both fields can coexist.
const COUNTRY_LANG = {
RU: 'ru', JP: 'ja', KR: 'ko', CN: 'zh', FR: 'fr',
DE: 'de', IT: 'it', ES: 'es', SE: 'sv', DK: 'da', TR: 'tr', IN: 'hi',
};
const langs = [], origCountries = [];
for (const c of countryArr) {
if (COUNTRY_LANG[c]) langs.push(COUNTRY_LANG[c]);
else origCountries.push(c);
}
if (langs.length) params.set('with_original_language', langs.join('|'));
if (origCountries.length) params.set('with_origin_country', origCountries.join('|'));
}
if (yearOverride) {
if (type === 'movie') params.set('primary_release_year', String(yearOverride));
else params.set('first_air_date_year', String(yearOverride));
}
return params;
};
const fetchOnce = async (params) => {
const url = `https://api.themoviedb.org/3/discover/${type}?${params}`;
const headers = isBearer ? { 'Authorization': `Bearer ${apiKey}` } : {};
const resp = await getProxySession().fetch(url, { headers, signal: AbortSignal.timeout(8000) });
if (!resp.ok) return { results: [], totalPages: 0, status: resp.status };
const data = await resp.json();
return { results: data.results || [], totalPages: data.total_pages || 1, status: 200 };
};
let aggregated = [];
let maxPages = 1;
if (yearArr.length === 0) {
const r = await fetchOnce(buildParams(null));
if (r.status !== 200) return { error: `TMDB ${r.status}`, results: [], totalPages: 1 };
aggregated = r.results;
maxPages = r.totalPages;
} else {
// Fan out per year, merge by id (TMDB has no OR for discrete years)
const settled = await Promise.allSettled(yearArr.map(y => fetchOnce(buildParams(y))));
const seen = new Set();
for (const s of settled) {
if (s.status !== 'fulfilled' || s.value.status !== 200) continue;
maxPages = Math.max(maxPages, s.value.totalPages);
for (const r of s.value.results) {
if (seen.has(r.id)) continue;
seen.add(r.id);
aggregated.push(r);
}
}
// Re-sort merged set by popularity (since TMDB sorted within each year)
aggregated.sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
} }
if (minRating) params.set('vote_average.gte', minRating);
if (country) { const results = aggregated.map(r => ({
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, id: r.id,
mediaType: type, mediaType: type,
title: (type === 'movie' ? r.title : r.name) || '', title: (type === 'movie' ? r.title : r.name) || '',
@@ -857,7 +1096,7 @@ ipcMain.handle('discover-tmdb', async (_event, { apiKey, mediaType, sortBy, genr
overview: r.overview || '', overview: r.overview || '',
rating: r.vote_average ? r.vote_average.toFixed(1) : '', rating: r.vote_average ? r.vote_average.toFixed(1) : '',
})); }));
return { results, totalPages: Math.min(data.total_pages || 1, 500) }; return { results, totalPages: Math.min(maxPages, 500) };
} catch (e) { } catch (e) {
return { error: e.message, results: [], totalPages: 1 }; return { error: e.message, results: [], totalPages: 1 };
} }
@@ -981,7 +1220,23 @@ ipcMain.on('action', (_event, action) => {
// --- App lifecycle --- // --- App lifecycle ---
app.whenReady().then(async () => { app.whenReady().then(async () => {
// Add Referer to image requests so hotlink protection doesn't block them // Strip Electron/app tokens from User-Agent: Google blocks Electron's default UA
// with "Поддержка JavaScript отключена" on accounts.google.com. We keep the Chrome
// version Electron advertises (sufficient for modern features) but remove the
// Electron/X.X.X and ESH-Media/X.X.X identifiers.
const cleanUserAgent = app.userAgentFallback
.replace(/Electron\/[\d.]+\s*/g, '')
.replace(/ESH-Media\/[\d.]+\s*/g, '')
.replace(/\s+/g, ' ')
.trim();
console.log('[ua]', cleanUserAgent);
app.userAgentFallback = cleanUserAgent;
session.defaultSession.setUserAgent(cleanUserAgent);
// Add Referer to image requests so hotlink protection doesn't block them.
// (Sec-CH-UA spoofing was tried in 1.0.4 and caused white pages — reverted.
// Google embedded-browser detection is now mitigated only via adblock whitelist
// of gstatic/google-analytics/etc., which previously was being eaten silently.)
session.defaultSession.webRequest.onBeforeSendHeaders( session.defaultSession.webRequest.onBeforeSendHeaders(
{ urls: ['https://*/*', 'http://*/*'] }, { urls: ['https://*/*', 'http://*/*'] },
(details, callback) => { (details, callback) => {
@@ -996,6 +1251,10 @@ app.whenReady().then(async () => {
} }
); );
// (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 // Apply proxy from config before blocker tries to download filter lists
loadTrustedDomainsFromDisk(); loadTrustedDomainsFromDisk();
try { try {
@@ -1011,12 +1270,19 @@ app.whenReady().then(async () => {
await loadExtensions(); await loadExtensions();
await createWindow(); await createWindow();
mainWindow.webContents.once('did-finish-load', () => { mainWindow.webContents.once('did-finish-load', async () => {
await restoreSession();
setTimeout(checkForUpdates, 4000); setTimeout(checkForUpdates, 4000);
setInterval(checkForUpdates, 60 * 60 * 1000); // re-check hourly for long-running kiosk sessions 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', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit(); if (process.platform !== 'darwin') app.quit();
}); });

104
package-lock.json generated
View File

@@ -1,15 +1,16 @@
{ {
"name": "ESH-Media", "name": "ESH-Media",
"version": "1.0.0", "version": "1.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ESH-Media", "name": "ESH-Media",
"version": "1.0.0", "version": "1.0.2",
"dependencies": { "dependencies": {
"@cliqz/adblocker-electron": "^1.34.0", "@cliqz/adblocker-electron": "^1.34.0",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"electron-updater": "^6.8.3",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
@@ -2305,7 +2306,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/assert-plus": { "node_modules/assert-plus": {
@@ -3666,6 +3666,82 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/electron-updater": {
"version": "6.8.3",
"resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz",
"integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==",
"license": "MIT",
"dependencies": {
"builder-util-runtime": "9.5.1",
"fs-extra": "^10.1.0",
"js-yaml": "^4.1.0",
"lazy-val": "^1.0.5",
"lodash.escaperegexp": "^4.1.2",
"lodash.isequal": "^4.5.0",
"semver": "~7.7.3",
"tiny-typed-emitter": "^2.1.0"
}
},
"node_modules/electron-updater/node_modules/builder-util-runtime": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz",
"integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.4",
"sax": "^1.2.4"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/electron-updater/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/electron-updater/node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/electron-updater/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/electron-updater/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -4620,7 +4696,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@@ -4697,7 +4772,6 @@
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lazystream": { "node_modules/lazystream": {
@@ -4773,6 +4847,12 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/lodash.escaperegexp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
"license": "MIT"
},
"node_modules/lodash.flatten": { "node_modules/lodash.flatten": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
@@ -4781,6 +4861,13 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.isplainobject": { "node_modules/lodash.isplainobject": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
@@ -5559,7 +5646,6 @@
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz",
"integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": ">=11.0.0" "node": ">=11.0.0"
@@ -5953,6 +6039,12 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/tiny-typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
"license": "MIT"
},
"node_modules/tldts-core": { "node_modules/tldts-core": {
"version": "6.1.86", "version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "ESH-Media", "name": "ESH-Media",
"version": "1.0.1", "version": "1.0.9",
"private": true, "private": true,
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
@@ -16,6 +16,7 @@
"dependencies": { "dependencies": {
"@cliqz/adblocker-electron": "^1.34.0", "@cliqz/adblocker-electron": "^1.34.0",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"electron-updater": "^6.8.3",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
@@ -46,7 +47,10 @@
"extensions/**/*" "extensions/**/*"
], ],
"win": { "win": {
"target": ["nsis", "zip"], "target": [
"nsis",
"zip"
],
"icon": "public/favicon.ico" "icon": "public/favicon.ico"
}, },
"nsis": { "nsis": {
@@ -54,9 +58,18 @@
"allowToChangeInstallationDirectory": true "allowToChangeInstallationDirectory": true
}, },
"linux": { "linux": {
"target": ["AppImage", "deb"], "target": [
"AppImage",
"deb"
],
"icon": "public/logo.png", "icon": "public/logo.png",
"category": "Utility" "category": "Utility"
} },
"publish": [
{
"provider": "generic",
"url": "https://gitea.esh-service.ru/public/ESH-Media/releases/download/latest/"
}
]
} }
} }

View File

@@ -22,6 +22,8 @@ contextBridge.exposeInMainWorld('electron', {
refreshPage: () => ipcRenderer.send('refreshPage'), refreshPage: () => ipcRenderer.send('refreshPage'),
getCurrentPage: () => ipcRenderer.invoke('get-current-page'), getCurrentPage: () => ipcRenderer.invoke('get-current-page'),
getPageMeta: () => ipcRenderer.invoke('get-page-meta'), getPageMeta: () => ipcRenderer.invoke('get-page-meta'),
installUpdate: () => ipcRenderer.invoke('install-update'),
checkUpdateNow: () => ipcRenderer.invoke('check-update-now'),
readConfig: () => ipcRenderer.invoke('read-config'), readConfig: () => ipcRenderer.invoke('read-config'),
writeConfig: (data) => ipcRenderer.send('write-config', data), writeConfig: (data) => ipcRenderer.send('write-config', data),
searchMovies: (query, sites) => ipcRenderer.invoke('search-movies', query, sites), searchMovies: (query, sites) => ipcRenderer.invoke('search-movies', query, sites),

View File

@@ -21,6 +21,7 @@ const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
const [rightDisabled, setRightDisabled] = useState(true) const [rightDisabled, setRightDisabled] = useState(true)
const [refreshDisabled, setRefreshDisabled] = useState(true) const [refreshDisabled, setRefreshDisabled] = useState(true)
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const [currentUrl, setCurrentUrl] = useState<string>('')
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const activeAppRef = useRef(activeApp) const activeAppRef = useRef(activeApp)
useEffect(() => { activeAppRef.current = activeApp }, [activeApp]) useEffect(() => { activeAppRef.current = activeApp }, [activeApp])
@@ -36,6 +37,7 @@ const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
setLeftDisabled(app.historyPosition === 0) setLeftDisabled(app.historyPosition === 0)
setRightDisabled(app.historyPosition === app.history.length - 1) setRightDisabled(app.historyPosition === app.history.length - 1)
setRefreshDisabled(false) setRefreshDisabled(false)
setCurrentUrl(app.history[app.historyPosition] || '')
}) })
return () => { offCloseApp(); offWebButtons() } return () => { offCloseApp(); offWebButtons() }
}, [setActiveApp]) }, [setActiveApp])
@@ -108,12 +110,24 @@ const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
const showSearchIcon = activeApp === 'home' || activeApp === 'movie-search' const showSearchIcon = activeApp === 'home' || activeApp === 'movie-search'
const [isKiosk, setIsKiosk] = useState(true) const [isKiosk, setIsKiosk] = useState(true)
const [updateInfo, setUpdateInfo] = useState<{ version: string; currentVersion?: string; releaseUrl: string; installerUrl: string; installerName?: string } | null>(null) type UpdateStatus =
| { state: 'available'; version: string; currentVersion?: string }
| { state: 'downloading'; percent: number; bytesPerSecond?: number; transferred?: number; total?: number; version?: string; currentVersion?: string }
| { state: 'ready'; version: string; currentVersion?: string }
| { state: 'manual'; version: string; currentVersion?: string; installerUrl: string; installerName?: string }
| { state: 'error'; message: string }
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
useEffect(() => { useEffect(() => {
if (!window.electron) return if (!window.electron) return
const off = window.electron.on('update-available', (info: { version: string; currentVersion?: string; releaseUrl: string; installerUrl: string; installerName?: string }) => { const off = window.electron.on('update-status', (info: UpdateStatus) => {
setUpdateInfo(info) setUpdateStatus(prev => {
// Preserve version across state transitions when payload omits it (download-progress)
if (info.state === 'downloading' && prev && 'version' in prev && prev.version) {
return { ...info, version: info.version || prev.version, currentVersion: info.currentVersion || ('currentVersion' in prev ? prev.currentVersion : undefined) }
}
return info
})
}) })
return off return off
}, []) }, [])
@@ -149,12 +163,9 @@ const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
} }
useEffect(() => { useEffect(() => {
if (!appOpen) { setIsBookmarked(false); return } if (!appOpen || !currentUrl) { setIsBookmarked(false); return }
window.electron?.getCurrentPage().then((page: any) => { setIsBookmarked(bookmarks.some(b => b.url === currentUrl))
if (!page) return }, [currentUrl, bookmarks, appOpen])
setIsBookmarked(bookmarks.some(b => b.url === page.url))
})
}, [activeApp, bookmarks, appOpen])
return ( return (
<> <>
@@ -279,13 +290,47 @@ const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
)} )}
</div> </div>
{updateInfo && ( {updateStatus && (
<div className="update-banner"> <div className={`update-banner${updateStatus.state === 'error' ? ' error' : ''}`}>
<span>Доступна версия {updateInfo.version}{updateInfo.currentVersion ? ` (текущая ${updateInfo.currentVersion})` : ''}</span> {updateStatus.state === 'available' && (
<button className="update-banner-btn" onClick={() => window.electron?.createView('Обновление', updateInfo.installerUrl, '', 1.0, false)}> <>
{updateInfo.installerName ? 'Скачать установщик' : 'Открыть релиз'} <span className="update-banner-spinner" />
</button> <span>Загружается обновление {updateStatus.version}{updateStatus.currentVersion ? ` (текущая ${updateStatus.currentVersion})` : ''}</span>
<button className="update-banner-close" onClick={() => setUpdateInfo(null)}></button> </>
)}
{updateStatus.state === 'error' && (
<>
<span>Ошибка обновления: {updateStatus.message}</span>
<button className="update-banner-btn" onClick={() => window.electron?.checkUpdateNow?.()}>
Повторить
</button>
</>
)}
{updateStatus.state === 'downloading' && (
<>
<span>Скачивается {updateStatus.version || 'обновление'}: {updateStatus.percent}%</span>
<div className="update-banner-progress">
<div className="update-banner-progress-bar" style={{ width: `${updateStatus.percent}%` }} />
</div>
</>
)}
{updateStatus.state === 'ready' && (
<>
<span>Версия {updateStatus.version} готова к установке</span>
<button className="update-banner-btn" onClick={() => window.electron?.installUpdate?.()}>
Установить и перезапустить
</button>
</>
)}
{updateStatus.state === 'manual' && (
<>
<span>Доступна {updateStatus.version}{updateStatus.currentVersion ? ` (текущая ${updateStatus.currentVersion})` : ''}</span>
<button className="update-banner-btn" onClick={() => window.electron?.createView('Обновление', updateStatus.installerUrl, '', 1.0, false)}>
Скачать установщик
</button>
</>
)}
<button className="update-banner-close" onClick={() => setUpdateStatus(null)}></button>
</div> </div>
)} )}

View File

@@ -45,6 +45,72 @@ const Select: React.FC<{
) )
} }
// Multi-select dropdown with checkboxes. Trigger shows N selected or placeholder.
const MultiSelect: React.FC<{
values: string[]
onChange: (v: string[]) => void
options: { value: string; label: string }[]
placeholder: string
maxHeight?: number
}> = ({ values, onChange, options, placeholder, maxHeight = 260 }) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const toggle = (v: string) => {
onChange(values.includes(v) ? values.filter(x => x !== v) : [...values, v])
}
const label = values.length === 0
? placeholder
: values.length === 1
? (options.find(o => o.value === values[0])?.label ?? values[0])
: `${placeholder}: ${values.length}`
return (
<div ref={ref} className={`ms-select${open ? ' open' : ''}`}>
<div className="ms-select-trigger" onClick={() => setOpen(o => !o)}>
<span className={values.length > 0 ? 'ms-select-active' : 'ms-select-placeholder'}>{label}</span>
{values.length > 0 && (
<button className="ms-multi-clear" onClick={e => { e.stopPropagation(); onChange([]) }} title="Сбросить">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
{open && (
<div className="ms-select-dropdown ms-multi-dropdown" style={{ maxHeight }} onClick={e => e.stopPropagation()}>
{options.map(o => {
const on = values.includes(o.value)
return (
<div key={o.value} className={`ms-select-opt ms-multi-opt${on ? ' active' : ''}`} onClick={() => toggle(o.value)}>
<span className={`ms-checkbox${on ? ' on' : ''}`}>{on && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}</span>
{o.label}
</div>
)
})}
</div>
)}
</div>
)
}
interface TmdbMovie { interface TmdbMovie {
id: number id: number
mediaType: 'movie' | 'tv' mediaType: 'movie' | 'tv'
@@ -159,10 +225,10 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
const [query, setQuery] = useState(initialQuery) const [query, setQuery] = useState(initialQuery)
const [mediaType, setMediaType] = useState<'movie' | 'tv'>('movie') const [mediaType, setMediaType] = useState<'movie' | 'tv'>('movie')
const [sortBy, setSortBy] = useState('popularity.desc') const [sortBy, setSortBy] = useState('popularity.desc')
const [genreId, setGenreId] = useState<number | null>(null) const [genreIds, setGenreIds] = useState<number[]>([])
const [year, setYear] = useState('') const [years, setYears] = useState<string[]>([])
const [minRating, setMinRating] = useState('') const [minRating, setMinRating] = useState('')
const [country, setCountry] = useState('') const [countries, setCountries] = useState<string[]>([])
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1) const [totalPages, setTotalPages] = useState(1)
@@ -214,7 +280,7 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
if (append) setLoadingMore(true) if (append) setLoadingMore(true)
else { setTmdbLoading(true); setMessage('') } else { setTmdbLoading(true); setMessage('') }
try { try {
const res = await window.electron!.discoverTmdb({ apiKey: key, mediaType, sortBy, genreId, year, minRating, country, page: pg }) const res = await window.electron!.discoverTmdb({ apiKey: key, mediaType, sortBy, genreIds, years, minRating, countries, page: pg })
if (token !== discoverRef.current) return if (token !== discoverRef.current) return
if (res.error) { setMessage(`Ошибка: ${res.error}`); return } if (res.error) { setMessage(`Ошибка: ${res.error}`); return }
setTmdbResults(prev => append ? [...prev, ...res.results] : res.results) setTmdbResults(prev => append ? [...prev, ...res.results] : res.results)
@@ -225,14 +291,14 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
} finally { } finally {
if (token === discoverRef.current) { setTmdbLoading(false); setLoadingMore(false) } if (token === discoverRef.current) { setTmdbLoading(false); setLoadingMore(false) }
} }
}, [mediaType, sortBy, genreId, year, minRating, country]) }, [mediaType, sortBy, genreIds, years, minRating, countries])
useEffect(() => { useEffect(() => {
if (!configLoaded || !apiKey || isSearchMode) return if (!configLoaded || !apiKey || isSearchMode) return
setPage(1) setPage(1)
setTmdbResults([]) setTmdbResults([])
doDiscover(apiKey, 1, false) doDiscover(apiKey, 1, false)
}, [configLoaded, apiKey, mediaType, sortBy, genreId, year, minRating, country, isSearchMode]) }, [configLoaded, apiKey, mediaType, sortBy, genreIds, years, minRating, countries, isSearchMode])
const searchRef = useRef(0) const searchRef = useRef(0)
const doTmdbSearch = async (q: string, key: string) => { const doTmdbSearch = async (q: string, key: string) => {
@@ -377,11 +443,11 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
<div className="ms-type-toggle"> <div className="ms-type-toggle">
<button <button
className={`ms-type-btn${mediaType === 'movie' ? ' active' : ''}`} className={`ms-type-btn${mediaType === 'movie' ? ' active' : ''}`}
onClick={() => handleFilterChange(() => { setMediaType('movie'); setGenreId(null) })} onClick={() => handleFilterChange(() => { setMediaType('movie'); setGenreIds([]) })}
>Фильмы</button> >Фильмы</button>
<button <button
className={`ms-type-btn${mediaType === 'tv' ? ' active' : ''}`} className={`ms-type-btn${mediaType === 'tv' ? ' active' : ''}`}
onClick={() => handleFilterChange(() => { setMediaType('tv'); setGenreId(null) })} onClick={() => handleFilterChange(() => { setMediaType('tv'); setGenreIds([]) })}
>Сериалы</button> >Сериалы</button>
</div> </div>
@@ -390,7 +456,7 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
<Select value={sortBy} onChange={v => handleFilterChange(() => setSortBy(v))} options={SORTS} /> <Select value={sortBy} onChange={v => handleFilterChange(() => setSortBy(v))} options={SORTS} />
)} )}
{/* Min rating */} {/* Min rating (single, threshold) */}
{!isSearchMode && ( {!isSearchMode && (
<Select <Select
value={minRating} value={minRating}
@@ -400,34 +466,45 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
/> />
)} )}
{/* Year */} {/* Years (multi, OR) */}
<Select <MultiSelect
value={year} values={years}
onChange={v => handleFilterChange(() => setYear(v))} onChange={v => handleFilterChange(() => setYears(v))}
options={[{ value: '', label: 'Год' }, ...YEARS.map(y => ({ value: String(y), label: String(y) }))]} options={YEARS.map(y => ({ value: String(y), label: String(y) }))}
placeholder="Год" placeholder="Год"
/> />
{/* Country */} {/* Countries (multi, OR) */}
{!isSearchMode && ( {!isSearchMode && (
<Select value={country} onChange={v => handleFilterChange(() => setCountry(v))} options={COUNTRIES} placeholder="Страна" /> <MultiSelect
values={countries}
onChange={v => handleFilterChange(() => setCountries(v))}
options={COUNTRIES.filter(c => c.value).map(c => ({ value: c.value, label: c.label }))}
placeholder="Страна"
/>
)} )}
</div> </div>
{/* Genres */} {/* Genres (multi, AND — фильм должен соответствовать ВСЕМ выбранным жанрам) */}
{!isSearchMode && ( {!isSearchMode && (
<div className="ms-genres"> <div className="ms-genres">
<button <button
className={`ms-genre-chip${genreId === null ? ' active' : ''}`} className={`ms-genre-chip${genreIds.length === 0 ? ' active' : ''}`}
onClick={() => handleFilterChange(() => setGenreId(null))} onClick={() => handleFilterChange(() => setGenreIds([]))}
title="Сбросить жанры"
>Все</button> >Все</button>
{genres.map(g => ( {genres.map(g => (
<button <button
key={g.id} key={g.id}
className={`ms-genre-chip${genreId === g.id ? ' active' : ''}`} className={`ms-genre-chip${genreIds.includes(g.id) ? ' active' : ''}`}
onClick={() => handleFilterChange(() => setGenreId(g.id))} onClick={() => handleFilterChange(() =>
setGenreIds(prev => prev.includes(g.id) ? prev.filter(x => x !== g.id) : [...prev, g.id])
)}
>{g.name}</button> >{g.name}</button>
))} ))}
{genreIds.length > 1 && (
<span className="ms-genres-hint">все выбранные одновременно</span>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -25,7 +25,10 @@ declare global {
searchMovies: (query: string, sites: any[]) => Promise<any[]> searchMovies: (query: string, sites: any[]) => Promise<any[]>
searchTmdb: (query: string, apiKey: string) => Promise<{ results: any[]; error?: string }> searchTmdb: (query: string, apiKey: string) => Promise<{ results: any[]; error?: string }>
discoverTmdb: (params: any) => Promise<{ results: any[]; totalPages: number; error?: string }> discoverTmdb: (params: any) => Promise<{ results: any[]; totalPages: number; error?: string }>
toggleKiosk: () => void getPageMeta: () => Promise<{ poster: string; title: string; url: string } | null>
installUpdate: () => Promise<boolean>
checkUpdateNow: () => Promise<boolean>
toggleKiosk: () => Promise<boolean>
isKiosk: () => Promise<boolean> isKiosk: () => Promise<boolean>
} }
} }

View File

@@ -788,6 +788,44 @@ body {
.ms-genre-chip:hover { background: rgba(255,255,255,0.1); color: #ccc; } .ms-genre-chip:hover { background: rgba(255,255,255,0.1); color: #ccc; }
.ms-genre-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; } .ms-genre-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.ms-genres-hint {
font-size: 10px;
color: #666;
font-style: italic;
align-self: center;
margin-left: 6px;
}
/* Multi-select dropdown */
.ms-multi-dropdown { overflow-y: auto; }
.ms-multi-opt { display: flex; align-items: center; gap: 8px; }
.ms-multi-opt.active { color: #eee; font-weight: 500; }
.ms-checkbox {
width: 14px;
height: 14px;
border-radius: 3px;
border: 1.5px solid #555;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.ms-checkbox.on { background: var(--accent); border-color: var(--accent); }
.ms-multi-clear {
background: transparent;
border: none;
color: #777;
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
margin-left: auto;
margin-right: 2px;
}
.ms-multi-clear:hover { color: #fff; }
.ms-load-more { .ms-load-more {
display: block; display: block;
margin: 20px auto 8px; margin: 20px auto 8px;
@@ -1442,6 +1480,37 @@ body {
} }
.update-banner-close:hover { color: #fff; } .update-banner-close:hover { color: #fff; }
.update-banner.error {
border-color: rgba(229,9,20,0.5);
background: rgba(40,10,12,0.95);
}
.update-banner-progress {
flex: 1;
height: 4px;
background: rgba(255,255,255,0.08);
border-radius: 2px;
overflow: hidden;
min-width: 80px;
max-width: 200px;
}
.update-banner-progress-bar {
height: 100%;
background: linear-gradient(90deg, #E50914, #ff5252);
transition: width 0.3s ease;
}
.update-banner-spinner {
width: 12px;
height: 12px;
border: 2px solid rgba(255,255,255,0.15);
border-top-color: #E50914;
border-radius: 50%;
animation: ub-spin 0.8s linear infinite;
display: inline-block;
}
@keyframes ub-spin { to { transform: rotate(360deg); } }
/* ---- Retry btn ---- */ /* ---- Retry btn ---- */
.ms-retry-btn { .ms-retry-btn {
background: none; background: none;