/* eslint-env node */ import { app, session, BrowserWindow, ipcMain, protocol, screen, shell, } from "electron"; import path from "path"; import fs from "fs"; import { ArchiveRequest, ArchiveResponse, Rewriter } from "@webrecorder/wabac"; import { Readable } from "stream"; import { autoUpdater } from "electron-updater"; import log from "electron-log"; import mime from "mime-types"; import url from "url"; import minimist from "minimist"; //global.Headers = Headers; //global.fetch = fetch; const STATIC_PREFIX = "http://localhost:5471/"; const REPLAY_PREFIX = STATIC_PREFIX + "w/"; const FILE_PROTO = "file2"; const URL_RX = /([^/]+)\/([\d]+)(?:\w\w_)?\/(.*)$/; // ============================================================================ class ElectronReplayApp { pluginPath = ""; appPath = app.getAppPath(); projPath = path.join(this.appPath, "../"); staticContentPath = "./"; profileName = ""; proxyColl: string | null = null; proxyTS: string | null = null; mainWindow: BrowserWindow | null = null; openNextFile: string | null = null; screenSize = { width: 1024, height: 768 }; origUA: string | null = null; constructor({ staticPath = "./", profileName = "" } = {}) { this.staticContentPath = staticPath; this.profileName = profileName; } get mainWindowWebPreferences() { return { plugins: true, preload: path.join(__dirname, "preload.js"), nativeWindowOpen: true, contextIsolation: true, enableRemoteModule: false, sandbox: false, nodeIntegration: false, }; } get mainWindowUrl() { return "index.html"; } init() { // Single instance check const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { console.log( "App already running, opening new window in first instance and quitting", ); app.quit(); } else { app.on("second-instance", (_, commandLine) => { // Just create a new window in case of second instance request this.createMainWindow(commandLine); }); } console.log("app path", this.appPath); console.log("dir name", __dirname); console.log("proj path", this.projPath); console.log("app data", app.getPath("appData")); console.log("user data", app.getPath("userData")); // possible workaround for GTK issue on Linux // https://github.com/webrecorder/replayweb.page/issues/430 // https://github.com/electron/electron/issues/46538#issuecomment-2808806722 if (process.env.APPIMAGE) { app.commandLine.appendSwitch("--gtk-version", "3"); } if (this.profileName) { app.setPath( "userData", path.join(app.getPath("appData"), this.profileName), ); } protocol.registerSchemesAsPrivileged([ { scheme: FILE_PROTO, privileges: { standard: false, secure: true, bypassCSP: true, allowServiceWorkers: true, supportFetchAPI: true, corsEnabled: true, stream: true, }, }, ]); app.on("will-finish-launching", () => { app.on("open-file", (event, filePath) => { this.openNextFile = filePath; if (this.mainWindow) { this.createMainWindow(process.argv); } }); }); app.on("activate", () => { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. if (BrowserWindow.getAllWindows().length === 0) { this.mainWindow = this.createMainWindow(process.argv); } }); void app.whenReady().then(() => this.onAppReady()); // Quit when all windows are closed. app.on("window-all-closed", function () { // On macOS it is common for applications and their menu bar // to stay active until the user quits explicitly with Cmd + Q //if (process.platform !== 'darwin') app.quit(); }); } checkUpdates() { autoUpdater.logger = log; // @ts-expect-error - TS2339 - Property 'transports' does not exist on type 'Logger'. autoUpdater.logger.transports.file.level = "info"; void autoUpdater.checkForUpdatesAndNotify(); } onAppReady() { this.checkUpdates(); this.screenSize = screen.getPrimaryDisplay().workAreaSize; app.on("web-contents-created", (event, contents) => { contents.setWindowOpenHandler(({ url }) => { // load docs in native browser for now if (url === STATIC_PREFIX + "docs") { void shell.openExternal("https://replayweb.page/docs/"); return { action: "deny" }; } // load external URLs in native browser if (!url.startsWith(STATIC_PREFIX)) { void shell.openExternal(url); return { action: "deny" }; } return { action: "allow" }; }); }); const sesh = session.defaultSession; sesh.protocol.handle("http", async (request: Request) => this.doIntercept(request), ); protocol.handle(FILE_PROTO, async (request: Request) => this.doHandleFile(request), ); this.origUA = sesh.getUserAgent(); this.mainWindow = this.createMainWindow(process.argv); } async doHandleFile(request: Request): Promise { //const parsedUrl = new URL(request.url); //const filename = parsedUrl.searchParams.get("filename"); if (request.url === FILE_PROTO + "://localhost") { return new Response("", { status: 200 }); } let urlStr = request.url.replace(FILE_PROTO, "file"); if (path.sep !== "/") { urlStr = urlStr.replace(/(\/\/\w)(\/\/)/, "$1:/"); urlStr = urlStr.replaceAll("/", path.sep); } const filename = url.fileURLToPath(urlStr); const headers = new Headers({ "Content-Type": "application/octet-stream" }); const reqHeaders = new Headers(request.headers); if (filename) { const stat = await fs.promises.lstat(filename); if (!stat.isFile()) { return this.notFound(filename); } const size = stat.size; const { status, start, end } = this.parseRange(reqHeaders, headers, size); const data = request.method === "HEAD" ? null : Readable.toWeb(fs.createReadStream(filename, { start, end })); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any return new Response(data as any, { status, headers }); } else { return this.notFound("No Resource Specified"); } } async doIntercept(request: Request): Promise { console.log(`${request.method} ${request.url} from ${request.referrer}`); // if local server if (request.url.startsWith(STATIC_PREFIX)) { //if replay prefix if (request.url.startsWith(REPLAY_PREFIX)) { const m = request.url.slice(REPLAY_PREFIX.length).match(URL_RX); if (m) { this.proxyColl = m[1]; this.proxyTS = m[2]; //request.url = m[3]; return await this.resolveArchiveResponse(request, m[3]); } } else { // try serve static file from app dir let filename = request.url.slice(STATIC_PREFIX.length).split("?", 1)[0]; filename = filename.split("#", 1)[0]; if (filename === "") { filename = "index.html"; } else if (filename === "docs") { filename = "docs/index.html"; } let ext = path.extname(filename); if (!ext) { ext = ".html"; filename += ext; } const mimeType = mime.contentType(ext); if (mimeType) { const fullPath = path.join(this.staticContentPath, filename); console.log("fullPath: " + fullPath); const data = fs.createReadStream(fullPath); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any return new Response(Readable.toWeb(data) as any, { headers: { "content-type": mimeType }, status: 200, }); } } return this.notFound(request.url); } // possible 'live leak' attempt, return archived version, if any if (request.referrer.startsWith(REPLAY_PREFIX)) { return await this.resolveArchiveResponse(request); } return await this.proxyLive(request); } async proxyLive(request: Request): Promise { let headers = request.headers; const { method, url, body } = request; // const body = uploadData // ? Readable.from(readBody(uploadData, session.defaultSession)) // : null; if (this.origUA) { headers = new Headers(headers); headers.set("User-Agent", this.origUA); } let response; try { response = await fetch(url, { method, headers, body }); } catch (e) { console.warn("fetch failed for: " + url); return new Response("", { status: 502 }); } const data = method === "HEAD" ? null : response.body; const status = response.status; return new Response(data, { status, headers: response.headers }); } notFound(url: string) { console.log("not found: " + url); const headers = { "Content-Type": 'text/html; charset="utf-8"' }; return new Response( `Sorry, the url ${url} could not be found in this archive.`, { status: 404, headers }, ); } async resolveArchiveResponse( request: Request, urlOverride?: string, ): Promise { const url = urlOverride || request.url; const channel = `req:${new Date().getTime()}:${url}`; let resolve: (r: Response) => void; const p = new Promise((r) => (resolve = r)); ipcMain.once( channel, async ( event, status: number, reqHeaders: Record, payload, ) => { if (status === 404 && !payload) { return this.notFound(url); } else { console.log("got response for: " + url); } let headers = new Headers(reqHeaders); const date = new Date(); let response: ArchiveResponse = new ArchiveResponse({ payload, headers, status, date, url, }); const rewriter = new Rewriter({ baseUrl: url, prefix: "", urlRewrite: false, contentRewrite: true, decode: true, useBaseRules: true, }); const arRequest = new ArchiveRequest(url, request); try { response = await rewriter.rewrite(response, arRequest); headers = response.headers; let data = await response.getBuffer(); if (!data) { data = new Uint8Array(); } if (status === 206 || status === 200) { headers = new Headers(response.headers); const res = this.parseRange(request.headers, headers, data.length); const { start, end } = res; status = res.status; if (start !== undefined) { data = data.slice(start, end); } } resolve(new Response(data, { status, headers })); } catch (e) { console.warn(e); } }, ); if (this.mainWindow) { this.mainWindow.webContents.send( "getresponse", request, this.proxyColl, this.proxyTS, channel, ); } return p; } parseRange(reqHeaders: Headers, headers: Headers, size: number) { let status = 200; const range = reqHeaders.get("range"); if (!range) { headers.set("content-length", "" + size); return { status }; } const m = range.match(/bytes=([\d]+)-([\d]*)/); if (!m) { return { status }; } const start = Number(m[1]); const end = m[2] ? Number(m[2]) : size - 1; status = 206; headers.set("content-range", `bytes ${start}-${end}/${size}`); headers.set("content-length", `${end - start + 1}`); return { status, start, end }; } createMainWindow(argv: string[]) { const sourceString = this.getOpenUrl(argv); // Create the browser window. const theWindow = new BrowserWindow({ width: this.screenSize.width, height: this.screenSize.height, show: false, webPreferences: this.mainWindowWebPreferences, }).once("ready-to-show", () => { theWindow.show(); theWindow.maximize(); }); void theWindow.loadURL(STATIC_PREFIX + this.mainWindowUrl + sourceString); if (process.env.NODE_ENV === "development") { theWindow.webContents.openDevTools(); } return theWindow; } getOpenUrl(argv: string[]) { const parsed = minimist(argv.slice(process.defaultApp ? 2 : 1)); const filename = this.openNextFile || parsed.filename || parsed.f || (parsed._.length && parsed._[0]); this.openNextFile = null; let sourceString = ""; if (filename) { const sourceParams = new URLSearchParams(); sourceParams.set("source", "file://" + filename); sourceString = "?" + sourceParams.toString(); const urlParams = new URLSearchParams(); const openUrl = parsed.url as string | undefined; const openTS = (parsed.ts || parsed.timestamp) as string | undefined; if (openUrl) { urlParams.set("url", openUrl); } if (openTS) { urlParams.set("ts", openTS); } sourceString += "#" + urlParams.toString(); console.log(`Opening Source: ${sourceString}`); } return sourceString; } } // async function* readBody(body, session) { // for (const chunk of body) { // if (chunk.bytes) { // yield await Promise.resolve(chunk.bytes); // } else if (chunk.blobUUID) { // yield await session.getBlobData(chunk.blobUUID); // } // } // } export { ElectronReplayApp, STATIC_PREFIX };