diff --git a/main.js b/main.js index e4b05fd..1778d26 100644 --- a/main.js +++ b/main.js @@ -7,9 +7,47 @@ const { ElectronBlocker, adsAndTrackingLists } = require('@cliqz/adblocker-elect const CONFIG_PATH = path.join(os.homedir(), '.ESH-Media.json'); const BLOCKER_CACHE_PATH = path.join(os.homedir(), '.ESH-Media-adblock-v2.bin'); -const DEFAULT_CONFIG = { apps: [], proxy: { host: '127.0.0.1', port: '7890' } }; +const DEFAULT_TRUSTED_DOMAINS = [ + // Google ecosystem (OAuth) + 'google.com', 'accounts.google.com', 'googleapis.com', 'googleusercontent.com', + 'gstatic.com', 'youtube.com', 'ytimg.com', 'googlevideo.com', + // Yandex + 'yandex.ru', 'yandex.com', 'passport.yandex.ru', 'passport.yandex.com', 'yastatic.net', + // GitHub + 'github.com', 'github.io', 'githubassets.com', 'githubusercontent.com', + // VK / Mail.ru + 'vk.com', 'vk.ru', 'vkuser.net', 'mail.ru', 'my.mail.ru', + // Microsoft (login.live.com etc., некоторые сайты через них) + 'live.com', 'microsoft.com', 'microsoftonline.com', 'office.com', + // Apple + 'apple.com', 'icloud.com', + // Facebook (для соцлогина) + 'facebook.com', 'fb.com', +]; +const DEFAULT_CONFIG = { apps: [], proxy: { host: '127.0.0.1', port: '7890' }, trustedDomains: DEFAULT_TRUSTED_DOMAINS }; let blockerPromise = null; +let cachedTrustedDomains = DEFAULT_TRUSTED_DOMAINS; + +function loadTrustedDomainsFromDisk() { + try { + if (fs.existsSync(CONFIG_PATH)) { + const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); + if (Array.isArray(cfg.trustedDomains) && cfg.trustedDomains.length) { + cachedTrustedDomains = cfg.trustedDomains; + } + } + } catch (_) {} +} + +function isTrustedDomain(hostname) { + if (!hostname) return false; + const h = hostname.toLowerCase(); + return cachedTrustedDomains.some(d => { + const dom = d.toLowerCase().replace(/^\./, ''); + return h === dom || h.endsWith('.' + dom); + }); +} function getBlocker() { if (blockerPromise) return blockerPromise; @@ -159,6 +197,18 @@ async function loadExtensions() { // --- 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; +} + async function checkForUpdates() { try { const res = await getDirectSession().fetch( @@ -168,13 +218,25 @@ async function checkForUpdates() { const data = await res.json(); const latest = (data.tag_name || '').replace(/^v/, ''); const current = app.getVersion(); - if (latest && latest !== current) { - mainWindow.webContents.send('update-available', { - version: latest, - url: data.html_url, - assets: (data.assets || []).map(a => ({ name: a.name, url: a.browser_download_url })), - }); - } + if (!latest || compareSemver(latest, current) <= 0) return; + const assets = (data.assets || []).map(a => ({ name: a.name, url: a.browser_download_url })); + // Prefer Windows installer (.exe) on Windows, AppImage/deb on Linux. Fall back to zip. + const isWin = process.platform === 'win32'; + const isLinux = process.platform === 'linux'; + const installer = assets.find(a => { + const n = a.name.toLowerCase(); + if (isWin) return n.endsWith('.exe'); + if (isLinux) return n.endsWith('.appimage') || n.endsWith('.deb'); + return false; + }) || assets.find(a => a.name.toLowerCase().endsWith('.zip')); + mainWindow.webContents.send('update-available', { + version: latest, + currentVersion: current, + releaseUrl: data.html_url, + installerUrl: installer?.url || data.html_url, + installerName: installer?.name || '', + assets, + }); } catch (_) {} } @@ -392,7 +454,7 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) = if (newUrl.startsWith('data:')) { trackNavigation(newUrl); return; } let newHostname = ''; try { newHostname = new URL(newUrl).hostname; } catch (_) { trackNavigation(newUrl); return; } - if (origHostname && newHostname && newHostname !== origHostname) { + if (origHostname && newHostname && newHostname !== origHostname && !isTrustedDomain(newHostname)) { e.preventDefault(); pendingNavigate = { view, url: newUrl }; setConfirm(`Перейти на "${newHostname}"?`, 'navigate-confirmed'); @@ -401,14 +463,37 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) = trackNavigation(newUrl); }); view.webContents.on('will-redirect', (_e, u) => trackNavigation(u)); - view.webContents.setWindowOpenHandler(({ url: newUrl }) => { + view.webContents.setWindowOpenHandler(({ url: newUrl, frameName, features }) => { let newHostname = ''; try { newHostname = new URL(newUrl).hostname; } catch (_) {} + + // Trusted domain → open as real popup BrowserWindow with same session. + // This is what OAuth flows need: window.opener.postMessage() works, + // popup can close itself when done, parent stays on the original page. + if (newHostname && isTrustedDomain(newHostname)) { + return { + action: 'allow', + overrideBrowserWindowOptions: { + width: 520, height: 640, + parent: mainWindow, + autoHideMenuBar: true, + webPreferences: { + session: view.webContents.session, + contextIsolation: true, + nodeIntegration: false, + }, + }, + }; + } + + // Untrusted cross-domain → ask the user (original behavior). if (origHostname && newHostname && newHostname !== origHostname) { pendingNavigate = { view, url: newUrl }; setConfirm(`Перейти на "${newHostname}"?`, 'navigate-confirmed'); return { action: 'deny' }; } + + // Same-origin popup → just navigate the current view. trackNavigation(newUrl); view.webContents.loadURL(newUrl); return { action: 'deny' }; @@ -806,11 +891,52 @@ ipcMain.handle('read-config', () => { ipcMain.on('write-config', (_event, data) => { try { fs.writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2), 'utf8'); + if (Array.isArray(data?.trustedDomains)) cachedTrustedDomains = data.trustedDomains; } catch (e) { console.warn('Failed to write config:', e.message); } }); +ipcMain.handle('get-page-meta', async () => { + if (!currentView || !currentView.view || currentView.view.webContents.isDestroyed()) return null; + try { + return await currentView.view.webContents.executeJavaScript(` + (function() { + const get = (sel, attr) => { const el = document.querySelector(sel); return el ? (el.getAttribute(attr) || '') : ''; }; + const abs = (u) => { try { return new URL(u, location.href).href } catch (_) { return u } }; + let poster = get('meta[property="og:image:secure_url"]', 'content') + || get('meta[property="og:image"]', 'content') + || get('meta[name="twitter:image"]', 'content') + || get('meta[name="twitter:image:src"]', 'content'); + // HDRezka has a higher-res poster in the sidebar + if (!poster || /hdrezka|rezka/i.test(location.hostname)) { + const side = document.querySelector('.b-sidecover img'); + if (side && side.src) poster = side.src; + } + // JSON-LD fallback + if (!poster) { + for (const s of document.querySelectorAll('script[type="application/ld+json"]')) { + try { + const d = JSON.parse(s.textContent || 'null'); + const arr = Array.isArray(d) ? d : [d]; + for (const it of arr) { + if (!it) continue; + const img = it.image || it.poster || (it.thumbnailUrl); + if (img) { poster = Array.isArray(img) ? img[0] : (typeof img === 'string' ? img : img.url); if (poster) break; } + } + if (poster) break; + } catch (_) {} + } + } + const title = (get('meta[property="og:title"]', 'content') || document.title || '').trim(); + return { poster: poster ? abs(poster) : '', title, url: location.href }; + })() + `); + } catch (e) { + return null; + } +}); + ipcMain.handle('is-kiosk', () => mainWindow.isKiosk()); ipcMain.handle('toggle-kiosk', () => { @@ -871,6 +997,7 @@ app.whenReady().then(async () => { ); // Apply proxy from config before blocker tries to download filter lists + loadTrustedDomainsFromDisk(); try { if (fs.existsSync(CONFIG_PATH)) { const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); @@ -886,6 +1013,7 @@ app.whenReady().then(async () => { mainWindow.webContents.once('did-finish-load', () => { setTimeout(checkForUpdates, 4000); + setInterval(checkForUpdates, 60 * 60 * 1000); // re-check hourly for long-running kiosk sessions }); }); diff --git a/package.json b/package.json index 1c85c88..8ce5c61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ESH-Media", - "version": "1.0.0", + "version": "1.0.1", "private": true, "main": "main.js", "scripts": { diff --git a/preload.js b/preload.js index dd5434e..e4a4069 100644 --- a/preload.js +++ b/preload.js @@ -21,6 +21,7 @@ contextBridge.exposeInMainWorld('electron', { forwardPage: () => ipcRenderer.send('forwardPage'), refreshPage: () => ipcRenderer.send('refreshPage'), getCurrentPage: () => ipcRenderer.invoke('get-current-page'), + getPageMeta: () => ipcRenderer.invoke('get-page-meta'), readConfig: () => ipcRenderer.invoke('read-config'), writeConfig: (data) => ipcRenderer.send('write-config', data), searchMovies: (query, sites) => ipcRenderer.invoke('search-movies', query, sites), diff --git a/src/components/BookmarksBar.tsx b/src/components/BookmarksBar.tsx index 72326e8..024c663 100644 --- a/src/components/BookmarksBar.tsx +++ b/src/components/BookmarksBar.tsx @@ -27,25 +27,42 @@ const BookmarksBar: React.FC = ({ bookmarks, onOpen, onRemove
- {bookmarks.map((b, i) => ( -
onOpen(b)}> -
- {b.poster - ? {b.title} - :
{b.title.charAt(0).toUpperCase()}
- } + {bookmarks.map((b, i) => { + const hasMoviePoster = !!b.poster && b.poster !== b.siteIcon + return ( +
onOpen(b)}> +
+ {b.poster + ? {b.title} { + const t = e.currentTarget + t.style.display = 'none' + const ph = t.nextElementSibling as HTMLElement | null + if (ph) ph.style.display = 'flex' + }} /> + : null} +
+ {b.title.charAt(0).toUpperCase()} +
+
+
+
{b.title}
+ {b.source && ( +
+ {hasMoviePoster && b.siteIcon && ( + { e.currentTarget.style.display = 'none' }} /> + )} + {b.source} +
+ )} +
+
-
-
{b.title}
- {b.source &&
{b.source}
} -
- -
- ))} + ) + })}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 6cf73e0..6d54132 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -7,7 +7,7 @@ interface HeaderProps { setActiveApp: (name: string) => void onAppsChange: (apps: AppEntry[]) => 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 bookmarks: import('./Settings').Bookmark[] openedFromSearch?: boolean @@ -108,11 +108,11 @@ const Header: React.FC = ({ activeApp, setActiveApp, onAppsChange, const showSearchIcon = activeApp === 'home' || activeApp === 'movie-search' const [isKiosk, setIsKiosk] = useState(true) - const [updateInfo, setUpdateInfo] = useState<{ version: string; url: string } | null>(null) + const [updateInfo, setUpdateInfo] = useState<{ version: string; currentVersion?: string; releaseUrl: string; installerUrl: string; installerName?: string } | null>(null) useEffect(() => { if (!window.electron) return - const off = window.electron.on('update-available', (info: { version: string; url: string }) => { + const off = window.electron.on('update-available', (info: { version: string; currentVersion?: string; releaseUrl: string; installerUrl: string; installerName?: string }) => { setUpdateInfo(info) }) return off @@ -128,33 +128,31 @@ const Header: React.FC = ({ activeApp, setActiveApp, onAppsChange, const [isBookmarked, setIsBookmarked] = useState(false) - const handleBookmark = () => { - window.electron?.getCurrentPage().then((page: any) => { - if (!page) return - let pageHost = '' - try { pageHost = new URL(page.url).hostname } catch (_) {} - const existingIdx = bookmarks.findIndex(b => { - try { return new URL(b.url).hostname === pageHost } catch (_) { return false } - }) - if (existingIdx !== -1) { - onBookmarkRemove(existingIdx) - setIsBookmarked(false) - } else { - onBookmark(page.name, page.url, page.imageUrl || '', '') - setIsBookmarked(true) - } - }) + const handleBookmark = async () => { + const page = await window.electron?.getCurrentPage() + if (!page) return + let pageHost = '' + try { pageHost = new URL(page.url).hostname } catch (_) {} + // Match by full URL — different movies on same site must not collide. + const existingIdx = bookmarks.findIndex(b => b.url === page.url) + if (existingIdx !== -1) { + onBookmarkRemove(existingIdx) + setIsBookmarked(false) + return + } + // 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(() => { if (!appOpen) { setIsBookmarked(false); return } window.electron?.getCurrentPage().then((page: any) => { if (!page) return - 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 } - })) + setIsBookmarked(bookmarks.some(b => b.url === page.url)) }) }, [activeApp, bookmarks, appOpen]) @@ -283,10 +281,10 @@ const Header: React.FC = ({ activeApp, setActiveApp, onAppsChange, {updateInfo && (
- Доступна версия {updateInfo.version} - window.electron?.createView('Обновление', updateInfo.url, '', 1.0, false)}> - Скачать - + Доступна версия {updateInfo.version}{updateInfo.currentVersion ? ` (текущая ${updateInfo.currentVersion})` : ''} +
)} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index bd4ba3c..a9ddd02 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -10,8 +10,9 @@ export interface AppEntry { export interface Bookmark { title: string url: string - poster?: string - source?: string + poster?: string // movie poster (og:image, or fallback site icon) + source?: string // domain shown under title + siteIcon?: string // small site icon shown alongside source } export interface MovieSite { @@ -31,6 +32,7 @@ interface SettingsData { movieSites: MovieSite[] tmdbApiKey: string bookmarks: Bookmark[] + trustedDomains?: string[] } interface SettingsProps { @@ -38,7 +40,17 @@ interface SettingsProps { 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'] { if (/rezka/.test(domain)) return 'hdrezka' @@ -96,6 +108,26 @@ const Settings: React.FC = ({ onClose, onAppsChange }) => { } const [newSite, setNewSite] = useState({ 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 = () => { if (!newSite.domain.trim()) return @@ -161,6 +193,36 @@ const Settings: React.FC = ({ onClose, onAppsChange }) => {

+
+
+

Доверенные домены

+ +
+

+ Переходы и popup'ы на эти домены открываются без подтверждения — нужно для входа через Google, Яндекс, GitHub и т.п. + Совпадение по суффиксу: google.com разрешит и accounts.google.com, и www.google.com. +

+
+ {trustedDomains.map((d, i) => ( + + {d} + + + ))} + {trustedDomains.length === 0 &&

Список пуст.

} +
+
+ setNewTrusted(e.target.value)} + onKeyDown={e => e.key === 'Enter' && addTrustedDomain()} + /> + +
+
+

Приложения

diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 7d8b1a3..058e287 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -64,8 +64,18 @@ const HomePage: React.FC = () => { setActiveApp('movie-search') } - const handleBookmarkAdd = (title: string, url: string, poster: string, source: string) => { - const updated = [...bookmarks, { title, url, poster, source }] + const handleBookmarkAdd = (title: string, url: string, poster: string, source: string, siteIcon?: string) => { + // 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) configRef.current = { ...configRef.current, bookmarks: updated } window.electron?.writeConfig(configRef.current) diff --git a/src/styles/main.css b/src/styles/main.css index 0380a15..5562d69 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -1227,7 +1227,22 @@ body { 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 { position: absolute; @@ -1253,6 +1268,70 @@ body { .bookmark-card:hover .bookmark-remove { opacity: 1; } .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-overlay { position: fixed;