You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
426 lines
13 KiB
426 lines
13 KiB
'use strict' |
|
|
|
const { app, BrowserWindow, ipcMain, shell, Menu, session, net } = require('electron') |
|
const fs = require('fs') |
|
const http = require('http') |
|
const path = require('path') |
|
|
|
/** True when running from source (`electron .`); false when packaged. */ |
|
const isDev = !app.isPackaged |
|
|
|
/** Stable loopback origin for packaged builds so YouTube embeds get a real `origin` (see YoutubeEmbeddedPlayer). */ |
|
const PACKAGED_STATIC_PORT_START = 45279 |
|
const PACKAGED_STATIC_PORT_TRIES = 40 |
|
|
|
const MIME = { |
|
'.html': 'text/html; charset=utf-8', |
|
'.js': 'application/javascript; charset=utf-8', |
|
'.mjs': 'application/javascript; charset=utf-8', |
|
'.css': 'text/css; charset=utf-8', |
|
'.json': 'application/json', |
|
'.png': 'image/png', |
|
'.jpg': 'image/jpeg', |
|
'.jpeg': 'image/jpeg', |
|
'.gif': 'image/gif', |
|
'.webp': 'image/webp', |
|
'.svg': 'image/svg+xml', |
|
'.ico': 'image/x-icon', |
|
'.woff': 'font/woff', |
|
'.woff2': 'font/woff2', |
|
'.webmanifest': 'application/manifest+json', |
|
'.txt': 'text/plain; charset=utf-8', |
|
'.wasm': 'application/wasm', |
|
'.map': 'application/json' |
|
} |
|
|
|
let packagedStaticServer = null |
|
/** @type {string | null} */ |
|
let packagedStaticBaseUrl = null |
|
/** @type {Promise<string> | null} */ |
|
let packagedStaticStartPromise = null |
|
|
|
function resolveUnderDist(distDir, pathname) { |
|
let rel = pathname |
|
try { |
|
rel = decodeURIComponent(pathname) |
|
} catch { |
|
return null |
|
} |
|
rel = rel.replace(/^\/+/, '') |
|
const candidate = path.resolve(distDir, rel) |
|
const root = path.resolve(distDir) |
|
const prefix = root.endsWith(path.sep) ? root : root + path.sep |
|
if (candidate !== root && !candidate.startsWith(prefix)) return null |
|
return candidate |
|
} |
|
|
|
function createPackagedStaticHandler(distDir) { |
|
const indexPath = path.join(distDir, 'index.html') |
|
|
|
return function handleRequest(req, res) { |
|
if (req.method !== 'GET' && req.method !== 'HEAD') { |
|
res.writeHead(405).end() |
|
return |
|
} |
|
|
|
let pathname = '/' |
|
try { |
|
pathname = new URL(req.url || '/', 'http://127.0.0.1').pathname |
|
} catch { |
|
pathname = '/' |
|
} |
|
|
|
const sendIndex = () => { |
|
fs.readFile(indexPath, (err, data) => { |
|
if (err) { |
|
res.writeHead(500).end('Missing index.html') |
|
return |
|
} |
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': data.length }) |
|
res.end(req.method === 'HEAD' ? undefined : data) |
|
}) |
|
} |
|
|
|
const filePath = resolveUnderDist(distDir, pathname) |
|
if (!filePath) { |
|
res.writeHead(403).end() |
|
return |
|
} |
|
|
|
fs.stat(filePath, (err, st) => { |
|
if (!err && st.isFile()) { |
|
const ext = path.extname(filePath).toLowerCase() |
|
const ct = MIME[ext] || 'application/octet-stream' |
|
res.writeHead(200, { 'Content-Type': ct, 'Content-Length': st.size }) |
|
if (req.method === 'HEAD') { |
|
res.end() |
|
return |
|
} |
|
fs.createReadStream(filePath).pipe(res) |
|
return |
|
} |
|
sendIndex() |
|
}) |
|
} |
|
} |
|
|
|
function startPackagedStaticServer(distDir) { |
|
return new Promise((resolve, reject) => { |
|
const handler = createPackagedStaticHandler(distDir) |
|
const server = http.createServer(handler) |
|
|
|
const listenOn = (port, attempt) => { |
|
if (attempt >= PACKAGED_STATIC_PORT_TRIES) { |
|
reject(new Error('No free port for packaged static server')) |
|
return |
|
} |
|
const onErr = (err) => { |
|
server.removeListener('error', onErr) |
|
if (err && err.code === 'EADDRINUSE') { |
|
listenOn(port + 1, attempt + 1) |
|
} else { |
|
reject(err) |
|
} |
|
} |
|
server.on('error', onErr) |
|
server.listen(port, '127.0.0.1', () => { |
|
server.removeListener('error', onErr) |
|
packagedStaticServer = server |
|
const addr = server.address() |
|
const p = typeof addr === 'object' && addr ? addr.port : port |
|
packagedStaticBaseUrl = `http://127.0.0.1:${p}` |
|
resolve(packagedStaticBaseUrl) |
|
}) |
|
} |
|
|
|
listenOn(PACKAGED_STATIC_PORT_START, 0) |
|
}) |
|
} |
|
|
|
function ensurePackagedStaticBaseUrl() { |
|
if (packagedStaticBaseUrl) return Promise.resolve(packagedStaticBaseUrl) |
|
if (!packagedStaticStartPromise) { |
|
const distDir = path.join(__dirname, '..', 'dist') |
|
packagedStaticStartPromise = startPackagedStaticServer(distDir).catch((err) => { |
|
packagedStaticStartPromise = null |
|
throw err |
|
}) |
|
} |
|
return packagedStaticStartPromise |
|
} |
|
|
|
function resolveWindowIcon() { |
|
const candidates = isDev |
|
? [path.join(__dirname, '..', 'public', 'favicon.png')] |
|
: [path.join(__dirname, '..', 'dist', 'favicon.png')] |
|
for (const p of candidates) { |
|
try { |
|
if (fs.existsSync(p)) return p |
|
} catch { |
|
// ignore |
|
} |
|
} |
|
return undefined |
|
} |
|
|
|
function loadRenderer(win) { |
|
if (isDev) { |
|
const devUrl = process.env.VITE_DEV_SERVER_URL || 'http://127.0.0.1:5173' |
|
void win.loadURL(devUrl) |
|
if (!win.webContents.isDevToolsOpened()) { |
|
win.webContents.openDevTools({ mode: 'detach' }) |
|
} |
|
return |
|
} |
|
void ensurePackagedStaticBaseUrl() |
|
.then((baseUrl) => { |
|
if (win.isDestroyed()) return |
|
const u = new URL('/', baseUrl) |
|
u.search = '' |
|
u.hash = '' |
|
void win.loadURL(u.href) |
|
}) |
|
.catch((err) => { |
|
console.error('Packaged static server failed, falling back to file://', err) |
|
if (!win.isDestroyed()) { |
|
void win.loadFile(path.join(__dirname, '..', 'dist', 'index.html')) |
|
} |
|
}) |
|
} |
|
|
|
/** |
|
* Packaged (and dev) renderer runs on http://127.0.0.1; hls.js and other fetches hit third-party |
|
* streams without CORS. Chromium still enforces CORS, so inject permissive CORS on subresources only. |
|
* LanguageTool / LibreTranslate use POST + non-simple Content-Type → preflight must see Allow-Methods / |
|
* Allow-Headers, not only ACAO. |
|
*/ |
|
function relaxCorsForRendererSubresources() { |
|
const stripCors = new Set([ |
|
'access-control-allow-origin', |
|
'access-control-allow-credentials', |
|
'access-control-allow-methods', |
|
'access-control-allow-headers', |
|
'access-control-expose-headers', |
|
'access-control-max-age' |
|
]) |
|
session.defaultSession.webRequest.onHeadersReceived((details, callback) => { |
|
if (details.resourceType === 'mainFrame' || details.resourceType === 'subFrame') { |
|
callback({ cancel: false, responseHeaders: details.responseHeaders }) |
|
return |
|
} |
|
// Main-process `net.fetch` (translate / LanguageTool IPC) has no `webContentsId` — do not rewrite |
|
// response headers; the consumer is Node/Chromium fetch in main, not a renderer CORS check. |
|
const url = String(details.url || '') |
|
if ( |
|
!details.webContentsId && |
|
(/\/api\/translate(?:\/|\?|$)/u.test(url) || /\/api\/languagetool(?:\/|\?|$)/u.test(url)) |
|
) { |
|
callback({ cancel: false, responseHeaders: details.responseHeaders }) |
|
return |
|
} |
|
const raw = details.responseHeaders |
|
if (!raw) { |
|
callback({ cancel: false }) |
|
return |
|
} |
|
const responseHeaders = { ...raw } |
|
for (const key of Object.keys(responseHeaders)) { |
|
if (stripCors.has(key.toLowerCase())) { |
|
delete responseHeaders[key] |
|
} |
|
} |
|
responseHeaders['Access-Control-Allow-Origin'] = ['*'] |
|
responseHeaders['Access-Control-Allow-Methods'] = ['GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS'] |
|
responseHeaders['Access-Control-Allow-Headers'] = [ |
|
'Authorization,Content-Type,Accept,Accept-Language,Origin,X-Requested-With' |
|
] |
|
callback({ cancel: false, responseHeaders }) |
|
}) |
|
} |
|
|
|
/** Hostnames allowed for main-process translate / LanguageTool proxy (HTTPS only, except loopback HTTP for dev). */ |
|
function parseImwaldBackendHosts() { |
|
const raw = process.env.IMWALD_ELECTRON_BACKEND_HOSTS || 'jumble.imwald.eu' |
|
return new Set( |
|
raw |
|
.split(/[,;\s]+/u) |
|
.map((s) => s.trim().toLowerCase()) |
|
.filter(Boolean) |
|
) |
|
} |
|
|
|
const imwaldBackendHosts = parseImwaldBackendHosts() |
|
|
|
function isAllowedImwaldBackendUrl(urlString) { |
|
let u |
|
try { |
|
u = new URL(urlString) |
|
} catch { |
|
return false |
|
} |
|
const path = u.pathname |
|
if (!path.startsWith('/api/translate') && !path.startsWith('/api/languagetool')) { |
|
return false |
|
} |
|
const host = u.hostname.toLowerCase() |
|
if (u.protocol === 'https:' && imwaldBackendHosts.has(host)) return true |
|
if (u.protocol === 'http:' && (host === '127.0.0.1' || host === 'localhost')) return true |
|
return false |
|
} |
|
|
|
const STRIP_OUTBOUND_REQUEST_HEADERS = new Set([ |
|
'host', |
|
'connection', |
|
'content-length', |
|
'transfer-encoding', |
|
'keep-alive' |
|
]) |
|
|
|
/** |
|
* Use Chromium’s network stack (same TLS / HTTP2 behavior as the browser). Node’s `https` can fail |
|
* where `net.fetch` succeeds (e.g. cert chains, SNI, corporate proxies already trusted by Chromium). |
|
*/ |
|
async function requestImwaldBackend(urlString, { method, headers, body }) { |
|
const safeHeaders = {} |
|
if (headers && typeof headers === 'object') { |
|
for (const [k, v] of Object.entries(headers)) { |
|
if (STRIP_OUTBOUND_REQUEST_HEADERS.has(k.toLowerCase())) continue |
|
if (typeof v === 'string') safeHeaders[k] = v |
|
} |
|
} |
|
const m = (method || 'GET').toUpperCase() |
|
const init = { |
|
method: m, |
|
headers: safeHeaders |
|
} |
|
if (body != null && m !== 'GET' && m !== 'HEAD') { |
|
init.body = body |
|
} |
|
const res = await net.fetch(urlString, init) |
|
const text = await res.text() |
|
const outHeaders = {} |
|
res.headers.forEach((value, key) => { |
|
outHeaders[key] = value |
|
}) |
|
return { |
|
status: res.status, |
|
statusText: res.statusText || '', |
|
headers: outHeaders, |
|
body: text |
|
} |
|
} |
|
|
|
function registerImwaldBackendRequestIpc() { |
|
try { |
|
ipcMain.removeHandler('imwald:backend-request') |
|
} catch { |
|
/* ignore if channel was never registered */ |
|
} |
|
ipcMain.handle('imwald:backend-request', async (_event, payload) => { |
|
const url = payload && typeof payload.url === 'string' ? payload.url : '' |
|
if (!isAllowedImwaldBackendUrl(url)) { |
|
throw new Error('imwald:backend-request: URL not allowed') |
|
} |
|
const method = payload && typeof payload.method === 'string' ? payload.method : 'GET' |
|
const headers = payload && payload.headers && typeof payload.headers === 'object' ? payload.headers : {} |
|
const body = payload && typeof payload.body === 'string' ? payload.body : null |
|
return requestImwaldBackend(url, { method, headers, body }) |
|
}) |
|
} |
|
|
|
function createWindow() { |
|
const win = new BrowserWindow({ |
|
width: 1280, |
|
height: 840, |
|
minWidth: 400, |
|
minHeight: 500, |
|
show: false, |
|
icon: resolveWindowIcon(), |
|
webPreferences: { |
|
preload: path.join(__dirname, 'preload.cjs'), |
|
contextIsolation: true, |
|
nodeIntegration: false, |
|
sandbox: true, |
|
// Packaged shell is loopback `http://127.0.0.1` → public HTTPS APIs; Chromium CORS blocks |
|
// renderer `fetch` unless disabled for this window (IPC + net.fetch still used as defense). |
|
webSecurity: !app.isPackaged |
|
} |
|
}) |
|
|
|
win.once('ready-to-show', () => win.show()) |
|
|
|
loadRenderer(win) |
|
|
|
win.webContents.setWindowOpenHandler(({ url }) => { |
|
if (url.startsWith('http:') || url.startsWith('https:')) { |
|
void shell.openExternal(url) |
|
} |
|
return { action: 'deny' } |
|
}) |
|
|
|
// Electron does not show Chromium’s full-page context menu; without this, users only get in-app UI |
|
// (e.g. selection highlight) and no Copy / Paste / Select all for text fields and content. |
|
win.webContents.on('context-menu', (_event, params) => { |
|
const template = [] |
|
|
|
if (params.linkURL && /^https?:\/\//i.test(params.linkURL)) { |
|
template.push({ |
|
label: 'Open link in browser', |
|
click: () => void shell.openExternal(params.linkURL) |
|
}) |
|
template.push({ type: 'separator' }) |
|
} |
|
|
|
template.push( |
|
{ role: 'cut', enabled: params.editFlags.canCut }, |
|
{ role: 'copy', enabled: params.editFlags.canCopy }, |
|
{ role: 'paste', enabled: params.editFlags.canPaste }, |
|
{ type: 'separator' }, |
|
{ role: 'selectAll', enabled: params.editFlags.canSelectAll } |
|
) |
|
|
|
if (isDev) { |
|
template.push({ type: 'separator' }) |
|
template.push({ |
|
label: 'Inspect element', |
|
click: () => win.webContents.inspectElement(params.x, params.y) |
|
}) |
|
} |
|
|
|
Menu.buildFromTemplate(template).popup({ window: win }) |
|
}) |
|
} |
|
|
|
app.whenReady().then(() => { |
|
relaxCorsForRendererSubresources() |
|
registerImwaldBackendRequestIpc() |
|
|
|
ipcMain.handle('imwald:reload-app', async (event) => { |
|
const win = BrowserWindow.fromWebContents(event.sender) |
|
if (!win || win.isDestroyed()) return false |
|
loadRenderer(win) |
|
return true |
|
}) |
|
|
|
createWindow() |
|
app.on('activate', () => { |
|
if (BrowserWindow.getAllWindows().length === 0) createWindow() |
|
}) |
|
}) |
|
|
|
app.on('will-quit', () => { |
|
if (packagedStaticServer) { |
|
try { |
|
packagedStaticServer.close() |
|
} catch { |
|
// ignore |
|
} |
|
packagedStaticServer = null |
|
packagedStaticBaseUrl = null |
|
packagedStaticStartPromise = null |
|
} |
|
}) |
|
|
|
app.on('window-all-closed', () => { |
|
if (process.platform !== 'darwin') app.quit() |
|
})
|
|
|