4 Commits

Author SHA1 Message Date
e80704c534 fix(1.0.7): disable Trusted Types engine-wide via Blink feature flag
The 1.0.6 fix (strip require-trusted-types-for from CSP via
onHeadersReceived) didn't take effect: cliqz/adblocker calls
session.webRequest.onHeadersReceived during enableBlockingInSession,
overwriting our hook (Electron permits only one listener per session).

Replace with engine-level kill switch:
  app.commandLine.appendSwitch('disable-blink-features', 'TrustedDOMTypes')

Makes the entire Trusted Types runtime feature inert, so
require-trusted-types-for CSP becomes a no-op site-wide. Safe in this
kiosk/single-user context; only relaxes one security boundary that
sites use to harden against XSS via adblocker-style script injection —
which is exactly what we need to neutralize for cliqz's anti-anti-adblock
scriptlets on YouTube.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:14:19 +03:00
c9c9e1171b fix(1.0.6): strip Trusted Types CSP on YouTube/Google to unbreak adblocker
YouTube response sends Content-Security-Policy: require-trusted-types-for
'script' which blocks the cliqz adblocker's inline-script injection used
to neutralize YT's anti-adblock detection (52 "HTMLScriptElement was
directly modified and will not be executed" console errors).

Strip require-trusted-types-for and trusted-types directives from CSP
and CSP-Report-Only headers for youtube.com / youtu.be / google.com /
gmail.com (and subdomains) via onHeadersReceived on all 3 sessions.
Other CSP directives stay intact so site-level security boundaries hold.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:01:26 +03:00
461e7ed737 fix(1.0.5): revert Sec-CH-UA spoof (white pages), add DevTools shortcut
- Sec-CH-UA / Sec-CH-UA-Mobile / Sec-CH-UA-Platform header overrides on
  every request in 1.0.4 broke page rendering (all views white). Reverted
  to image-Referer-only behavior from 1.0.3. The Google "embedded browser"
  fix in 1.0.4 came primarily from the adblock whitelist (which IS kept)
  — Sec-CH-UA spoofing was the suspect for the regression.
- Ctrl+Shift+I and F12 now open DevTools on the main shell and on every
  in-app browser view. Always-on so kiosk machines can be debugged
  without leaving kiosk mode.
- Restore session sequenced (await 150ms between tabs) to avoid concurrent
  create-view races where multiple setLoader/addChild interleaved.
- Update banner now shows error state with a "Повторить" button instead
  of hiding it, so install-update failures are visible to the user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:24:38 +03:00
542be8135a fix: whitelist Google/OAuth domains in adblock, spoof Sec-CH-UA brand
Two-part fix for Google login "Возможно, этот браузер небезопасны" error:

1. The adblocker was eating Google integrity-check resources (gstatic,
   google-analytics, googletagmanager — flagged by EasyPrivacy). Add @@
   whitelist filters for Google, Yandex, Microsoft, Apple, Facebook,
   GitHub, VK, Mail.ru ecosystems. Also switch from non-existent
   addFilters() to updateFromDiff({added}) — previous TMDB whitelist was
   silently failing in a then().catch() and never applied. Adblock cache
   bumped to v3 so the new filters take effect.
2. Sec-CH-UA client-hints branding was leaking Electron app name as the
   browser brand. Override sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform
   headers via webRequest.onBeforeSendHeaders on all 3 sessions so
   embedded-browser detectors see real-Chrome brand list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:54:35 +03:00
4 changed files with 82 additions and 14 deletions

77
main.js
View File

@@ -6,8 +6,18 @@ const cheerio = require('cheerio');
const { ElectronBlocker, adsAndTrackingLists } = require('@cliqz/adblocker-electron'); const { ElectronBlocker, adsAndTrackingLists } = require('@cliqz/adblocker-electron');
const { autoUpdater } = require('electron-updater'); 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_TRUSTED_DOMAINS = [ const DEFAULT_TRUSTED_DOMAINS = [
// Google ecosystem (OAuth) // Google ecosystem (OAuth)
'google.com', 'accounts.google.com', 'googleapis.com', 'googleusercontent.com', 'google.com', 'accounts.google.com', 'googleapis.com', 'googleusercontent.com',
@@ -72,8 +82,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;
@@ -316,6 +344,20 @@ ipcMain.handle('check-update-now', async () => {
// --- 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,
@@ -329,6 +371,8 @@ async function createWindow() {
}, },
}); });
attachDevToolsShortcut(mainWindow.webContents);
if (isDev) { if (isDev) {
mainWindow.loadURL(RENDERER_URL); mainWindow.loadURL(RENDERER_URL);
} else { } else {
@@ -488,19 +532,22 @@ async function restoreSession() {
const sess = cfg.openedSession; const sess = cfg.openedSession;
if (!sess || !Array.isArray(sess.tabs) || !sess.tabs.length) return; if (!sess || !Array.isArray(sess.tabs) || !sess.tabs.length) return;
console.log(`[session] restoring ${sess.tabs.length} tab(s), active=${sess.activeName}`); console.log(`[session] restoring ${sess.tabs.length} tab(s), active=${sess.activeName}`);
// Spawn each saved tab by replaying create-view. ipcMain.emit triggers the handler // Spawn each saved tab by replaying create-view, sequentially with a small delay.
// synchronously; the view's loadURL is fire-and-forget. We chain via setTimeout to // Concurrent create-view calls in v1.0.3 caused races: multiple setLoader/addChild
// avoid stacking N loaders simultaneously. // 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) { for (const tab of sess.tabs) {
if (!tab?.name || !tab?.url) continue; if (!tab?.name || !tab?.url) continue;
ipcMain.emit('create-view', { sender: mainWindow.webContents }, tab.name, tab.url, tab.imageUrl || '', 1.0, !!tab.useProxy); ipcMain.emit('create-view', fakeEvent, tab.name, tab.url, tab.imageUrl || '', 1.0, !!tab.useProxy);
await new Promise(r => setTimeout(r, 150));
} }
// After all spawned, the last one is `currentView`. Switch to the saved active if different. // Switch to saved active if it isn't already the last-spawned (currentView).
if (sess.activeName === 'home') { if (sess.activeName === 'home') {
ipcMain.emit('hide-view', { sender: mainWindow.webContents }); ipcMain.emit('hide-view', fakeEvent);
sendOpenedApps('home'); sendOpenedApps('home');
} else if (sess.activeName && sess.activeName !== currentView?.name) { } else if (sess.activeName && sess.activeName !== currentView?.name) {
ipcMain.emit('show-view', { sender: mainWindow.webContents }, sess.activeName); ipcMain.emit('show-view', fakeEvent, sess.activeName);
} }
} catch (e) { } catch (e) {
console.warn('[session] restore failed:', e.message); console.warn('[session] restore failed:', e.message);
@@ -539,6 +586,7 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
openedApps.push(appEntry); openedApps.push(appEntry);
currentView = appEntry; currentView = appEntry;
view.setBounds(getViewBounds()); view.setBounds(getViewBounds());
attachDevToolsShortcut(view.webContents);
view.webContents.on('did-finish-load', () => { view.webContents.on('did-finish-load', () => {
removeLoader(); removeLoader();
@@ -1172,7 +1220,10 @@ app.whenReady().then(async () => {
app.userAgentFallback = cleanUserAgent; app.userAgentFallback = cleanUserAgent;
session.defaultSession.setUserAgent(cleanUserAgent); session.defaultSession.setUserAgent(cleanUserAgent);
// Add Referer to image requests so hotlink protection doesn't block them // 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) => {
@@ -1187,6 +1238,10 @@ 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(); loadTrustedDomainsFromDisk();
try { try {

View File

@@ -1,6 +1,6 @@
{ {
"name": "ESH-Media", "name": "ESH-Media",
"version": "1.0.3", "version": "1.0.7",
"private": true, "private": true,
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View File

@@ -290,14 +290,22 @@ const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
)} )}
</div> </div>
{updateStatus && updateStatus.state !== 'error' && ( {updateStatus && (
<div className="update-banner"> <div className={`update-banner${updateStatus.state === 'error' ? ' error' : ''}`}>
{updateStatus.state === 'available' && ( {updateStatus.state === 'available' && (
<> <>
<span className="update-banner-spinner" /> <span className="update-banner-spinner" />
<span>Загружается обновление {updateStatus.version}{updateStatus.currentVersion ? ` (текущая ${updateStatus.currentVersion})` : ''}</span> <span>Загружается обновление {updateStatus.version}{updateStatus.currentVersion ? ` (текущая ${updateStatus.currentVersion})` : ''}</span>
</> </>
)} )}
{updateStatus.state === 'error' && (
<>
<span>Ошибка обновления: {updateStatus.message}</span>
<button className="update-banner-btn" onClick={() => window.electron?.checkUpdateNow?.()}>
Повторить
</button>
</>
)}
{updateStatus.state === 'downloading' && ( {updateStatus.state === 'downloading' && (
<> <>
<span>Скачивается {updateStatus.version || 'обновление'}: {updateStatus.percent}%</span> <span>Скачивается {updateStatus.version || 'обновление'}: {updateStatus.percent}%</span>

View File

@@ -1480,6 +1480,11 @@ 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 { .update-banner-progress {
flex: 1; flex: 1;
height: 4px; height: 4px;