10 Commits

Author SHA1 Message Date
103c2d1e09 feat(1.0.10): experimental chrome.* spoof for Google embedded-browser detection
Inject chrome.app, chrome.runtime, chrome.csi, chrome.loadTimes,
navigator.permissions.query wrapper via executeJavaScript on dom-ready
for every view. Goal: pass Google's JS-side embedded-browser detector
("Возможно, этот браузер небезопасны") by exposing the same chrome.*
shape real Chrome does.

Caveats acknowledged upfront:
- dom-ready fires AFTER <head> scripts, so detection scripts there have
  already seen the un-spoofed environment. Helps only if Google re-checks
  on form submit / later events.
- TLS fingerprint (JA3/JA4) is server-side. If Google flags us there,
  no client-side spoof works. This is a best-effort attempt.

No webPreferences changes — keeps contextIsolation:true and
sandbox-equivalent isolation intact. If this fails we lose nothing
architecturally and revert is trivial.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:45:49 +03:00
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
2857a40d1e feat: trusted-domains OAuth popups, og:image bookmark posters, periodic updater
- main.js: trusted-domains list with default Google/Yandex/GitHub/etc.;
  cross-domain confirmation skipped for trusted; setWindowOpenHandler
  returns action:'allow' for trusted so OAuth popups work (postMessage
  back to opener, popup self-closes). Fixes YouTube/Google login reset.
- main.js: get-page-meta IPC extracts og:image / twitter:image / JSON-LD
  image from current view; HDRezka also tries .b-sidecover img for hi-res.
- Header: bookmark button pulls og:image as poster and the page's title;
  duplicate detection switched from hostname to full URL so multiple
  movies from same site can coexist.
- BookmarksBar: site icon rendered next to source domain when distinct
  from poster; img onerror falls back to placeholder.
- Settings: trusted domains chip list with add/remove/reset.
- Updater: proper semver compare (only show if latest > current),
  direct installer URL detection per platform, hourly re-check.

Bookmark schema gains optional siteIcon; existing bookmarks remain valid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:56:12 +03:00
11 changed files with 1067 additions and 139 deletions

552
main.js
View File

@@ -4,12 +4,116 @@ 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_CONFIG = { apps: [], proxy: { host: '127.0.0.1', port: '7890' } }; const DEFAULT_TRUSTED_DOMAINS = [
// Google ecosystem (OAuth)
'google.com', 'accounts.google.com', 'googleapis.com', 'googleusercontent.com',
'gstatic.com', 'youtube.com', 'ytimg.com', 'googlevideo.com',
// Yandex
'yandex.ru', 'yandex.com', 'passport.yandex.ru', 'passport.yandex.com', 'yastatic.net',
// GitHub
'github.com', 'github.io', 'githubassets.com', 'githubusercontent.com',
// VK / Mail.ru
'vk.com', 'vk.ru', 'vkuser.net', 'mail.ru', 'my.mail.ru',
// Microsoft (login.live.com etc., некоторые сайты через них)
'live.com', 'microsoft.com', 'microsoftonline.com', 'office.com',
// Apple
'apple.com', 'icloud.com',
// Facebook (для соцлогина)
'facebook.com', 'fb.com',
];
const DEFAULT_CONFIG = { apps: [], proxy: { host: '127.0.0.1', port: '7890' }, trustedDomains: DEFAULT_TRUSTED_DOMAINS };
let blockerPromise = null; let blockerPromise = null;
let cachedTrustedDomains = DEFAULT_TRUSTED_DOMAINS;
// chrome.* spoof: injected via executeJavaScript on every page's dom-ready.
// Goal is to look like real Chrome to JS-based "embedded browser" detectors
// (Google login, etc.). Cannot fix TLS-fingerprint detection — that's server-side.
const CHROME_SPOOF_JS = `(function(){
try {
if (!window.chrome) window.chrome = {};
var c = window.chrome;
if (!c.app) c.app = {
isInstalled: false,
InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
getDetails: function(){ return null; },
getIsInstalled: function(){ return false; },
runningState: function(){ return 'cannot_run'; }
};
if (!c.runtime) c.runtime = {
PlatformOs: { MAC:'mac', WIN:'win', ANDROID:'android', CROS:'cros', LINUX:'linux', OPENBSD:'openbsd' },
PlatformArch: { ARM:'arm', X86_32:'x86-32', X86_64:'x86-64' },
PlatformNaclArch: { ARM:'arm', X86_32:'x86-32', X86_64:'x86-64' },
RequestUpdateCheckStatus: { NO_UPDATE:'no_update', THROTTLED:'throttled', UPDATE_AVAILABLE:'update_available' },
OnInstalledReason: { CHROME_UPDATE:'chrome_update', INSTALL:'install', SHARED_MODULE_UPDATE:'shared_module_update', UPDATE:'update' },
OnRestartRequiredReason: { APP_UPDATE:'app_update', OS_UPDATE:'os_update', PERIODIC:'periodic' },
sendMessage: function(){},
connect: function(){
return {
postMessage: function(){}, disconnect: function(){},
onDisconnect: { addListener: function(){}, removeListener: function(){} },
onMessage: { addListener: function(){}, removeListener: function(){} }
};
}
};
if (!c.csi) c.csi = function(){ return { startE: Date.now()-1000, onloadT: Date.now()-500, pageT: 1000, tran: 15 }; };
if (!c.loadTimes) c.loadTimes = function(){
var t = performance.timing;
return {
commitLoadTime: t.responseStart/1000, connectionInfo: 'http/1.1',
finishDocumentLoadTime: t.domContentLoadedEventEnd/1000,
finishLoadTime: (t.loadEventEnd/1000) || 0,
firstPaintAfterLoadTime: 0, firstPaintTime: t.responseEnd/1000,
navigationType: 'Other', npnNegotiatedProtocol: 'h2',
requestTime: t.requestStart/1000, startLoadTime: t.fetchStart/1000,
wasAlternateProtocolAvailable: false, wasFetchedViaSpdy: true, wasNpnNegotiated: true
};
};
// navigator.permissions.query: Notification permission must agree with Notification.permission
if (navigator.permissions && navigator.permissions.query) {
var origQuery = navigator.permissions.query.bind(navigator.permissions);
navigator.permissions.query = function(p){
if (p && p.name === 'notifications') return Promise.resolve({ state: Notification.permission, onchange: null });
return origQuery(p);
};
}
} catch (_) {}
})();`;
function loadTrustedDomainsFromDisk() {
try {
if (fs.existsSync(CONFIG_PATH)) {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
if (Array.isArray(cfg.trustedDomains) && cfg.trustedDomains.length) {
cachedTrustedDomains = cfg.trustedDomains;
}
}
} catch (_) {}
}
function isTrustedDomain(hostname) {
if (!hostname) return false;
const h = hostname.toLowerCase();
return cachedTrustedDomains.some(d => {
const dom = d.toLowerCase().replace(/^\./, '');
return h === dom || h.endsWith('.' + dom);
});
}
function getBlocker() { function getBlocker() {
if (blockerPromise) return blockerPromise; if (blockerPromise) return blockerPromise;
@@ -33,8 +137,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;
@@ -44,7 +166,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));
} }
@@ -72,6 +214,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;
@@ -80,6 +223,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);
} }
@@ -159,27 +303,136 @@ async function loadExtensions() {
// --- Updates --- // --- Updates ---
function compareSemver(a, b) {
// Returns 1 if a > b, -1 if a < b, 0 if equal. Numeric per-segment, missing → 0.
const pa = a.split('.').map(n => parseInt(n, 10) || 0);
const pb = b.split('.').map(n => parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const x = pa[i] || 0, y = pb[i] || 0;
if (x > y) return 1;
if (x < y) return -1;
}
return 0;
}
// --- Auto-update (electron-updater + Gitea API discovery) ---
//
// Gitea 1.24.7 doesn't expose /releases/latest/download/ as a stable URL shortcut,
// so we use the API to find the latest release at runtime, then point setFeedURL
// at THAT release's download directory. electron-updater fetches latest.yml from
// there and uses .blockmap files for differential downloads (saves ~70 MB per
// minor patch since most of the 80 MB installer is unchanged Electron runtime).
autoUpdater.autoDownload = true; // download in background as soon as we detect an update
autoUpdater.autoInstallOnAppQuit = false; // we'll manually trigger install — kiosk shouldn't surprise-restart mid-video
autoUpdater.allowDowngrade = false;
autoUpdater.logger = {
info: (m) => console.log('[updater]', m),
warn: (m) => console.warn('[updater]', m),
error: (m) => console.error('[updater]', m),
debug: () => {},
};
function sendUpdateStatus(payload) {
if (mainWindow && !mainWindow.webContents.isDestroyed()) {
mainWindow.webContents.send('update-status', payload);
}
}
autoUpdater.on('update-available', (info) => {
sendUpdateStatus({ state: 'available', version: info.version, currentVersion: app.getVersion() });
});
autoUpdater.on('download-progress', (p) => {
sendUpdateStatus({ state: 'downloading', percent: Math.round(p.percent), transferred: p.transferred, total: p.total, bytesPerSecond: p.bytesPerSecond });
});
autoUpdater.on('update-downloaded', (info) => {
sendUpdateStatus({ state: 'ready', version: info.version, currentVersion: app.getVersion() });
});
autoUpdater.on('error', (err) => {
console.warn('[updater] error:', err?.message || err);
sendUpdateStatus({ state: 'error', message: err?.message || String(err) });
});
let updateCheckInFlight = false;
async function checkForUpdates() { 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 && latest !== current) { if (!latestTag || compareSemver(latestTag, current) <= 0) {
mainWindow.webContents.send('update-available', { console.log(`[updater] up to date (current=${current}, latest=${latestTag || 'none'})`);
version: latest, return;
url: data.html_url,
assets: (data.assets || []).map(a => ({ name: a.name, url: a.browser_download_url })),
});
} }
} catch (_) {} // Verify latest.yml is present in this release's assets — without it autoUpdater can't proceed.
const hasYml = (data.assets || []).some(a => a.name === 'latest.yml');
if (!hasYml) {
console.warn(`[updater] release v${latestTag} has no latest.yml — falling back to manual download link`);
const installer = (data.assets || []).find(a => a.name.toLowerCase().endsWith('.exe'));
sendUpdateStatus({
state: 'manual',
version: latestTag,
currentVersion: current,
installerUrl: installer?.browser_download_url || data.html_url,
installerName: installer?.name || '',
});
return;
}
// Point feedURL at this release's download directory
const feedUrl = `https://gitea.esh-service.ru/public/ESH-Media/releases/download/v${latestTag}/`;
console.log(`[updater] setFeedURL ${feedUrl}`);
autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl });
await autoUpdater.checkForUpdates();
} catch (e) {
console.warn('[updater] check failed:', e?.message || e);
} finally {
updateCheckInFlight = false;
}
} }
ipcMain.handle('install-update', () => {
// Quits the app and runs the downloaded installer with NSIS /S silent flag.
// Installer waits for the running process to exit, replaces files, then relaunches.
try {
setImmediate(() => autoUpdater.quitAndInstall(true, true));
return true;
} catch (e) {
console.warn('[updater] quitAndInstall failed:', e?.message);
return false;
}
});
ipcMain.handle('check-update-now', async () => {
await checkForUpdates();
return true;
});
// --- Window --- // --- 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,
@@ -193,6 +446,8 @@ async function createWindow() {
}, },
}); });
attachDevToolsShortcut(mainWindow.webContents);
if (isDev) { if (isDev) {
mainWindow.loadURL(RENDERER_URL); mainWindow.loadURL(RENDERER_URL);
} else { } else {
@@ -316,6 +571,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 ---
@@ -346,10 +657,22 @@ 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);
// Experimental: spoof chrome.* JS objects on every page so Google's
// "embedded browser" detector sees a real-Chrome-shaped global. Runs on
// dom-ready which is AFTER <head> scripts, so detection scripts that ran
// there have already seen the un-spoofed environment — this fix only
// helps if Google's gate is re-checked on form submit / later events.
// TLS fingerprint (JA3) is server-side and unaffected; if Google flags us
// there, no client-side spoof helps. Best-effort attempt only.
view.webContents.on('dom-ready', () => {
view.webContents.executeJavaScript(CHROME_SPOOF_JS).catch(() => {});
});
view.webContents.on('did-finish-load', () => { view.webContents.on('did-finish-load', () => {
removeLoader(); removeLoader();
@@ -383,6 +706,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 = '';
@@ -392,7 +716,7 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
if (newUrl.startsWith('data:')) { trackNavigation(newUrl); return; } if (newUrl.startsWith('data:')) { trackNavigation(newUrl); return; }
let newHostname = ''; let newHostname = '';
try { newHostname = new URL(newUrl).hostname; } catch (_) { trackNavigation(newUrl); return; } try { newHostname = new URL(newUrl).hostname; } catch (_) { trackNavigation(newUrl); return; }
if (origHostname && newHostname && newHostname !== origHostname) { if (origHostname && newHostname && newHostname !== origHostname && !isTrustedDomain(newHostname)) {
e.preventDefault(); e.preventDefault();
pendingNavigate = { view, url: newUrl }; pendingNavigate = { view, url: newUrl };
setConfirm(`Перейти на "${newHostname}"?`, 'navigate-confirmed'); setConfirm(`Перейти на "${newHostname}"?`, 'navigate-confirmed');
@@ -404,11 +728,27 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
view.webContents.setWindowOpenHandler(({ url: newUrl }) => { 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 (Google, Yandex, etc.) → navigate IN-PLACE, no popup.
// 1.0.1 tried opening a real popup BrowserWindow here for OAuth postMessage
// 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)) {
trackNavigation(newUrl);
view.webContents.loadURL(newUrl);
return { action: 'deny' };
}
// 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 → navigate the current view.
trackNavigation(newUrl); trackNavigation(newUrl);
view.webContents.loadURL(newUrl); view.webContents.loadURL(newUrl);
return { action: 'deny' }; return { action: 'deny' };
@@ -416,6 +756,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);
@@ -429,6 +770,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) => {
@@ -439,6 +781,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;
@@ -731,38 +1074,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) || '',
@@ -772,7 +1162,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 };
} }
@@ -806,11 +1196,52 @@ ipcMain.handle('read-config', () => {
ipcMain.on('write-config', (_event, data) => { ipcMain.on('write-config', (_event, data) => {
try { try {
fs.writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2), 'utf8'); fs.writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2), 'utf8');
if (Array.isArray(data?.trustedDomains)) cachedTrustedDomains = data.trustedDomains;
} catch (e) { } catch (e) {
console.warn('Failed to write config:', e.message); console.warn('Failed to write config:', e.message);
} }
}); });
ipcMain.handle('get-page-meta', async () => {
if (!currentView || !currentView.view || currentView.view.webContents.isDestroyed()) return null;
try {
return await currentView.view.webContents.executeJavaScript(`
(function() {
const get = (sel, attr) => { const el = document.querySelector(sel); return el ? (el.getAttribute(attr) || '') : ''; };
const abs = (u) => { try { return new URL(u, location.href).href } catch (_) { return u } };
let poster = get('meta[property="og:image:secure_url"]', 'content')
|| get('meta[property="og:image"]', 'content')
|| get('meta[name="twitter:image"]', 'content')
|| get('meta[name="twitter:image:src"]', 'content');
// HDRezka has a higher-res poster in the sidebar
if (!poster || /hdrezka|rezka/i.test(location.hostname)) {
const side = document.querySelector('.b-sidecover img');
if (side && side.src) poster = side.src;
}
// JSON-LD fallback
if (!poster) {
for (const s of document.querySelectorAll('script[type="application/ld+json"]')) {
try {
const d = JSON.parse(s.textContent || 'null');
const arr = Array.isArray(d) ? d : [d];
for (const it of arr) {
if (!it) continue;
const img = it.image || it.poster || (it.thumbnailUrl);
if (img) { poster = Array.isArray(img) ? img[0] : (typeof img === 'string' ? img : img.url); if (poster) break; }
}
if (poster) break;
} catch (_) {}
}
}
const title = (get('meta[property="og:title"]', 'content') || document.title || '').trim();
return { poster: poster ? abs(poster) : '', title, url: location.href };
})()
`);
} catch (e) {
return null;
}
});
ipcMain.handle('is-kiosk', () => mainWindow.isKiosk()); ipcMain.handle('is-kiosk', () => mainWindow.isKiosk());
ipcMain.handle('toggle-kiosk', () => { ipcMain.handle('toggle-kiosk', () => {
@@ -855,7 +1286,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) => {
@@ -870,7 +1317,12 @@ 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();
try { try {
if (fs.existsSync(CONFIG_PATH)) { if (fs.existsSync(CONFIG_PATH)) {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
@@ -884,11 +1336,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
}); });
}); });
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.0", "version": "1.0.10",
"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

@@ -21,6 +21,9 @@ contextBridge.exposeInMainWorld('electron', {
forwardPage: () => ipcRenderer.send('forwardPage'), forwardPage: () => ipcRenderer.send('forwardPage'),
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'),
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

@@ -27,25 +27,42 @@ const BookmarksBar: React.FC<BookmarksBarProps> = ({ bookmarks, onOpen, onRemove
<div className={`bookmarks-collapse${expanded ? ' open' : ''}`}> <div className={`bookmarks-collapse${expanded ? ' open' : ''}`}>
<div className="bookmarks-collapse-inner"> <div className="bookmarks-collapse-inner">
<div className="bookmarks-list"> <div className="bookmarks-list">
{bookmarks.map((b, i) => ( {bookmarks.map((b, i) => {
<div key={i} className="bookmark-card" onClick={() => onOpen(b)}> const hasMoviePoster = !!b.poster && b.poster !== b.siteIcon
<div className="bookmark-poster"> return (
{b.poster <div key={i} className="bookmark-card" onClick={() => onOpen(b)}>
? <img src={b.poster} alt={b.title} /> <div className="bookmark-poster">
: <div className="bookmark-poster-placeholder">{b.title.charAt(0).toUpperCase()}</div> {b.poster
} ? <img src={b.poster} alt={b.title} onError={e => {
const t = e.currentTarget
t.style.display = 'none'
const ph = t.nextElementSibling as HTMLElement | null
if (ph) ph.style.display = 'flex'
}} />
: null}
<div className="bookmark-poster-placeholder" style={b.poster ? { display: 'none' } : undefined}>
{b.title.charAt(0).toUpperCase()}
</div>
</div>
<div className="bookmark-info">
<div className="bookmark-title" title={b.title}>{b.title}</div>
{b.source && (
<div className="bookmark-source-row">
{hasMoviePoster && b.siteIcon && (
<img className="bookmark-source-icon" src={b.siteIcon} alt="" onError={e => { e.currentTarget.style.display = 'none' }} />
)}
<span className="bookmark-source">{b.source}</span>
</div>
)}
</div>
<button
className="bookmark-remove"
onClick={e => { e.stopPropagation(); onRemove(i) }}
title="Удалить закладку"
></button>
</div> </div>
<div className="bookmark-info"> )
<div className="bookmark-title">{b.title}</div> })}
{b.source && <div className="bookmark-source">{b.source}</div>}
</div>
<button
className="bookmark-remove"
onClick={e => { e.stopPropagation(); onRemove(i) }}
title="Удалить закладку"
></button>
</div>
))}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -7,7 +7,7 @@ interface HeaderProps {
setActiveApp: (name: string) => void setActiveApp: (name: string) => void
onAppsChange: (apps: AppEntry[]) => void onAppsChange: (apps: AppEntry[]) => void
onMovieSearch: (query: string) => void onMovieSearch: (query: string) => void
onBookmark: (title: string, url: string, poster: string, source: string) => void onBookmark: (title: string, url: string, poster: string, source: string, siteIcon?: string) => void
onBookmarkRemove: (index: number) => void onBookmarkRemove: (index: number) => void
bookmarks: import('./Settings').Bookmark[] bookmarks: import('./Settings').Bookmark[]
openedFromSearch?: boolean openedFromSearch?: boolean
@@ -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; url: 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; url: 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
}, []) }, [])
@@ -128,35 +142,30 @@ const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
const [isBookmarked, setIsBookmarked] = useState(false) const [isBookmarked, setIsBookmarked] = useState(false)
const handleBookmark = () => { const handleBookmark = async () => {
window.electron?.getCurrentPage().then((page: any) => { const page = await window.electron?.getCurrentPage()
if (!page) return if (!page) return
let pageHost = '' let pageHost = ''
try { pageHost = new URL(page.url).hostname } catch (_) {} try { pageHost = new URL(page.url).hostname } catch (_) {}
const existingIdx = bookmarks.findIndex(b => { // Match by full URL — different movies on same site must not collide.
try { return new URL(b.url).hostname === pageHost } catch (_) { return false } const existingIdx = bookmarks.findIndex(b => b.url === page.url)
}) if (existingIdx !== -1) {
if (existingIdx !== -1) { onBookmarkRemove(existingIdx)
onBookmarkRemove(existingIdx) setIsBookmarked(false)
setIsBookmarked(false) return
} else { }
onBookmark(page.name, page.url, page.imageUrl || '', '') // Pull og:image / JSON-LD poster from the live page (specific to this movie).
setIsBookmarked(true) const meta = await window.electron?.getPageMeta?.().catch(() => null)
} const poster = meta?.poster || ''
}) const title = (meta?.title || page.name || '').trim() || page.name
onBookmark(title, page.url, poster, pageHost, page.imageUrl || '')
setIsBookmarked(true)
} }
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])
let pageHost = ''
try { pageHost = new URL(page.url).hostname } catch (_) {}
setIsBookmarked(bookmarks.some(b => {
try { return new URL(b.url).hostname === pageHost } catch (_) { return false }
}))
})
}, [activeApp, bookmarks, appOpen])
return ( return (
<> <>
@@ -281,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}</span> {updateStatus.state === 'available' && (
<a href={updateInfo.url} target="_blank" rel="noreferrer" className="update-banner-btn" onClick={() => window.electron?.createView('Обновление', updateInfo.url, '', 1.0, false)}> <>
Скачать <span className="update-banner-spinner" />
</a> <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

@@ -10,8 +10,9 @@ export interface AppEntry {
export interface Bookmark { export interface Bookmark {
title: string title: string
url: string url: string
poster?: string poster?: string // movie poster (og:image, or fallback site icon)
source?: string source?: string // domain shown under title
siteIcon?: string // small site icon shown alongside source
} }
export interface MovieSite { export interface MovieSite {
@@ -31,6 +32,7 @@ interface SettingsData {
movieSites: MovieSite[] movieSites: MovieSite[]
tmdbApiKey: string tmdbApiKey: string
bookmarks: Bookmark[] bookmarks: Bookmark[]
trustedDomains?: string[]
} }
interface SettingsProps { interface SettingsProps {
@@ -38,7 +40,17 @@ interface SettingsProps {
onAppsChange: (apps: AppEntry[]) => void onAppsChange: (apps: AppEntry[]) => void
} }
const DEFAULT_SETTINGS: SettingsData = { apps: [], proxy: { host: '127.0.0.1', port: '7890' }, movieSites: [], tmdbApiKey: '', bookmarks: [] } const DEFAULT_TRUSTED_DOMAINS = [
'google.com', 'accounts.google.com', 'googleapis.com', 'googleusercontent.com',
'gstatic.com', 'youtube.com', 'ytimg.com', 'googlevideo.com',
'yandex.ru', 'yandex.com', 'passport.yandex.ru', 'passport.yandex.com', 'yastatic.net',
'github.com', 'github.io', 'githubassets.com', 'githubusercontent.com',
'vk.com', 'vk.ru', 'vkuser.net', 'mail.ru', 'my.mail.ru',
'live.com', 'microsoft.com', 'microsoftonline.com', 'office.com',
'apple.com', 'icloud.com', 'facebook.com', 'fb.com',
]
const DEFAULT_SETTINGS: SettingsData = { apps: [], proxy: { host: '127.0.0.1', port: '7890' }, movieSites: [], tmdbApiKey: '', bookmarks: [], trustedDomains: DEFAULT_TRUSTED_DOMAINS }
function guessMovieSiteType(domain: string): MovieSite['type'] { function guessMovieSiteType(domain: string): MovieSite['type'] {
if (/rezka/.test(domain)) return 'hdrezka' if (/rezka/.test(domain)) return 'hdrezka'
@@ -96,6 +108,26 @@ const Settings: React.FC<SettingsProps> = ({ onClose, onAppsChange }) => {
} }
const [newSite, setNewSite] = useState<MovieSite>({ domain: '', type: 'dle', enabled: true }) const [newSite, setNewSite] = useState<MovieSite>({ domain: '', type: 'dle', enabled: true })
const [newTrusted, setNewTrusted] = useState('')
const trustedDomains = settings.trustedDomains ?? DEFAULT_TRUSTED_DOMAINS
const addTrustedDomain = () => {
const d = newTrusted.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^\./, '')
if (!d || trustedDomains.includes(d)) { setNewTrusted(''); return }
const updated = { ...settings, trustedDomains: [...trustedDomains, d] }
setSettings(updated); saveSettings(updated); setNewTrusted('')
}
const removeTrustedDomain = (index: number) => {
const updated = { ...settings, trustedDomains: trustedDomains.filter((_, i) => i !== index) }
setSettings(updated); saveSettings(updated)
}
const resetTrustedDomains = () => {
const updated = { ...settings, trustedDomains: DEFAULT_TRUSTED_DOMAINS }
setSettings(updated); saveSettings(updated)
}
const addMovieSite = () => { const addMovieSite = () => {
if (!newSite.domain.trim()) return if (!newSite.domain.trim()) return
@@ -161,6 +193,36 @@ const Settings: React.FC<SettingsProps> = ({ onClose, onAppsChange }) => {
</p> </p>
</div> </div>
<div className="settings-section">
<div className="settings-section-head-row">
<h3>Доверенные домены</h3>
<button className="settings-reset-btn" onClick={resetTrustedDomains} title="Сбросить к стандартному списку">Сбросить</button>
</div>
<p className="proxy-hint">
Переходы и popup'ы на эти домены открываются без подтверждения — нужно для входа через Google, Яндекс, GitHub и т.п.
Совпадение по суффиксу: <code>google.com</code> разрешит и <code>accounts.google.com</code>, и <code>www.google.com</code>.
</p>
<div className="trusted-domains-list">
{trustedDomains.map((d, i) => (
<span key={i} className="trusted-domain-chip">
{d}
<button className="trusted-domain-remove" onClick={() => removeTrustedDomain(i)} title="Удалить">✕</button>
</span>
))}
{trustedDomains.length === 0 && <p className="settings-empty">Список пуст.</p>}
</div>
<div className="trusted-domain-add">
<input
className="settings-input"
placeholder="example.com"
value={newTrusted}
onChange={e => setNewTrusted(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addTrustedDomain()}
/>
<button className="settings-add-btn" onClick={addTrustedDomain}>Добавить</button>
</div>
</div>
<div className="settings-section"> <div className="settings-section">
<h3>Приложения</h3> <h3>Приложения</h3>
<div className="settings-apps-list"> <div className="settings-apps-list">

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

@@ -64,8 +64,18 @@ const HomePage: React.FC = () => {
setActiveApp('movie-search') setActiveApp('movie-search')
} }
const handleBookmarkAdd = (title: string, url: string, poster: string, source: string) => { const handleBookmarkAdd = (title: string, url: string, poster: string, source: string, siteIcon?: string) => {
const updated = [...bookmarks, { title, url, poster, source }] // If caller didn't pass a site icon (e.g. movie search), look it up from the apps config by host.
let icon = siteIcon || ''
if (!icon) {
try {
const host = new URL(url).hostname
const match = appCardList.find(a => { try { return new URL(a.url).hostname === host } catch { return false } })
if (match?.imageUrl) icon = match.imageUrl
} catch {}
}
const sourceStr = source || (() => { try { return new URL(url).hostname.replace(/^www\./, '') } catch { return '' } })()
const updated = [...bookmarks, { title, url, poster, source: sourceStr, siteIcon: icon }]
setBookmarks(updated) setBookmarks(updated)
configRef.current = { ...configRef.current, bookmarks: updated } configRef.current = { ...configRef.current, bookmarks: updated }
window.electron?.writeConfig(configRef.current) window.electron?.writeConfig(configRef.current)

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;
@@ -1227,7 +1265,22 @@ body {
line-height: 1.35; line-height: 1.35;
} }
.bookmark-source { font-size: 10px; color: #555; } .bookmark-source { font-size: 10px; color: #777; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bookmark-source-row {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.bookmark-source-icon {
width: 12px;
height: 12px;
border-radius: 2px;
object-fit: contain;
flex-shrink: 0;
}
.bookmark-remove { .bookmark-remove {
position: absolute; position: absolute;
@@ -1253,6 +1306,70 @@ body {
.bookmark-card:hover .bookmark-remove { opacity: 1; } .bookmark-card:hover .bookmark-remove { opacity: 1; }
.bookmark-remove:hover { color: #fff; background: rgba(200,40,40,0.85); } .bookmark-remove:hover { color: #fff; background: rgba(200,40,40,0.85); }
/* ---- Trusted Domains ---- */
.settings-section-head-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.settings-reset-btn {
background: transparent;
border: 1px solid var(--border);
color: #888;
font-size: 11px;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.settings-reset-btn:hover { color: #ccc; border-color: #555; }
.trusted-domains-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 8px 0;
}
.trusted-domain-chip {
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
border-radius: 12px;
padding: 3px 4px 3px 10px;
font-size: 11px;
color: #bbb;
line-height: 1;
}
.trusted-domain-remove {
background: transparent;
border: none;
color: #666;
cursor: pointer;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
line-height: 1;
padding: 0;
}
.trusted-domain-remove:hover { color: #fff; background: rgba(200,40,40,0.7); }
.trusted-domain-add {
display: flex;
gap: 6px;
align-items: center;
}
.trusted-domain-add .settings-input { flex: 1; }
/* ---- Modal Dialog ---- */ /* ---- Modal Dialog ---- */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
@@ -1363,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;