Files
replayweb.page/src/electron-replay-app.js
T

475 lines
13 KiB
JavaScript

import fetch from 'node-fetch';
import { Headers } from 'node-fetch';
import {app, session, BrowserWindow, ipcMain, screen, shell } from 'electron';
import path from 'path';
import fs from 'fs';
import { ArchiveResponse, Rewriter } from '@webrecorder/wabac/src/rewrite';
import { IPFSClient } from '@webrecorder/wabac/src/ipfs';
import { PassThrough, Readable } from 'stream';
import { autoUpdater } from "electron-updater";
import log from "electron-log";
import mime from 'mime-types';
global.Headers = Headers;
global.fetch = fetch;
const STATIC_PREFIX = "http://localhost:5471/";
const REPLAY_PREFIX = STATIC_PREFIX + "wabac/";
const URL_RX = /([^\/]+)\/([\d]+)(?:\w\w_)?\/(.*)$/;
let IPFS = null;
// ============================================================================
class NativeIPFSClient extends IPFSClient
{
async _doInitIPFS() {
if (!IPFS) {
IPFS = require("ipfs-core");
}
this.ipfs = await IPFS.create({
//repo: "/tmp/test-ipfs"
init: {emptyRepo: true},
//preload: {enabled: false},
});
this.resetGC();
}
}
// ============================================================================
class ElectronReplayApp
{
constructor({staticPath="./", profileName=""} = {}) {
this.pluginPath = "";
this.appPath = app.getAppPath();
this.projPath = path.join(this.appPath, "../");
this.staticContentPath = staticPath;
this.profileName = profileName;
this.proxyColl = null;
this.proxyTS = null;
this.mainWindow = null;
this.openNextFile = null;
this.screenSize = {width: 1024, height: 768};
this.ipfsClient = new NativeIPFSClient();
}
get mainWindowWebPreferences() {
return {
plugins: true,
preload: path.join(__dirname, 'preload.js'),
nativeWindowOpen: true,
contextIsolation: true,
enableRemoteModule: 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', (event, commandLine, workingDirectory) => {
// Just create a new window in case of second instance request
this.createMainWindow(commandLine);
});
}
switch (process.platform) {
case 'win32':
this.pluginPath = path.join(this.projPath, "plugins-win", `pepflashplayer-x86${process.arch === 'x64' ? '_64' : ''}.dll`);
break;
case 'darwin':
this.pluginPath = path.join(this.projPath, "plugins-mac", "PepperFlashPlayer.plugin");
break;
case 'linux':
this.pluginPath = path.join(this.projPath, "plugins-mac", "libpepflashplayer.so");
break;
default:
console.log('platform unsupported: ' + process.platform);
break;
}
app.commandLine.appendSwitch('ppapi-flash-path', this.pluginPath);
console.log('app path', this.appPath);
console.log('dir name', __dirname);
console.log('proj path', this.projPath);
console.log('plugin path', this.pluginPath);
console.log('app data', app.getPath('appData'));
console.log('user data', app.getPath('userData'));
if (this.profileName) {
app.setPath('userData', path.join(app.getPath('appData'), this.profileName));
}
app.on('will-finish-launching', () => {
app.on('open-file', (event, filePath) => {
this.openNextFile = filePath;
if (this.mainWindow) {
this.createMainWindow(process.argv);
}
});
});
app.on('activate', function () {
// 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);
}
});
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.channel = "alpha";
autoUpdater.logger = log;
autoUpdater.logger.transports.file.level = "debug";
autoUpdater.checkForUpdatesAndNotify();
}
onAppReady() {
this.checkUpdates();
this.screenSize = screen.getPrimaryDisplay().workAreaSize;
app.on('web-contents-created', (event, contents) => {
contents.on('new-window', async (event, navigationUrl) => {
// load docs in native browser for now
if (navigationUrl === STATIC_PREFIX + "docs") {
event.preventDefault();
await shell.openExternal("https://replayweb.page/docs/");
}
});
});
const sesh = session.defaultSession;
sesh.protocol.interceptStreamProtocol("http", (request, callback) => this.doIntercept(request, callback));
this.mainWindow = this.createMainWindow(process.argv);
}
_bufferToStream(data) {
const rv = new PassThrough();
rv.push(data);
rv.push(null);
return rv;
}
async doIntercept(request, callback) {
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, callback);
}
} 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);
return callback({statusCode: 200, headers: {"content-type": mimeType}, data});
}
}
return this.notFound(request.url, callback);
}
if (request.url.startsWith(__APP_FILE_SERVE_PREFIX__)) {
const parsedUrl = new URL(request.url);
const filename = parsedUrl.searchParams.get("filename");
const ipfsCID = parsedUrl.searchParams.get("ipfs");
const headers = {"Content-Type": "application/octet-stream"};
const reqHeaders = new Headers(request.headers);
if (filename) {
console.log("file serve:", filename);
const stat = await fs.promises.lstat(filename);
if (!stat.isFile()) {
return this.notFound(filename, callback);
}
const size = stat.size;
const {statusCode, start, end} = this.parseRange(reqHeaders, headers, size);
const data = request.method === "HEAD" ? null : fs.createReadStream(filename, {start, end});
callback({statusCode, headers, data});
return;
} else if (ipfsCID) {
console.log("ipfs serve:", ipfsCID);
await this.ipfsClient.initIPFS();
let size = await this.ipfsClient.getFileSize(ipfsCID);
if (size === null) {
return this.notFound(ipfsCID, callback);
}
const {statusCode, start, end} = this.parseRange(reqHeaders, headers, size);
let data = null;
if (request.method === "GET") {
const offset = start || 0;
const length = end ? end - start + 1 : size;
data = Readable.from(this.ipfsClient.cat(ipfsCID, {offset, length}));
}
callback({statusCode, headers, data});
return;
} else {
return this.notFound("No Resource Specified", callback);
}
}
// possible 'live leak' attempt, just return not found
if (request.referrer && request.referrer.startsWith(REPLAY_PREFIX)) {
return this.notFound(request.url, callback);
}
await this.proxyLive(request, callback);
}
async proxyLive(request, callback) {
let headers = request.headers;
const method = request.method;
const response = await fetch(request.url, {method, headers});
const data = method === "HEAD" ? null : response.body;
const statusCode = response.status;
headers = Object.fromEntries(response.headers.entries());
callback({statusCode, headers, data});
}
notFound(url, callback) {
console.log("not found: " + url);
const data = this._bufferToStream(`Sorry, the url <b>${url}</b> could not be found in this archive.`);
callback({statusCode: 404, headers: {"Content-Type": 'text/html; charset="utf-8"'}, data});
}
async resolveArchiveResponse(request, callback) {
const channel = `req:${new Date().getTime()}:${request.url}`;
ipcMain.once(channel, async (event, status, headers, payload) => {
const url = request.url;
if (status === 404 && !payload) {
return notFound(url, callback);
} else {
console.log("got response for: " + url);
}
headers = new Headers(headers);
const date = new Date();
let response = new ArchiveResponse({payload, headers, status, date, url});
const rewriter = new Rewriter({
baseUrl: url,
prefix: "",
urlRewrite: false,
contentRewrite: true,
decode: true,
useBaseRules: true
});
request.headers = new Headers(request.headers);
try {
response = await rewriter.rewrite(response, request);
headers = Object.fromEntries(response.headers.entries());
let data = await response.getBuffer();
if (status === 206 || status === 200) {
const {statusCode, start, end} = this.parseRange(request.headers, headers, data.length);
if (start !== undefined) {
data = data.slice(start, end);
}
status = statusCode;
}
data = this._bufferToStream(data);
callback({statusCode: status, headers, data});
} catch (e) {
console.warn(e);
}
});
if (this.mainWindow) {
this.mainWindow.webContents.send("getresponse", request, this.proxyColl, this.proxyTS, channel);
}
}
parseRange(reqHeaders, headers, size) {
let statusCode = 200;
const range = reqHeaders.get("range");
if (!range) {
if (headers) {
headers["Content-Length"] = "" + size;
}
return {statusCode};
}
const m = range.match(/bytes=([\d]+)-([\d]*)/);
if (!m) {
return {statusCode};
}
const start = Number(m[1]);
const end = m[2] ? Number(m[2]) : size - 1;
statusCode = 206;
if (headers) {
headers["Content-Range"] = `bytes ${start}-${end}/${size}`;
headers["Content-Length"] = `${end - start + 1}`;
}
return {statusCode, start, end};
}
createMainWindow(argv) {
const sourceString = this.getOpenUrl(argv);
// Create the browser window.
const theWindow = new BrowserWindow({
width: this.screenSize.width,
height: this.screenSize.height,
isMaximized: true,
show: false,
webPreferences: this.mainWindowWebPreferences,
}).once('ready-to-show', () => {
theWindow.show();
theWindow.maximize();
});
theWindow.loadURL(STATIC_PREFIX + this.mainWindowUrl + sourceString);
if (process.env.NODE_ENV === "development") {
theWindow.webContents.openDevTools();
}
return theWindow;
}
getOpenUrl(argv) {
argv = require('minimist')(argv.slice(process.defaultApp ? 2 : 1));
const filename = this.openNextFile || argv.filename || argv.f || (argv._.length && argv._[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 = argv.url;
const openTS = argv.ts || argv.timestamp;
if (openUrl) {
urlParams.set("url", openUrl);
}
if (openTS) {
urlParams.set("ts", openTS);
}
sourceString += "#" + urlParams.toString();
console.log(`Opening Source: ${sourceString}`);
}
return sourceString;
}
}
export { ElectronReplayApp, STATIC_PREFIX };