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:
2026-05-16 18:56:12 +03:00
parent 6c314b614d
commit 2857a40d1e
8 changed files with 358 additions and 63 deletions

View File

@@ -27,25 +27,42 @@ const BookmarksBar: React.FC<BookmarksBarProps> = ({ bookmarks, onOpen, onRemove
<div className={`bookmarks-collapse${expanded ? ' open' : ''}`}>
<div className="bookmarks-collapse-inner">
<div className="bookmarks-list">
{bookmarks.map((b, i) => (
<div key={i} className="bookmark-card" onClick={() => onOpen(b)}>
<div className="bookmark-poster">
{b.poster
? <img src={b.poster} alt={b.title} />
: <div className="bookmark-poster-placeholder">{b.title.charAt(0).toUpperCase()}</div>
}
{bookmarks.map((b, i) => {
const hasMoviePoster = !!b.poster && b.poster !== b.siteIcon
return (
<div key={i} className="bookmark-card" onClick={() => onOpen(b)}>
<div className="bookmark-poster">
{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 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>

View File

@@ -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<HeaderProps> = ({ 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<HeaderProps> = ({ 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<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
{updateInfo && (
<div className="update-banner">
<span>Доступна версия {updateInfo.version}</span>
<a href={updateInfo.url} target="_blank" rel="noreferrer" className="update-banner-btn" onClick={() => window.electron?.createView('Обновление', updateInfo.url, '', 1.0, false)}>
Скачать
</a>
<span>Доступна версия {updateInfo.version}{updateInfo.currentVersion ? ` (текущая ${updateInfo.currentVersion})` : ''}</span>
<button className="update-banner-btn" onClick={() => window.electron?.createView('Обновление', updateInfo.installerUrl, '', 1.0, false)}>
{updateInfo.installerName ? 'Скачать установщик' : 'Открыть релиз'}
</button>
<button className="update-banner-close" onClick={() => setUpdateInfo(null)}></button>
</div>
)}

View File

@@ -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<SettingsProps> = ({ onClose, onAppsChange }) => {
}
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 = () => {
if (!newSite.domain.trim()) return
@@ -161,6 +193,36 @@ const Settings: React.FC<SettingsProps> = ({ onClose, onAppsChange }) => {
</p>
</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">
<h3>Приложения</h3>
<div className="settings-apps-list">

View File

@@ -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)

View File

@@ -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;