ESH-Media v1.0.11 — kiosk media browser for elderly users

Electron-based kiosk desktop app: large-tile launcher for YouTube, RuTube,
movie sites and Google services, designed for low-tech grandparent use.

Features:
  - WebContentsView-per-app tabbed browsing with session persistence
  - per-app proxy routing (Clash/V2Ray friendly, useProxy flag)
  - cliqz-electron adblocker with whitelist for OAuth/integrity domains
  - TMDB-backed movie search across kinogo / hdrezka / filmix
  - bookmark posters auto-fetched from og:image / JSON-LD
  - electron-updater wired to Gitea releases API (latest.yml + .blockmap)
  - cross-domain navigation confirms via custom WebContentsView dialogs
  - kiosk window with hidden menu, Ctrl+Shift+I devtools shortcut
  - Trusted Types disabled engine-wide so adblocker scriptlets work on YouTube

Google OAuth handling (the hard-won part):
  Google's anti-abuse JS rejects WebContentsView + custom session settings
  as "embedded browser". So accounts.google.com opens in a top-level
  BrowserWindow popup in a dedicated persist:google-login partition that
  we never call setProxy/setUserAgent on — it inherits Windows system
  proxy and the default Electron-tagged UA, both of which Google accepts.
  After login, .google.com/.youtube.com cookies migrate into the parent
  view's session and the view reloads to pick up the logged-in state.

Session restore: only the last-active tab attaches to the window; other
tabs load silently in the background and become instantly visible when
the user clicks them in the sidebar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 00:46:02 +03:00
commit 1c7bb75a05
69 changed files with 12035 additions and 0 deletions

354
src/components/Settings.tsx Normal file
View File

@@ -0,0 +1,354 @@
import React, { useState, useEffect } from 'react'
export interface AppEntry {
name: string
imageUrl: string
url: string
useProxy: boolean
}
export interface Bookmark {
title: string
url: 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 {
domain: string
type: 'dle' | 'hdrezka' | 'filmix'
enabled: boolean
}
interface ProxyConfig {
host: string
port: string
}
interface SettingsData {
apps: AppEntry[]
proxy: ProxyConfig
movieSites: MovieSite[]
tmdbApiKey: string
bookmarks: Bookmark[]
trustedDomains?: string[]
}
interface SettingsProps {
onClose: () => void
onAppsChange: (apps: AppEntry[]) => void
}
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'
if (/filmix/.test(domain)) return 'filmix'
return 'dle'
}
function saveSettings(data: SettingsData) {
window.electron?.writeConfig(data)
}
const Settings: React.FC<SettingsProps> = ({ onClose, onAppsChange }) => {
const [settings, setSettings] = useState<SettingsData>(DEFAULT_SETTINGS)
const [newApp, setNewApp] = useState<AppEntry>({ name: '', imageUrl: '', url: '', useProxy: false })
useEffect(() => {
window.electron?.readConfig().then((cfg: SettingsData | null) => {
if (cfg?.apps) setSettings(cfg)
})
}, [])
const updateProxy = (field: keyof ProxyConfig, value: string) => {
const updated = { ...settings, proxy: { ...settings.proxy, [field]: value } }
setSettings(updated)
saveSettings(updated)
window.electron?.setProxy(updated.proxy.host, updated.proxy.port)
}
const toggleAppProxy = (index: number) => {
const apps = settings.apps.map((app, i) =>
i === index ? { ...app, useProxy: !app.useProxy } : app
)
const updated = { ...settings, apps }
setSettings(updated)
saveSettings(updated)
onAppsChange(apps)
}
const addApp = () => {
if (!newApp.name || !newApp.url) return
const apps = [...settings.apps, newApp]
const updated = { ...settings, apps }
setSettings(updated)
saveSettings(updated)
onAppsChange(apps)
setNewApp({ name: '', imageUrl: '', url: '', useProxy: false })
}
const removeApp = (index: number) => {
const apps = settings.apps.filter((_, i) => i !== index)
const updated = { ...settings, apps }
setSettings(updated)
saveSettings(updated)
onAppsChange(apps)
}
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
let domain = newSite.domain.trim()
if (domain.startsWith('https://')) domain = domain.slice(8)
else if (domain.startsWith('http://')) domain = domain.slice(7)
if (domain.endsWith('/')) domain = domain.slice(0, -1)
const movieSites = [...(settings.movieSites ?? []), { ...newSite, domain }]
const updated = { ...settings, movieSites }
setSettings(updated)
saveSettings(updated)
setNewSite({ domain: '', type: 'dle', enabled: true })
}
const removeMovieSite = (index: number) => {
const movieSites = (settings.movieSites ?? []).filter((_, i) => i !== index)
const updated = { ...settings, movieSites }
setSettings(updated)
saveSettings(updated)
}
const toggleMovieSite = (index: number) => {
const movieSites = (settings.movieSites ?? []).map((s, i) =>
i === index ? { ...s, enabled: !s.enabled } : s
)
const updated = { ...settings, movieSites }
setSettings(updated)
saveSettings(updated)
}
return (
<div className="settings-overlay" onClick={onClose}>
<div className="settings-panel" onClick={e => e.stopPropagation()}>
<div className="settings-header">
<h2>Настройки</h2>
<button className="settings-close" onClick={onClose}></button>
</div>
<div className="settings-section">
<h3>Прокси</h3>
<div className="proxy-row">
<input
className="settings-input"
placeholder="Хост"
value={settings.proxy.host}
onChange={e => updateProxy('host', e.target.value)}
/>
<span className="proxy-colon">:</span>
<input
className="settings-input proxy-port"
placeholder="Порт"
value={settings.proxy.port}
onChange={e => updateProxy('port', e.target.value)}
/>
</div>
<div className="proxy-info">
<code>http_proxy=http://{settings.proxy.host}:{settings.proxy.port}</code>
<code>https_proxy=http://{settings.proxy.host}:{settings.proxy.port}</code>
<code>socks5://{settings.proxy.host}:{settings.proxy.port}</code>
</div>
<p className="proxy-hint">
Прокси применяется к каждому сайту индивидуально переключатель рядом с каждым приложением.
</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">
{settings.apps.map((app, i) => (
<div key={i} className="settings-app-row">
<div className="settings-app-info">
{app.imageUrl && (
<img src={app.imageUrl} alt={app.name} className="settings-app-icon" />
)}
<div className="settings-app-text">
<span className="settings-app-name">{app.name}</span>
<span className="settings-app-url">{app.url}</span>
</div>
</div>
<div className="settings-app-actions">
<label className="proxy-switch-label">
<span>Прокси</span>
<div
className={`proxy-switch ${app.useProxy ? 'on' : 'off'}`}
onClick={() => toggleAppProxy(i)}
/>
</label>
<button className="settings-remove-btn" onClick={() => removeApp(i)}>✕</button>
</div>
</div>
))}
{settings.apps.length === 0 && (
<p className="settings-empty">Нет приложений. Добавьте ниже.</p>
)}
</div>
<div className="add-app-form">
<h4>Добавить приложение</h4>
<input
className="settings-input"
placeholder="Название"
value={newApp.name}
onChange={e => setNewApp({ ...newApp, name: e.target.value })}
/>
<input
className="settings-input"
placeholder="URL сайта"
value={newApp.url}
onChange={e => setNewApp({ ...newApp, url: e.target.value })}
/>
<input
className="settings-input"
placeholder="URL иконки (необязательно)"
value={newApp.imageUrl}
onChange={e => setNewApp({ ...newApp, imageUrl: e.target.value })}
/>
<label className="proxy-switch-label add-proxy-label">
<span>Использовать прокси</span>
<div
className={`proxy-switch ${newApp.useProxy ? 'on' : 'off'}`}
onClick={() => setNewApp({ ...newApp, useProxy: !newApp.useProxy })}
/>
</label>
<button className="settings-add-btn" onClick={addApp}>Добавить</button>
</div>
</div>
<div className="settings-section">
<h3>Поиск фильмов</h3>
<h4>TMDB API ключ</h4>
<input
className="settings-input"
placeholder="Получить бесплатно на themoviedb.org"
value={settings.tmdbApiKey ?? ''}
onChange={e => {
const updated = { ...settings, tmdbApiKey: e.target.value }
setSettings(updated)
saveSettings(updated)
}}
/>
<p className="proxy-hint">Нужен для постеров и метаданных. Без ключа поиск работает напрямую по сайтам.</p>
<h4>Сайты</h4>
<p className="proxy-hint">Тип определяется автоматически. Поддерживаются: kinogo, lordfilm, gidonline, hdrezka, filmix и их зеркала. Домен без https://</p>
<div className="settings-apps-list">
{(settings.movieSites ?? []).map((site, i) => (
<div key={i} className="settings-app-row">
<div className="settings-app-info">
<div className="settings-app-text">
<span className="settings-app-name">{site.domain}</span>
<span className="settings-app-url">{site.type}</span>
</div>
</div>
<div className="settings-app-actions">
<label className="proxy-switch-label">
<span>Вкл</span>
<div className={`proxy-switch ${site.enabled ? 'on' : 'off'}`} onClick={() => toggleMovieSite(i)} />
</label>
<button className="settings-remove-btn" onClick={() => removeMovieSite(i)}>✕</button>
</div>
</div>
))}
{!(settings.movieSites ?? []).length && (
<p className="settings-empty">Нет сайтов. Добавьте ниже.</p>
)}
</div>
<div className="add-app-form">
<h4>Добавить сайт</h4>
<input
className="settings-input"
placeholder="Домен (например: kinogo.cc)"
value={newSite.domain}
onChange={e => {
const domain = e.target.value
setNewSite({ ...newSite, domain, type: guessMovieSiteType(domain) })
}}
/>
<select
className="settings-select"
value={newSite.type}
onChange={e => setNewSite({ ...newSite, type: e.target.value as MovieSite['type'] })}
>
<option value="dle">DLE (kinogo, lordfilm и зеркала)</option>
<option value="hdrezka">HDRezka</option>
<option value="filmix">Filmix</option>
</select>
<button className="settings-add-btn" onClick={addMovieSite}>Добавить</button>
</div>
</div>
</div>
</div>
)
}
export default Settings