feat: trusted-domains OAuth popups, og:image bookmark posters, periodic updater
- main.js: trusted-domains list with default Google/Yandex/GitHub/etc.; cross-domain confirmation skipped for trusted; setWindowOpenHandler returns action:'allow' for trusted so OAuth popups work (postMessage back to opener, popup self-closes). Fixes YouTube/Google login reset. - main.js: get-page-meta IPC extracts og:image / twitter:image / JSON-LD image from current view; HDRezka also tries .b-sidecover img for hi-res. - Header: bookmark button pulls og:image as poster and the page's title; duplicate detection switched from hostname to full URL so multiple movies from same site can coexist. - BookmarksBar: site icon rendered next to source domain when distinct from poster; img onerror falls back to placeholder. - Settings: trusted domains chip list with add/remove/reset. - Updater: proper semver compare (only show if latest > current), direct installer URL detection per platform, hourly re-check. Bookmark schema gains optional siteIcon; existing bookmarks remain valid. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
148
main.js
148
main.js
@@ -7,9 +7,47 @@ const { ElectronBlocker, adsAndTrackingLists } = require('@cliqz/adblocker-elect
|
|||||||
|
|
||||||
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-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 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() {
|
function getBlocker() {
|
||||||
if (blockerPromise) return blockerPromise;
|
if (blockerPromise) return blockerPromise;
|
||||||
@@ -159,6 +197,18 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
async function checkForUpdates() {
|
async function checkForUpdates() {
|
||||||
try {
|
try {
|
||||||
const res = await getDirectSession().fetch(
|
const res = await getDirectSession().fetch(
|
||||||
@@ -168,13 +218,25 @@ async function checkForUpdates() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const latest = (data.tag_name || '').replace(/^v/, '');
|
const latest = (data.tag_name || '').replace(/^v/, '');
|
||||||
const current = app.getVersion();
|
const current = app.getVersion();
|
||||||
if (latest && latest !== current) {
|
if (!latest || compareSemver(latest, current) <= 0) return;
|
||||||
mainWindow.webContents.send('update-available', {
|
const assets = (data.assets || []).map(a => ({ name: a.name, url: a.browser_download_url }));
|
||||||
version: latest,
|
// Prefer Windows installer (.exe) on Windows, AppImage/deb on Linux. Fall back to zip.
|
||||||
url: data.html_url,
|
const isWin = process.platform === 'win32';
|
||||||
assets: (data.assets || []).map(a => ({ name: a.name, url: a.browser_download_url })),
|
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 (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,7 +454,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');
|
||||||
@@ -401,14 +463,37 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
|
|||||||
trackNavigation(newUrl);
|
trackNavigation(newUrl);
|
||||||
});
|
});
|
||||||
view.webContents.on('will-redirect', (_e, u) => trackNavigation(u));
|
view.webContents.on('will-redirect', (_e, u) => trackNavigation(u));
|
||||||
view.webContents.setWindowOpenHandler(({ url: newUrl }) => {
|
view.webContents.setWindowOpenHandler(({ url: newUrl, frameName, features }) => {
|
||||||
let newHostname = '';
|
let newHostname = '';
|
||||||
try { newHostname = new URL(newUrl).hostname; } catch (_) {}
|
try { newHostname = new URL(newUrl).hostname; } catch (_) {}
|
||||||
|
|
||||||
|
// Trusted domain → open as real popup BrowserWindow with same session.
|
||||||
|
// 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) {
|
if (origHostname && newHostname && newHostname !== origHostname) {
|
||||||
pendingNavigate = { view, url: newUrl };
|
pendingNavigate = { view, url: newUrl };
|
||||||
setConfirm(`Перейти на "${newHostname}"?`, 'navigate-confirmed');
|
setConfirm(`Перейти на "${newHostname}"?`, 'navigate-confirmed');
|
||||||
return { action: 'deny' };
|
return { action: 'deny' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same-origin popup → just navigate the current view.
|
||||||
trackNavigation(newUrl);
|
trackNavigation(newUrl);
|
||||||
view.webContents.loadURL(newUrl);
|
view.webContents.loadURL(newUrl);
|
||||||
return { action: 'deny' };
|
return { action: 'deny' };
|
||||||
@@ -806,11 +891,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', () => {
|
||||||
@@ -871,6 +997,7 @@ app.whenReady().then(async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 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'));
|
||||||
@@ -886,6 +1013,7 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
mainWindow.webContents.once('did-finish-load', () => {
|
mainWindow.webContents.once('did-finish-load', () => {
|
||||||
setTimeout(checkForUpdates, 4000);
|
setTimeout(checkForUpdates, 4000);
|
||||||
|
setInterval(checkForUpdates, 60 * 60 * 1000); // re-check hourly for long-running kiosk sessions
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ESH-Media",
|
"name": "ESH-Media",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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'),
|
||||||
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,25 +27,42 @@ const BookmarksBar: React.FC<BookmarksBarProps> = ({ bookmarks, onOpen, onRemove
|
|||||||
<div className={`bookmarks-collapse${expanded ? ' open' : ''}`}>
|
<div className={`bookmarks-collapse${expanded ? ' open' : ''}`}>
|
||||||
<div className="bookmarks-collapse-inner">
|
<div className="bookmarks-collapse-inner">
|
||||||
<div className="bookmarks-list">
|
<div className="bookmarks-list">
|
||||||
{bookmarks.map((b, i) => (
|
{bookmarks.map((b, i) => {
|
||||||
<div key={i} className="bookmark-card" onClick={() => onOpen(b)}>
|
const hasMoviePoster = !!b.poster && b.poster !== b.siteIcon
|
||||||
<div className="bookmark-poster">
|
return (
|
||||||
{b.poster
|
<div key={i} className="bookmark-card" onClick={() => onOpen(b)}>
|
||||||
? <img src={b.poster} alt={b.title} />
|
<div className="bookmark-poster">
|
||||||
: <div className="bookmark-poster-placeholder">{b.title.charAt(0).toUpperCase()}</div>
|
{b.poster
|
||||||
}
|
? <img src={b.poster} alt={b.title} onError={e => {
|
||||||
|
const t = e.currentTarget
|
||||||
|
t.style.display = 'none'
|
||||||
|
const ph = t.nextElementSibling as HTMLElement | null
|
||||||
|
if (ph) ph.style.display = 'flex'
|
||||||
|
}} />
|
||||||
|
: null}
|
||||||
|
<div className="bookmark-poster-placeholder" style={b.poster ? { display: 'none' } : undefined}>
|
||||||
|
{b.title.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bookmark-info">
|
||||||
|
<div className="bookmark-title" title={b.title}>{b.title}</div>
|
||||||
|
{b.source && (
|
||||||
|
<div className="bookmark-source-row">
|
||||||
|
{hasMoviePoster && b.siteIcon && (
|
||||||
|
<img className="bookmark-source-icon" src={b.siteIcon} alt="" onError={e => { e.currentTarget.style.display = 'none' }} />
|
||||||
|
)}
|
||||||
|
<span className="bookmark-source">{b.source}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="bookmark-remove"
|
||||||
|
onClick={e => { e.stopPropagation(); onRemove(i) }}
|
||||||
|
title="Удалить закладку"
|
||||||
|
>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="bookmark-info">
|
)
|
||||||
<div className="bookmark-title">{b.title}</div>
|
})}
|
||||||
{b.source && <div className="bookmark-source">{b.source}</div>}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="bookmark-remove"
|
|
||||||
onClick={e => { e.stopPropagation(); onRemove(i) }}
|
|
||||||
title="Удалить закладку"
|
|
||||||
>✕</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -108,11 +108,11 @@ 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)
|
const [updateInfo, setUpdateInfo] = useState<{ version: string; currentVersion?: string; releaseUrl: string; installerUrl: string; installerName?: string } | 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-available', (info: { version: string; currentVersion?: string; releaseUrl: string; installerUrl: string; installerName?: string }) => {
|
||||||
setUpdateInfo(info)
|
setUpdateInfo(info)
|
||||||
})
|
})
|
||||||
return off
|
return off
|
||||||
@@ -128,33 +128,31 @@ const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
|
|||||||
|
|
||||||
const [isBookmarked, setIsBookmarked] = useState(false)
|
const [isBookmarked, setIsBookmarked] = useState(false)
|
||||||
|
|
||||||
const handleBookmark = () => {
|
const handleBookmark = async () => {
|
||||||
window.electron?.getCurrentPage().then((page: any) => {
|
const page = await window.electron?.getCurrentPage()
|
||||||
if (!page) return
|
if (!page) return
|
||||||
let pageHost = ''
|
let pageHost = ''
|
||||||
try { pageHost = new URL(page.url).hostname } catch (_) {}
|
try { pageHost = new URL(page.url).hostname } catch (_) {}
|
||||||
const existingIdx = bookmarks.findIndex(b => {
|
// Match by full URL — different movies on same site must not collide.
|
||||||
try { return new URL(b.url).hostname === pageHost } catch (_) { return false }
|
const existingIdx = bookmarks.findIndex(b => b.url === page.url)
|
||||||
})
|
if (existingIdx !== -1) {
|
||||||
if (existingIdx !== -1) {
|
onBookmarkRemove(existingIdx)
|
||||||
onBookmarkRemove(existingIdx)
|
setIsBookmarked(false)
|
||||||
setIsBookmarked(false)
|
return
|
||||||
} else {
|
}
|
||||||
onBookmark(page.name, page.url, page.imageUrl || '', '')
|
// Pull og:image / JSON-LD poster from the live page (specific to this movie).
|
||||||
setIsBookmarked(true)
|
const meta = await window.electron?.getPageMeta?.().catch(() => null)
|
||||||
}
|
const poster = meta?.poster || ''
|
||||||
})
|
const title = (meta?.title || page.name || '').trim() || page.name
|
||||||
|
onBookmark(title, page.url, poster, pageHost, page.imageUrl || '')
|
||||||
|
setIsBookmarked(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!appOpen) { setIsBookmarked(false); return }
|
if (!appOpen) { setIsBookmarked(false); return }
|
||||||
window.electron?.getCurrentPage().then((page: any) => {
|
window.electron?.getCurrentPage().then((page: any) => {
|
||||||
if (!page) return
|
if (!page) return
|
||||||
let pageHost = ''
|
setIsBookmarked(bookmarks.some(b => b.url === page.url))
|
||||||
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])
|
}, [activeApp, bookmarks, appOpen])
|
||||||
|
|
||||||
@@ -283,10 +281,10 @@ const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
|
|||||||
|
|
||||||
{updateInfo && (
|
{updateInfo && (
|
||||||
<div className="update-banner">
|
<div className="update-banner">
|
||||||
<span>Доступна версия {updateInfo.version}</span>
|
<span>Доступна версия {updateInfo.version}{updateInfo.currentVersion ? ` (текущая ${updateInfo.currentVersion})` : ''}</span>
|
||||||
<a href={updateInfo.url} target="_blank" rel="noreferrer" className="update-banner-btn" onClick={() => window.electron?.createView('Обновление', updateInfo.url, '', 1.0, false)}>
|
<button className="update-banner-btn" onClick={() => window.electron?.createView('Обновление', updateInfo.installerUrl, '', 1.0, false)}>
|
||||||
Скачать
|
{updateInfo.installerName ? 'Скачать установщик' : 'Открыть релиз'}
|
||||||
</a>
|
</button>
|
||||||
<button className="update-banner-close" onClick={() => setUpdateInfo(null)}>✕</button>
|
<button className="update-banner-close" onClick={() => setUpdateInfo(null)}>✕</button>
|
||||||
</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">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1227,7 +1227,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 +1268,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user