Compare commits
10 Commits
6c314b614d
...
v1.0.10
| Author | SHA1 | Date | |
|---|---|---|---|
| 103c2d1e09 | |||
| 82f7fa7545 | |||
| b5e1296a7a | |||
| e80704c534 | |||
| c9c9e1171b | |||
| 461e7ed737 | |||
| 542be8135a | |||
| a171f62629 | |||
| 10361cf3c0 | |||
| 2857a40d1e |
520
main.js
520
main.js
@@ -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 })),
|
// 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;
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,10 +1074,20 @@ 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 genreArr = Array.isArray(genreIds) ? genreIds.filter(Boolean) : [];
|
||||||
|
const countryArr = Array.isArray(countries) ? countries.filter(Boolean) : [];
|
||||||
|
const yearArr = Array.isArray(years) ? years.filter(Boolean) : [];
|
||||||
|
|
||||||
|
const buildParams = (yearOverride) => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
language: 'ru-RU',
|
language: 'ru-RU',
|
||||||
sort_by: sortBy || 'popularity.desc',
|
sort_by: sortBy || 'popularity.desc',
|
||||||
@@ -742,27 +1095,64 @@ ipcMain.handle('discover-tmdb', async (_event, { apiKey, mediaType, sortBy, genr
|
|||||||
include_adult: 'false',
|
include_adult: 'false',
|
||||||
});
|
});
|
||||||
if (!isBearer) params.set('api_key', apiKey);
|
if (!isBearer) params.set('api_key', apiKey);
|
||||||
if (genreId) params.set('with_genres', String(genreId));
|
if (genreArr.length) params.set('with_genres', genreArr.join(',')); // AND
|
||||||
if (year) {
|
|
||||||
if (type === 'movie') params.set('primary_release_year', year);
|
|
||||||
else params.set('first_air_date_year', year);
|
|
||||||
}
|
|
||||||
if (minRating) params.set('vote_average.gte', minRating);
|
if (minRating) params.set('vote_average.gte', minRating);
|
||||||
if (country) {
|
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 = {
|
const COUNTRY_LANG = {
|
||||||
RU: 'ru', JP: 'ja', KR: 'ko', CN: 'zh', FR: 'fr',
|
RU: 'ru', JP: 'ja', KR: 'ko', CN: 'zh', FR: 'fr',
|
||||||
DE: 'de', IT: 'it', ES: 'es', SE: 'sv', DK: 'da', TR: 'tr', IN: 'hi',
|
DE: 'de', IT: 'it', ES: 'es', SE: 'sv', DK: 'da', TR: 'tr', IN: 'hi',
|
||||||
};
|
};
|
||||||
const lang = COUNTRY_LANG[country];
|
const langs = [], origCountries = [];
|
||||||
if (lang) params.set('with_original_language', lang);
|
for (const c of countryArr) {
|
||||||
else params.set('with_origin_country', country);
|
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 url = `https://api.themoviedb.org/3/discover/${type}?${params}`;
|
||||||
const headers = isBearer ? { 'Authorization': `Bearer ${apiKey}` } : {};
|
const headers = isBearer ? { 'Authorization': `Bearer ${apiKey}` } : {};
|
||||||
const resp = await getProxySession().fetch(url, { headers, signal: AbortSignal.timeout(8000) });
|
const resp = await getProxySession().fetch(url, { headers, signal: AbortSignal.timeout(8000) });
|
||||||
if (!resp.ok) return { error: `TMDB ${resp.status}`, results: [], totalPages: 1 };
|
if (!resp.ok) return { results: [], totalPages: 0, status: resp.status };
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
const results = (data.results || []).map(r => ({
|
return { results: data.results || [], totalPages: data.total_pages || 1, status: 200 };
|
||||||
|
};
|
||||||
|
|
||||||
|
let aggregated = [];
|
||||||
|
let maxPages = 1;
|
||||||
|
if (yearArr.length === 0) {
|
||||||
|
const r = await fetchOnce(buildParams(null));
|
||||||
|
if (r.status !== 200) return { error: `TMDB ${r.status}`, results: [], totalPages: 1 };
|
||||||
|
aggregated = r.results;
|
||||||
|
maxPages = r.totalPages;
|
||||||
|
} else {
|
||||||
|
// Fan out per year, merge by id (TMDB has no OR for discrete years)
|
||||||
|
const settled = await Promise.allSettled(yearArr.map(y => fetchOnce(buildParams(y))));
|
||||||
|
const seen = new Set();
|
||||||
|
for (const s of settled) {
|
||||||
|
if (s.status !== 'fulfilled' || s.value.status !== 200) continue;
|
||||||
|
maxPages = Math.max(maxPages, s.value.totalPages);
|
||||||
|
for (const r of s.value.results) {
|
||||||
|
if (seen.has(r.id)) continue;
|
||||||
|
seen.add(r.id);
|
||||||
|
aggregated.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Re-sort merged set by popularity (since TMDB sorted within each year)
|
||||||
|
aggregated.sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = aggregated.map(r => ({
|
||||||
id: r.id,
|
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
104
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
19
package.json
19
package.json
@@ -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/"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -27,17 +27,33 @@ 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) => {
|
||||||
|
const hasMoviePoster = !!b.poster && b.poster !== b.siteIcon
|
||||||
|
return (
|
||||||
<div key={i} className="bookmark-card" onClick={() => onOpen(b)}>
|
<div key={i} className="bookmark-card" onClick={() => onOpen(b)}>
|
||||||
<div className="bookmark-poster">
|
<div className="bookmark-poster">
|
||||||
{b.poster
|
{b.poster
|
||||||
? <img src={b.poster} alt={b.title} />
|
? <img src={b.poster} alt={b.title} onError={e => {
|
||||||
: <div className="bookmark-poster-placeholder">{b.title.charAt(0).toUpperCase()}</div>
|
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>
|
||||||
<div className="bookmark-info">
|
<div className="bookmark-info">
|
||||||
<div className="bookmark-title">{b.title}</div>
|
<div className="bookmark-title" title={b.title}>{b.title}</div>
|
||||||
{b.source && <div className="bookmark-source">{b.source}</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="bookmark-remove"
|
className="bookmark-remove"
|
||||||
@@ -45,7 +61,8 @@ const BookmarksBar: React.FC<BookmarksBarProps> = ({ bookmarks, onOpen, onRemove
|
|||||||
title="Удалить закладку"
|
title="Удалить закладку"
|
||||||
>✕</button>
|
>✕</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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)
|
||||||
} else {
|
return
|
||||||
onBookmark(page.name, page.url, page.imageUrl || '', '')
|
|
||||||
setIsBookmarked(true)
|
|
||||||
}
|
}
|
||||||
})
|
// Pull og:image / JSON-LD poster from the live page (specific to this movie).
|
||||||
|
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user