Fix browser screenshot capture for parked webviews
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
out/
|
||||
fatal-error.txt
|
||||
@@ -0,0 +1,74 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Paseo Capture Harness Target</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
width: 1280px;
|
||||
min-height: 1600px;
|
||||
margin: 0;
|
||||
background: #ff00ff;
|
||||
overflow: auto;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
left: 48px;
|
||||
top: 48px;
|
||||
color: #ffffff;
|
||||
font-size: 72px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
text-shadow:
|
||||
5px 5px 0 #000000,
|
||||
-2px -2px 0 #000000;
|
||||
}
|
||||
|
||||
.sub {
|
||||
position: absolute;
|
||||
left: 54px;
|
||||
top: 150px;
|
||||
color: #000000;
|
||||
background: #ffffff;
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
padding: 10px 18px;
|
||||
}
|
||||
|
||||
.block {
|
||||
position: absolute;
|
||||
left: 56px;
|
||||
top: 220px;
|
||||
width: 360px;
|
||||
height: 92px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#000000 0 25%,
|
||||
#ffffff 25% 50%,
|
||||
#000000 50% 75%,
|
||||
#ffffff 75% 100%
|
||||
);
|
||||
border: 8px solid #000000;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: absolute;
|
||||
left: 48px;
|
||||
top: 1370px;
|
||||
color: #ffffff;
|
||||
font-size: 60px;
|
||||
font-weight: 800;
|
||||
text-shadow: 5px 5px 0 #000000;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="label">PASEO CAPTURE HARNESS</div>
|
||||
<div class="sub">LOCAL MAGENTA TARGET 1280x800 VIEWPORT</div>
|
||||
<div class="block"></div>
|
||||
<div class="bottom">FULL PAGE MARKER AT Y=1370</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,166 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Paseo Browser Capture Harness</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: #202020;
|
||||
overflow: hidden;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
#paseo-browser-resident-webviews {
|
||||
position: fixed;
|
||||
left: -20000px;
|
||||
top: 0;
|
||||
width: 1280px;
|
||||
height: 800px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#target-webview {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
width: 1280px;
|
||||
height: 800px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="paseo-browser-resident-webviews" aria-hidden="true"></div>
|
||||
|
||||
<script>
|
||||
const RESIDENT_VIEWPORT_WIDTH = 1280;
|
||||
const RESIDENT_VIEWPORT_HEIGHT = 800;
|
||||
const host = document.getElementById("paseo-browser-resident-webviews");
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const activeTokens = new Set();
|
||||
let nextTokenId = 0;
|
||||
|
||||
const webview = document.createElement("webview");
|
||||
webview.id = "target-webview";
|
||||
webview.setAttribute("data-paseo-browser-id", "capture-harness");
|
||||
webview.setAttribute("partition", "persist:paseo-capture-harness");
|
||||
webview.setAttribute("allowpopups", "true");
|
||||
webview.setAttribute("spellcheck", "false");
|
||||
webview.setAttribute("autosize", "on");
|
||||
webview.src = params.get("targetUrl") || "bright.html";
|
||||
host.appendChild(webview);
|
||||
|
||||
function applyParking() {
|
||||
host.style.position = "fixed";
|
||||
host.style.left = "-20000px";
|
||||
host.style.top = "0";
|
||||
host.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
|
||||
host.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
|
||||
host.style.overflow = "hidden";
|
||||
host.style.opacity = "0";
|
||||
host.style.pointerEvents = "none";
|
||||
host.style.zIndex = "";
|
||||
host.style.clipPath = "";
|
||||
host.style.visibility = "";
|
||||
host.style.transform = "";
|
||||
webview.style.display = "inline-flex";
|
||||
webview.style.flex = "0 0 auto";
|
||||
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
|
||||
webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
|
||||
webview.style.border = "0";
|
||||
webview.style.background = "transparent";
|
||||
}
|
||||
|
||||
function applyCapturePrep() {
|
||||
host.style.position = "fixed";
|
||||
host.style.left = "0";
|
||||
host.style.top = "0";
|
||||
host.style.width = "1px";
|
||||
host.style.height = "1px";
|
||||
host.style.overflow = "hidden";
|
||||
host.style.opacity = "1";
|
||||
host.style.pointerEvents = "none";
|
||||
host.style.zIndex = "1";
|
||||
host.style.clipPath = "";
|
||||
host.style.visibility = "";
|
||||
host.style.transform = "";
|
||||
}
|
||||
|
||||
function waitFrames(count) {
|
||||
return new Promise((resolve) => {
|
||||
function step(remaining) {
|
||||
if (remaining <= 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => step(remaining - 1));
|
||||
}
|
||||
step(count);
|
||||
});
|
||||
}
|
||||
|
||||
function state() {
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
const webviewRect = webview.getBoundingClientRect();
|
||||
return {
|
||||
hostStyle: {
|
||||
left: host.style.left,
|
||||
top: host.style.top,
|
||||
width: host.style.width,
|
||||
height: host.style.height,
|
||||
overflow: host.style.overflow,
|
||||
opacity: host.style.opacity,
|
||||
pointerEvents: host.style.pointerEvents,
|
||||
},
|
||||
hostRect: {
|
||||
x: hostRect.x,
|
||||
y: hostRect.y,
|
||||
width: hostRect.width,
|
||||
height: hostRect.height,
|
||||
},
|
||||
webviewRect: {
|
||||
x: webviewRect.x,
|
||||
y: webviewRect.y,
|
||||
width: webviewRect.width,
|
||||
height: webviewRect.height,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
window.captureHarness = {
|
||||
async waitForFrames(count = 2) {
|
||||
await waitFrames(count);
|
||||
return state();
|
||||
},
|
||||
async prepareForPixelCapture() {
|
||||
const token = `capture-${++nextTokenId}`;
|
||||
activeTokens.add(token);
|
||||
applyCapturePrep();
|
||||
await waitFrames(2);
|
||||
webview.getBoundingClientRect();
|
||||
return { token, state: state() };
|
||||
},
|
||||
async restorePixelCapture(token) {
|
||||
activeTokens.delete(token);
|
||||
if (activeTokens.size === 0) {
|
||||
applyParking();
|
||||
}
|
||||
await waitFrames(2);
|
||||
return state();
|
||||
},
|
||||
async restoreParking() {
|
||||
activeTokens.clear();
|
||||
applyParking();
|
||||
await waitFrames(2);
|
||||
return state();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,378 @@
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { app, BrowserWindow, nativeImage } = require("electron");
|
||||
|
||||
const ROOT = __dirname;
|
||||
const OUT_DIR = process.env.PASEO_CAPTURE_HARNESS_OUT_DIR || path.join(ROOT, "out");
|
||||
const VIEWPORT_WIDTH = 1280;
|
||||
const VIEWPORT_HEIGHT = 800;
|
||||
const FULL_PAGE_HEIGHT = 1600;
|
||||
const CAPTURE_TIMEOUT_MS = 5000;
|
||||
const REPEAT_COUNT = 5;
|
||||
|
||||
function fileUrl(filePath) {
|
||||
return new URL(`file://${filePath}`).toString();
|
||||
}
|
||||
|
||||
function ensureDirSync(dir) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function withTimeout(promise, label) {
|
||||
let timeoutId;
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${CAPTURE_TIMEOUT_MS}ms`));
|
||||
}, CAPTURE_TIMEOUT_MS);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => {
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
}
|
||||
|
||||
function pixelOffset(width, x, y) {
|
||||
return (y * width + x) * 4;
|
||||
}
|
||||
|
||||
function isBrightMagenta(bitmap, offset) {
|
||||
const c0 = bitmap[offset];
|
||||
const c1 = bitmap[offset + 1];
|
||||
const c2 = bitmap[offset + 2];
|
||||
return c0 > 200 && c1 < 90 && c2 > 200;
|
||||
}
|
||||
|
||||
function analyzeImage(image, expected, guestMetrics) {
|
||||
if (!image || image.isEmpty()) {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
brightRatio: 0,
|
||||
textNonUniform: false,
|
||||
pass: false,
|
||||
};
|
||||
}
|
||||
|
||||
const size = image.getSize();
|
||||
const width = size.width;
|
||||
const height = size.height;
|
||||
const bitmap = image.toBitmap();
|
||||
const totalPixels = width * height;
|
||||
let brightPixels = 0;
|
||||
for (let offset = 0; offset < bitmap.length; offset += 4) {
|
||||
if (isBrightMagenta(bitmap, offset)) {
|
||||
brightPixels += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const crop = {
|
||||
left: Math.min(40, Math.max(0, width - 1)),
|
||||
top: Math.min(40, Math.max(0, height - 1)),
|
||||
right: Math.min(width, 940),
|
||||
bottom: Math.min(height, 260),
|
||||
};
|
||||
let cropPixels = 0;
|
||||
let cropNonBright = 0;
|
||||
let luminanceSum = 0;
|
||||
let luminanceSqSum = 0;
|
||||
const quantized = new Set();
|
||||
for (let y = crop.top; y < crop.bottom; y += 1) {
|
||||
for (let x = crop.left; x < crop.right; x += 1) {
|
||||
const offset = pixelOffset(width, x, y);
|
||||
cropPixels += 1;
|
||||
if (!isBrightMagenta(bitmap, offset)) {
|
||||
cropNonBright += 1;
|
||||
}
|
||||
const r = bitmap[offset + 2];
|
||||
const g = bitmap[offset + 1];
|
||||
const b = bitmap[offset];
|
||||
const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
luminanceSum += luma;
|
||||
luminanceSqSum += luma * luma;
|
||||
quantized.add(`${r >> 5},${g >> 5},${b >> 5},${bitmap[offset + 3] >> 6}`);
|
||||
}
|
||||
}
|
||||
|
||||
const devicePixelRatio =
|
||||
typeof guestMetrics.devicePixelRatio === "number" && guestMetrics.devicePixelRatio > 0
|
||||
? guestMetrics.devicePixelRatio
|
||||
: 1;
|
||||
const sizeTargets = [
|
||||
{ width: expected.width, height: expected.height },
|
||||
{
|
||||
width: Math.round(expected.width * devicePixelRatio),
|
||||
height: Math.round(expected.height * devicePixelRatio),
|
||||
},
|
||||
];
|
||||
const matchedSize = sizeTargets.some(
|
||||
(target) => Math.abs(width - target.width) <= 2 && Math.abs(height - target.height) <= 2,
|
||||
);
|
||||
const luminanceMean = cropPixels ? luminanceSum / cropPixels : 0;
|
||||
const luminanceVariance = cropPixels
|
||||
? luminanceSqSum / cropPixels - luminanceMean * luminanceMean
|
||||
: 0;
|
||||
const brightRatio = totalPixels ? brightPixels / totalPixels : 0;
|
||||
const textNonUniform =
|
||||
cropPixels > 0 &&
|
||||
cropNonBright / cropPixels > 0.02 &&
|
||||
quantized.size >= 4 &&
|
||||
luminanceVariance > 100;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
logicalWidthAtDpr: width / devicePixelRatio,
|
||||
logicalHeightAtDpr: height / devicePixelRatio,
|
||||
brightRatio,
|
||||
textNonUniform,
|
||||
matchedSize,
|
||||
pass: matchedSize && brightRatio >= expected.minBrightRatio && textNonUniform,
|
||||
};
|
||||
}
|
||||
|
||||
function pass(message) {
|
||||
console.log(`PASS ${message}`);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.log(`FAIL ${message}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
async function saveImage(image, outputPath) {
|
||||
await fsp.writeFile(outputPath, image.toPNG());
|
||||
}
|
||||
|
||||
async function waitForGuestLoad(contents) {
|
||||
await new Promise((resolve) => {
|
||||
if (!contents.isLoading()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
contents.once("did-finish-load", resolve);
|
||||
contents.once("did-fail-load", resolve);
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
async function renderer(win, expression) {
|
||||
return await win.webContents.executeJavaScript(expression, true);
|
||||
}
|
||||
|
||||
async function readGuestMetrics(contents) {
|
||||
return await contents.executeJavaScript(
|
||||
`({
|
||||
innerWidth: window.innerWidth,
|
||||
innerHeight: window.innerHeight,
|
||||
devicePixelRatio: window.devicePixelRatio,
|
||||
documentClientWidth: document.documentElement.clientWidth,
|
||||
documentClientHeight: document.documentElement.clientHeight,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
scrollHeight: document.documentElement.scrollHeight,
|
||||
visualViewport: window.visualViewport ? {
|
||||
width: window.visualViewport.width,
|
||||
height: window.visualViewport.height,
|
||||
scale: window.visualViewport.scale
|
||||
} : null
|
||||
})`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async function capturePageSequence(contents) {
|
||||
const previousBackgroundThrottling = contents.getBackgroundThrottling();
|
||||
contents.setBackgroundThrottling(false);
|
||||
try {
|
||||
contents.invalidate();
|
||||
return await withTimeout(contents.capturePage(undefined, { stayHidden: false }), "capturePage");
|
||||
} finally {
|
||||
contents.setBackgroundThrottling(previousBackgroundThrottling);
|
||||
}
|
||||
}
|
||||
|
||||
async function captureFullPage(contents) {
|
||||
let attachedHere = false;
|
||||
if (!contents.debugger.isAttached()) {
|
||||
contents.debugger.attach("1.3");
|
||||
attachedHere = true;
|
||||
}
|
||||
try {
|
||||
const metrics = await contents.debugger.sendCommand("Page.getLayoutMetrics");
|
||||
const contentSize = metrics.cssContentSize ||
|
||||
metrics.contentSize || {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: VIEWPORT_WIDTH,
|
||||
height: FULL_PAGE_HEIGHT,
|
||||
};
|
||||
const clip = {
|
||||
x: Math.floor(contentSize.x || 0),
|
||||
y: Math.floor(contentSize.y || 0),
|
||||
width: Math.ceil(contentSize.width || VIEWPORT_WIDTH),
|
||||
height: Math.ceil(contentSize.height || FULL_PAGE_HEIGHT),
|
||||
scale: 1,
|
||||
};
|
||||
const result = await withTimeout(
|
||||
contents.debugger.sendCommand("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
captureBeyondViewport: true,
|
||||
clip,
|
||||
}),
|
||||
"CDP Page.captureScreenshot",
|
||||
);
|
||||
return nativeImage.createFromBuffer(Buffer.from(result.data, "base64"));
|
||||
} finally {
|
||||
if (attachedHere && contents.debugger.isAttached()) {
|
||||
contents.debugger.detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function captureWithPrep({ win, contents, mode, repeatIndex, guestMetrics }) {
|
||||
const preparation = await renderer(win, "window.captureHarness.prepareForPixelCapture()");
|
||||
const outputPath = path.join(OUT_DIR, `${mode}-prep-${repeatIndex}.png`);
|
||||
try {
|
||||
const image =
|
||||
mode === "viewport" ? await capturePageSequence(contents) : await captureFullPage(contents);
|
||||
await saveImage(image, outputPath);
|
||||
const expected =
|
||||
mode === "viewport"
|
||||
? { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT, minBrightRatio: 0.65 }
|
||||
: { width: VIEWPORT_WIDTH, height: FULL_PAGE_HEIGHT, minBrightRatio: 0.55 };
|
||||
const analysis = analyzeImage(image, expected, guestMetrics);
|
||||
const size = `${analysis.width}x${analysis.height}`;
|
||||
const logicalSize = `${analysis.logicalWidthAtDpr}x${analysis.logicalHeightAtDpr}`;
|
||||
const bright = analysis.brightRatio.toFixed(4);
|
||||
if (!analysis.pass) {
|
||||
fail(
|
||||
`${mode} prep ${repeatIndex}/${REPEAT_COUNT} size=${size} logical=${logicalSize} bright=${bright} text=${analysis.textNonUniform} file=${outputPath}`,
|
||||
);
|
||||
}
|
||||
pass(
|
||||
`${mode} prep ${repeatIndex}/${REPEAT_COUNT} size=${size} logical=${logicalSize} bright=${bright} text=${analysis.textNonUniform} file=${outputPath}`,
|
||||
);
|
||||
return analysis;
|
||||
} finally {
|
||||
const restoredState = await renderer(
|
||||
win,
|
||||
`window.captureHarness.restorePixelCapture(${JSON.stringify(preparation.token)})`,
|
||||
);
|
||||
const style = restoredState.hostStyle;
|
||||
if (style.left !== "-20000px" || style.opacity !== "0") {
|
||||
fail(`restore left=${style.left} opacity=${style.opacity}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
ensureDirSync(OUT_DIR);
|
||||
|
||||
let resolveGuest;
|
||||
const guestPromise = new Promise((resolve) => {
|
||||
resolveGuest = resolve;
|
||||
});
|
||||
const win = new BrowserWindow({
|
||||
width: 1000,
|
||||
height: 700,
|
||||
show: true,
|
||||
backgroundColor: "#202020",
|
||||
webPreferences: {
|
||||
webviewTag: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
|
||||
win.webContents.on("will-attach-webview", (_event, webPreferences) => {
|
||||
webPreferences.nodeIntegration = false;
|
||||
webPreferences.contextIsolation = true;
|
||||
});
|
||||
win.webContents.on("did-attach-webview", (_event, contents) => {
|
||||
resolveGuest(contents);
|
||||
});
|
||||
|
||||
await win.loadFile(path.join(ROOT, "index.html"), {
|
||||
query: { targetUrl: fileUrl(path.join(ROOT, "bright.html")) },
|
||||
});
|
||||
const guest = await withTimeout(guestPromise, "did-attach-webview");
|
||||
await waitForGuestLoad(guest);
|
||||
await renderer(win, "window.captureHarness.waitForFrames(2)");
|
||||
const guestMetrics = await readGuestMetrics(guest);
|
||||
|
||||
if (guestMetrics.innerWidth !== VIEWPORT_WIDTH || guestMetrics.innerHeight !== VIEWPORT_HEIGHT) {
|
||||
fail(
|
||||
`guest viewport sizing inner=${guestMetrics.innerWidth}x${guestMetrics.innerHeight} expected=${VIEWPORT_WIDTH}x${VIEWPORT_HEIGHT}`,
|
||||
);
|
||||
}
|
||||
pass(
|
||||
`guest viewport sizing inner=${guestMetrics.innerWidth}x${guestMetrics.innerHeight} dpr=${guestMetrics.devicePixelRatio}`,
|
||||
);
|
||||
|
||||
await renderer(win, "window.captureHarness.restoreParking()");
|
||||
try {
|
||||
const image = await capturePageSequence(guest);
|
||||
const analysis = analyzeImage(
|
||||
image,
|
||||
{ width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT, minBrightRatio: 0.65 },
|
||||
guestMetrics,
|
||||
);
|
||||
fail(
|
||||
`parked webview unexpectedly captured size=${analysis.width}x${analysis.height} bright=${analysis.brightRatio.toFixed(4)}`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
pass(`parked webview has no copyable viewport frame error=${message}`);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (let index = 1; index <= REPEAT_COUNT; index += 1) {
|
||||
results.push(
|
||||
await captureWithPrep({
|
||||
win,
|
||||
contents: guest,
|
||||
mode: "viewport",
|
||||
repeatIndex: index,
|
||||
guestMetrics,
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (let index = 1; index <= REPEAT_COUNT; index += 1) {
|
||||
results.push(
|
||||
await captureWithPrep({
|
||||
win,
|
||||
contents: guest,
|
||||
mode: "full-page",
|
||||
repeatIndex: index,
|
||||
guestMetrics,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await fsp.writeFile(
|
||||
path.join(OUT_DIR, "results.json"),
|
||||
`${JSON.stringify({ generatedAt: new Date().toISOString(), guestMetrics, results }, null, 2)}\n`,
|
||||
);
|
||||
pass(`capture harness complete output=${OUT_DIR}`);
|
||||
|
||||
if (!win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(main)
|
||||
.then(() => app.quit())
|
||||
.catch(async (error) => {
|
||||
console.error(error);
|
||||
try {
|
||||
await fsp.writeFile(
|
||||
path.join(OUT_DIR, "fatal-error.txt"),
|
||||
`${error && error.stack ? error.stack : String(error)}\n`,
|
||||
);
|
||||
} catch {
|
||||
// Ignore reporting failures during shutdown.
|
||||
}
|
||||
app.exit(1);
|
||||
});
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
|
||||
|
||||
exec "$REPO_ROOT/node_modules/.bin/electron" "$SCRIPT_DIR/main.js"
|
||||
Reference in New Issue
Block a user