|
|
|
|
@@ -6,8 +6,18 @@ const cheerio = require('cheerio');
|
|
|
|
|
const { ElectronBlocker, adsAndTrackingLists } = require('@cliqz/adblocker-electron');
|
|
|
|
|
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 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 = [
|
|
|
|
|
// Google ecosystem (OAuth)
|
|
|
|
|
'google.com', 'accounts.google.com', 'googleapis.com', 'googleusercontent.com',
|
|
|
|
|
@@ -30,6 +40,61 @@ const DEFAULT_CONFIG = { apps: [], proxy: { host: '127.0.0.1', port: '7890' }, t
|
|
|
|
|
let blockerPromise = null;
|
|
|
|
|
let cachedTrustedDomains = DEFAULT_TRUSTED_DOMAINS;
|
|
|
|
|
|
|
|
|
|
// chrome.* spoof: injected via executeJavaScript on every page's dom-ready.
|
|
|
|
|
// Goal is to look like real Chrome to JS-based "embedded browser" detectors
|
|
|
|
|
// (Google login, etc.). Cannot fix TLS-fingerprint detection — that's server-side.
|
|
|
|
|
const CHROME_SPOOF_JS = `(function(){
|
|
|
|
|
try {
|
|
|
|
|
if (!window.chrome) window.chrome = {};
|
|
|
|
|
var c = window.chrome;
|
|
|
|
|
if (!c.app) c.app = {
|
|
|
|
|
isInstalled: false,
|
|
|
|
|
InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
|
|
|
|
|
RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
|
|
|
|
|
getDetails: function(){ return null; },
|
|
|
|
|
getIsInstalled: function(){ return false; },
|
|
|
|
|
runningState: function(){ return 'cannot_run'; }
|
|
|
|
|
};
|
|
|
|
|
if (!c.runtime) c.runtime = {
|
|
|
|
|
PlatformOs: { MAC:'mac', WIN:'win', ANDROID:'android', CROS:'cros', LINUX:'linux', OPENBSD:'openbsd' },
|
|
|
|
|
PlatformArch: { ARM:'arm', X86_32:'x86-32', X86_64:'x86-64' },
|
|
|
|
|
PlatformNaclArch: { ARM:'arm', X86_32:'x86-32', X86_64:'x86-64' },
|
|
|
|
|
RequestUpdateCheckStatus: { NO_UPDATE:'no_update', THROTTLED:'throttled', UPDATE_AVAILABLE:'update_available' },
|
|
|
|
|
OnInstalledReason: { CHROME_UPDATE:'chrome_update', INSTALL:'install', SHARED_MODULE_UPDATE:'shared_module_update', UPDATE:'update' },
|
|
|
|
|
OnRestartRequiredReason: { APP_UPDATE:'app_update', OS_UPDATE:'os_update', PERIODIC:'periodic' },
|
|
|
|
|
sendMessage: function(){},
|
|
|
|
|
connect: function(){
|
|
|
|
|
return {
|
|
|
|
|
postMessage: function(){}, disconnect: function(){},
|
|
|
|
|
onDisconnect: { addListener: function(){}, removeListener: function(){} },
|
|
|
|
|
onMessage: { addListener: function(){}, removeListener: function(){} }
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if (!c.csi) c.csi = function(){ return { startE: Date.now()-1000, onloadT: Date.now()-500, pageT: 1000, tran: 15 }; };
|
|
|
|
|
if (!c.loadTimes) c.loadTimes = function(){
|
|
|
|
|
var t = performance.timing;
|
|
|
|
|
return {
|
|
|
|
|
commitLoadTime: t.responseStart/1000, connectionInfo: 'http/1.1',
|
|
|
|
|
finishDocumentLoadTime: t.domContentLoadedEventEnd/1000,
|
|
|
|
|
finishLoadTime: (t.loadEventEnd/1000) || 0,
|
|
|
|
|
firstPaintAfterLoadTime: 0, firstPaintTime: t.responseEnd/1000,
|
|
|
|
|
navigationType: 'Other', npnNegotiatedProtocol: 'h2',
|
|
|
|
|
requestTime: t.requestStart/1000, startLoadTime: t.fetchStart/1000,
|
|
|
|
|
wasAlternateProtocolAvailable: false, wasFetchedViaSpdy: true, wasNpnNegotiated: true
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
// navigator.permissions.query: Notification permission must agree with Notification.permission
|
|
|
|
|
if (navigator.permissions && navigator.permissions.query) {
|
|
|
|
|
var origQuery = navigator.permissions.query.bind(navigator.permissions);
|
|
|
|
|
navigator.permissions.query = function(p){
|
|
|
|
|
if (p && p.name === 'notifications') return Promise.resolve({ state: Notification.permission, onchange: null });
|
|
|
|
|
return origQuery(p);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
})();`;
|
|
|
|
|
|
|
|
|
|
function loadTrustedDomainsFromDisk() {
|
|
|
|
|
try {
|
|
|
|
|
if (fs.existsSync(CONFIG_PATH)) {
|
|
|
|
|
@@ -72,8 +137,26 @@ function getBlocker() {
|
|
|
|
|
'https://easylist-downloads.adblockplus.org/ruadlist+easylist.txt', // RuAdList
|
|
|
|
|
];
|
|
|
|
|
const b = await ElectronBlocker.fromLists(fetchFn, [...adsAndTrackingLists, ...russianLists]);
|
|
|
|
|
// Whitelist TMDB so the movie search API is not blocked
|
|
|
|
|
b.addFilters(['@@||api.themoviedb.org^', '@@||image.tmdb.org^', '@@||themoviedb.org^']);
|
|
|
|
|
// Whitelist domains that need ALL requests passed through unfiltered.
|
|
|
|
|
// 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()));
|
|
|
|
|
console.log('[adblock] filter lists downloaded and cached');
|
|
|
|
|
return b;
|
|
|
|
|
@@ -83,7 +166,27 @@ function getBlocker() {
|
|
|
|
|
|
|
|
|
|
function enableBlockingInSession(sess) {
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -316,6 +419,20 @@ ipcMain.handle('check-update-now', async () => {
|
|
|
|
|
|
|
|
|
|
// --- 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() {
|
|
|
|
|
mainWindow = new BrowserWindow({
|
|
|
|
|
width: 1280,
|
|
|
|
|
@@ -329,6 +446,8 @@ async function createWindow() {
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
attachDevToolsShortcut(mainWindow.webContents);
|
|
|
|
|
|
|
|
|
|
if (isDev) {
|
|
|
|
|
mainWindow.loadURL(RENDERER_URL);
|
|
|
|
|
} else {
|
|
|
|
|
@@ -488,19 +607,22 @@ async function restoreSession() {
|
|
|
|
|
const sess = cfg.openedSession;
|
|
|
|
|
if (!sess || !Array.isArray(sess.tabs) || !sess.tabs.length) return;
|
|
|
|
|
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
|
|
|
|
|
// synchronously; the view's loadURL is fire-and-forget. We chain via setTimeout to
|
|
|
|
|
// avoid stacking N loaders simultaneously.
|
|
|
|
|
// Spawn each saved tab by replaying create-view, sequentially with a small delay.
|
|
|
|
|
// Concurrent create-view calls in v1.0.3 caused races: multiple setLoader/addChild
|
|
|
|
|
// 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) {
|
|
|
|
|
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') {
|
|
|
|
|
ipcMain.emit('hide-view', { sender: mainWindow.webContents });
|
|
|
|
|
ipcMain.emit('hide-view', fakeEvent);
|
|
|
|
|
sendOpenedApps('home');
|
|
|
|
|
} 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) {
|
|
|
|
|
console.warn('[session] restore failed:', e.message);
|
|
|
|
|
@@ -539,6 +661,18 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
|
|
|
|
|
openedApps.push(appEntry);
|
|
|
|
|
currentView = appEntry;
|
|
|
|
|
view.setBounds(getViewBounds());
|
|
|
|
|
attachDevToolsShortcut(view.webContents);
|
|
|
|
|
|
|
|
|
|
// Experimental: spoof chrome.* JS objects on every page so Google's
|
|
|
|
|
// "embedded browser" detector sees a real-Chrome-shaped global. Runs on
|
|
|
|
|
// dom-ready which is AFTER <head> scripts, so detection scripts that ran
|
|
|
|
|
// there have already seen the un-spoofed environment — this fix only
|
|
|
|
|
// helps if Google's gate is re-checked on form submit / later events.
|
|
|
|
|
// TLS fingerprint (JA3) is server-side and unaffected; if Google flags us
|
|
|
|
|
// there, no client-side spoof helps. Best-effort attempt only.
|
|
|
|
|
view.webContents.on('dom-ready', () => {
|
|
|
|
|
view.webContents.executeJavaScript(CHROME_SPOOF_JS).catch(() => {});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
view.webContents.on('did-finish-load', () => {
|
|
|
|
|
removeLoader();
|
|
|
|
|
@@ -591,37 +725,30 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
|
|
|
|
|
trackNavigation(newUrl);
|
|
|
|
|
});
|
|
|
|
|
view.webContents.on('will-redirect', (_e, u) => trackNavigation(u));
|
|
|
|
|
view.webContents.setWindowOpenHandler(({ url: newUrl, frameName, features }) => {
|
|
|
|
|
view.webContents.setWindowOpenHandler(({ url: newUrl }) => {
|
|
|
|
|
let newHostname = '';
|
|
|
|
|
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.
|
|
|
|
|
// Trusted domain (Google, Yandex, etc.) → navigate IN-PLACE, no popup.
|
|
|
|
|
// 1.0.1 tried opening a real popup BrowserWindow here for OAuth postMessage
|
|
|
|
|
// flows — turns out Google specifically detects popup-style embedded
|
|
|
|
|
// browsers and blocks OAuth ("Возможно, этот браузер небезопасны").
|
|
|
|
|
// YouTube-style login uses standard redirect flow, so in-place navigation
|
|
|
|
|
// works AND avoids the popup fingerprint. 1.0.0 behavior, restored.
|
|
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
trackNavigation(newUrl);
|
|
|
|
|
view.webContents.loadURL(newUrl);
|
|
|
|
|
return { action: 'deny' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Untrusted cross-domain → ask the user (original behavior).
|
|
|
|
|
// Untrusted cross-domain → ask the user.
|
|
|
|
|
if (origHostname && newHostname && newHostname !== origHostname) {
|
|
|
|
|
pendingNavigate = { view, url: newUrl };
|
|
|
|
|
setConfirm(`Перейти на "${newHostname}"?`, 'navigate-confirmed');
|
|
|
|
|
return { action: 'deny' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Same-origin popup → just navigate the current view.
|
|
|
|
|
// Same-origin popup → navigate the current view.
|
|
|
|
|
trackNavigation(newUrl);
|
|
|
|
|
view.webContents.loadURL(newUrl);
|
|
|
|
|
return { action: 'deny' };
|
|
|
|
|
@@ -1172,7 +1299,10 @@ app.whenReady().then(async () => {
|
|
|
|
|
app.userAgentFallback = 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(
|
|
|
|
|
{ urls: ['https://*/*', 'http://*/*'] },
|
|
|
|
|
(details, callback) => {
|
|
|
|
|
@@ -1187,6 +1317,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
|
|
|
|
|
loadTrustedDomainsFromDisk();
|
|
|
|
|
try {
|
|
|
|
|
|