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

'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()
})