4 Commits

Author SHA1 Message Date
b5e1296a7a fix(1.0.8): strip cliqz adblocker preload — breaks CSP-strict sites
cliqz/adblocker-electron registers its preload at session level via
session.setPreloads([...preloads, PRELOAD_PATH]) inside
enableBlockingInSession. That preload injects inline <script> elements
via doc.createElement('script') + script.appendChild(textNode) +
parent.appendChild(script). On modern strict-CSP sites this breaks:

- Trusted Types (YouTube, Gmail): "An HTMLScriptElement was directly
  modified and will not be executed" — 52+ console errors.
- Nonce-required CSP (kinogo via Cloudflare): "Refused to execute inline
  script ... script-src 'nonce-...'" — competing with Cloudflare's
  challenge JS, likely the proximate cause of the 403 we see on kinogo
  (CF treats the broken page as bot).

Remove the cliqz preload from each session immediately after
enableBlockingInSession. The network/CSP/blockers attached via
webRequest hooks remain active — only the script-injection layer for
anti-anti-adblock scriptlets is lost, which is a niche feature that
breaks more sites than it fixes.

The 1.0.7 Blink TrustedDOMTypes disable stays (defense in depth, no
cost). The 1.0.6 CSP-header strip stays removed (adblocker overwrites
the webRequest listener anyway).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:24:47 +03:00
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
4 changed files with 93 additions and 40 deletions

92
main.js
View File

@@ -6,6 +6,16 @@ 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-v3.bin'); const BLOCKER_CACHE_PATH = path.join(os.homedir(), '.ESH-Media-adblock-v3.bin');
const DEFAULT_TRUSTED_DOMAINS = [ const DEFAULT_TRUSTED_DOMAINS = [
@@ -101,7 +111,27 @@ function getBlocker() {
function enableBlockingInSession(sess) { function enableBlockingInSession(sess) {
getBlocker() getBlocker()
.then(b => { b.enableBlockingInSession(sess); console.log('[adblock] enabled for session'); }) .then(b => {
b.enableBlockingInSession(sess);
// Remove the cliqz preload script that the blocker just registered on this
// session. The preload injects inline <script> elements (via createTextNode +
// appendChild) to neutralize anti-adblock scripts, but:
// • Strict-CSP sites (kinogo via Cloudflare, etc.) reject inline scripts
// without a matching nonce → "Refused to execute inline script".
// • Trusted-Types sites (YouTube, Gmail) reject `script.appendChild(text)`
// → "HTMLScriptElement was directly modified" (52 errors).
// We keep the adblocker's network blocking and CSP filtering (via the still-
// attached webRequest hooks), losing only the niche scriptlet/cosmetic-DOM
// injection layer that breaks more sites than it helps.
const before = sess.getPreloads();
const after = before.filter(p => !/adblocker-electron-preload/i.test(p));
if (after.length !== before.length) {
sess.setPreloads(after);
console.log('[adblock] enabled for session (preload script disabled)');
} else {
console.log('[adblock] enabled for session');
}
})
.catch(e => console.warn('[adblock] failed to enable:', e.message)); .catch(e => console.warn('[adblock] failed to enable:', e.message));
} }
@@ -334,6 +364,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,
@@ -347,6 +391,8 @@ async function createWindow() {
}, },
}); });
attachDevToolsShortcut(mainWindow.webContents);
if (isDev) { if (isDev) {
mainWindow.loadURL(RENDERER_URL); mainWindow.loadURL(RENDERER_URL);
} else { } else {
@@ -506,19 +552,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);
@@ -557,6 +606,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();
@@ -1190,23 +1240,14 @@ app.whenReady().then(async () => {
app.userAgentFallback = cleanUserAgent; app.userAgentFallback = cleanUserAgent;
session.defaultSession.setUserAgent(cleanUserAgent); session.defaultSession.setUserAgent(cleanUserAgent);
// Chrome version from the cleaned UA — used for client hints below // Add Referer to image requests so hotlink protection doesn't block them.
const chromeVerMatch = cleanUserAgent.match(/Chrome\/(\d+)/); // (Sec-CH-UA spoofing was tried in 1.0.4 and caused white pages — reverted.
const chromeMajor = chromeVerMatch ? chromeVerMatch[1] : '128'; // Google embedded-browser detection is now mitigated only via adblock whitelist
const secChUa = `"Not_A Brand";v="8", "Chromium";v="${chromeMajor}", "Google Chrome";v="${chromeMajor}"`; // of gstatic/google-analytics/etc., which previously was being eaten silently.)
session.defaultSession.webRequest.onBeforeSendHeaders(
const installRequestHooks = (sess) => {
sess.webRequest.onBeforeSendHeaders(
{ urls: ['https://*/*', 'http://*/*'] }, { urls: ['https://*/*', 'http://*/*'] },
(details, callback) => { (details, callback) => {
const headers = details.requestHeaders; const headers = details.requestHeaders;
// Spoof Sec-CH-UA so embedded-browser detectors (Google login, etc.) see
// a real-Chrome brand list. Electron normally injects the app name as
// the brand which is how Google fingerprints us as "embedded/unsafe".
headers['sec-ch-ua'] = secChUa;
headers['sec-ch-ua-mobile'] = '?0';
headers['sec-ch-ua-platform'] = '"Windows"';
// Add Referer to image requests so hotlink protection doesn't block them
if (details.resourceType === 'image' && !headers['Referer'] && !headers['referer']) { if (details.resourceType === 'image' && !headers['Referer'] && !headers['referer']) {
try { try {
const u = new URL(details.url); const u = new URL(details.url);
@@ -1216,11 +1257,10 @@ app.whenReady().then(async () => {
callback({ requestHeaders: headers }); callback({ requestHeaders: headers });
} }
); );
};
installRequestHooks(session.defaultSession); // (Trusted Types now handled engine-wide via --disable-blink-features
installRequestHooks(getProxySession()); // command-line switch at file top. webRequest.onHeadersReceived strip
installRequestHooks(getDirectSession()); // 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();

View File

@@ -1,6 +1,6 @@
{ {
"name": "ESH-Media", "name": "ESH-Media",
"version": "1.0.4", "version": "1.0.8",
"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;