mirror of
https://github.com/morluto/rea.git
synced 2026-10-18 20:10:13 -05:00
fix(electron): block synchronous shell effects during capture
Block showItemInFolder and writeShortcutLink using the shared active shell boundary, preserving undefined/false synchronous refusal contracts and shape-only timeline evidence. Retain Nitin13-git implementation and add base-red/shared-contract coverage plus a reproducible unchanged Joplin welcome-attachment reveal. Local87cases, static and docs pass. The explicit test argv shim verifies the operation phase; standard startup and provider cleanup remain unsuccessful and are recorded without a full-workflow performance claim. Fixes #4393 Co-authored-by: Nitin13-git <internshipsvc01@gmail.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
6 files changed
+2290
-3
No files matched your search
@@ -114,6 +114,11 @@ window, remains pending until cancelled. After cancellation, the final state
|
||||
read has 5 seconds before REA keeps the initial snapshot instead; the
|
||||
cancellation keeps the caller's reason and records that expiry in its
|
||||
diagnostics.
|
||||
Blocked synchronous shell APIs keep their return contracts:
|
||||
`showItemInFolder` returns `undefined` and `writeShortcutLink` returns `false`.
|
||||
Their `shell-attempt` events record the blocked phase and argument shapes;
|
||||
neither result means the requested host operation occurred. The existing
|
||||
`openExternal` and `openPath` blocks continue to reject their promises.
|
||||
CLI SIGINT/SIGTERM requests cancellation and awaits owned-process cleanup.
|
||||
Window IDs, URLs, and cached titles come from one main-process Electron snapshot,
|
||||
so a crashed renderer does not require a successful renderer-side title query.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,91 @@
|
||||
# Electron synchronous shell boundary verification — 2026-10-16
|
||||
|
||||
Fix #4393 reuses Nitin13-git's existing PR #4562. The boundary owns a fixed
|
||||
method/return table: existing asynchronous blocks still reject, while
|
||||
`showItemInFolder` returns `undefined` and `writeShortcutLink` returns `false`.
|
||||
Both new blocks use the existing shape-only event recorder, shared by CLI/MCP.
|
||||
No tool, schema, capture authority, analysis cache or launch behavior is added.
|
||||
|
||||
## Source and controlled regression
|
||||
|
||||
The fresh main base is `f26973a52918a938631e90fdc9116f7b85a62a52`;
|
||||
implementation integration is `0386b60fb` and frozen verifier source is
|
||||
`3f231735c3fbc28918590ddc61461871c8a2aa4d`.
|
||||
The adjacent JSON records frozen final script/runtime/input hashes and all attempts;
|
||||
unpinned exploratory helper revisions are explicitly identified.
|
||||
Original contributor attribution remains in Git ancestry.
|
||||
|
||||
Using the candidate test with the base boundary produces **73 passes / 1 failure**:
|
||||
the fake native shortcut implementation executes and returns true. With the
|
||||
candidate boundary, **87 cases pass** (74 staged-hook, 5 projection, 8 MCP).
|
||||
These test native dispatch suppression, synchronous results, shape-only
|
||||
retention and the shared event schema. The fake Electron cases and controlled
|
||||
MCP provider do not prove a real Windows shell implementation was exercised.
|
||||
`npm run check:fast` passes both uncached tasks. Build-cache differences and
|
||||
different case counts make the red/green timings unsuitable as a speed comparison.
|
||||
|
||||
## Real Joplin workflow and limits
|
||||
|
||||
Inputs: unchanged Joplin Desktop **3.7.21** ASAR (233,575,014 bytes), and the
|
||||
explicit generic Electron **44.7.0** runtime. Acquisition/checksum references
|
||||
are in `javascript-native-handoff-identity-2026-10-15.json` and
|
||||
`electron-cancellation-2026-10-16.json`; this is not vendor-executable compatibility.
|
||||
No executable fuse is changed. Large downloadable inputs and generated profiles
|
||||
are not committed or retained as an experiment bundle.
|
||||
|
||||
The verifier opens Joplin's shipped **1. Welcome to Joplin!** note and real PNG
|
||||
attachment. It selects Joplin's actual editor layout and resource context-menu
|
||||
callbacks; Joplin's existing bridge calls the real patched shell method. The
|
||||
native popup presentation is captured for automation; its application callbacks
|
||||
are not replaced. Before any app actions, the verifier requires the active hook.
|
||||
It requires zero earlier reveal events, one new blocked shape-only reveal event,
|
||||
a still-rendered attachment and an identical event in the shared retained capture.
|
||||
|
||||
**Standard launch remains unsuccessful.** The earlier standard probe reports
|
||||
`Unknown flag: -r` in Joplin's start-flag parser; the frozen standard verifier
|
||||
times out waiting for the application search input. This is relevant to #2974,
|
||||
not fixed here. A discarded NODE_OPTIONS experiment was invalid: Playwright
|
||||
removes that variable, so no hook was installed. Those attempts are never used
|
||||
as interception or performance evidence.
|
||||
|
||||
The explicit `--argv-shim-transport` test adapter loads the provider's staged
|
||||
canonical hook and removes only its own exact `-r`/shim pair from app argv before
|
||||
Joplin starts. It does not modify the ASAR or production provider. Its successful
|
||||
operation phase is not a successful standard public CLI/MCP workflow. Provider
|
||||
cleanup still fails (#4282); the verifier exits nonzero, preserving the distinction.
|
||||
External owned-scope teardown does not establish provider cleanup.
|
||||
|
||||
The first valid shim probe reaches one blocked reveal, visible 1075×666 PNG,
|
||||
no page errors and retained capture evidence. The final frozen rerun additionally
|
||||
checks event causality and exact capture equality. Detailed measured results and
|
||||
whole-family observation status are in the JSON. The final controlled pipeline
|
||||
takes16.989s wall /16.055812 family CPU s /1,376,382,976 peak cgroup bytes
|
||||
with verified payload-exit observation; the overall workflow still fails cleanup. Each final trial has a new
|
||||
private config mount; earlier exploratory shared-config trials are not independent
|
||||
cold runs. Filesystem/runtime/build caches are warm; no cold-system or warm-repeat
|
||||
speedup is claimed. Agent/billed tokens are unmeasured; there is no token benefit claim.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Use the pinned Node/npm toolchain. Install dependencies and build with
|
||||
`npm ci` and `npm run build:cached`. Create an owned writable packet directory
|
||||
with `tmp`, `profiles`, and `config/joplin-desktop`. On Linux, bind that private
|
||||
config over the actual user config directory in a disposable `bwrap` mount
|
||||
namespace (do not change HOME), keep the application resources read-only and
|
||||
run under Xvfb. Set TMPDIR to the owned writable packet `tmp` directory. Run:
|
||||
|
||||
```sh
|
||||
node experiments/analysis-efficiency/joplin-shell-boundary.mjs \
|
||||
--runtime-root /absolute/rea-checkout \
|
||||
--executable /absolute/generic-electron \
|
||||
--application /absolute/Joplin/resources/app.asar \
|
||||
--profile-root /absolute/owned-packet/profiles \
|
||||
--argv-shim-transport
|
||||
```
|
||||
|
||||
Omit the last flag for the failing standard launch. Run heavy work serially under
|
||||
`resource-run.py --memory-gib 8 --observe-payload-exit`, one CPU, no swap, the
|
||||
6 GiB early-stop margin and 192-task ceiling. CPU/peak memory receipts are at
|
||||
payload exit with the observer alive; memory includes file cache, not just RSS.
|
||||
Unverified complete-family observations are explicitly marked. No Windows host,
|
||||
macOS host, vendor packaged runtime or full `check:pr` was executed here.
|
||||
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env node
|
||||
// Unchanged Joplin ASAR and its shipped welcome note/attachment. The actual
|
||||
// resource context menu, bridge and shell call execute; menu automation is explicit.
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
import { _electron } from "playwright-core";
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
"runtime-root": { type: "string" },
|
||||
executable: { type: "string" },
|
||||
application: { type: "string" },
|
||||
"profile-root": { type: "string" },
|
||||
"argv-shim-transport": { type: "boolean", default: false },
|
||||
},
|
||||
});
|
||||
for (const key of ["runtime-root", "executable", "application", "profile-root"])
|
||||
assert.ok(values[key], `Missing --${key}`);
|
||||
const runtime = resolve(values["runtime-root"]);
|
||||
const { PlaywrightElectronActiveProvider } = await import(
|
||||
pathToFileURL(
|
||||
join(runtime, "dist/browser/PlaywrightElectronActiveProvider.js"),
|
||||
)
|
||||
);
|
||||
const { captureElectronScenario } = await import(
|
||||
pathToFileURL(
|
||||
join(
|
||||
runtime,
|
||||
"dist/application/javascript/ElectronActiveObservationService.js",
|
||||
),
|
||||
)
|
||||
);
|
||||
const profile = await mkdtemp(
|
||||
join(resolve(values["profile-root"]), "joplin-shell-"),
|
||||
);
|
||||
let application;
|
||||
let child;
|
||||
let probe;
|
||||
const started = performance.now();
|
||||
const controller = new AbortController();
|
||||
const deadline = setTimeout(
|
||||
() => controller.abort(new Error("Verifier deadline")),
|
||||
45_000,
|
||||
);
|
||||
|
||||
const launch = async (options) => {
|
||||
let launchOptions = options;
|
||||
if (values["argv-shim-transport"]) {
|
||||
// Explicit test adapter for #2974, not the public provider launch path.
|
||||
const args = [...options.args];
|
||||
const index = args.indexOf("-r");
|
||||
assert.notEqual(index, -1, "Expected the provider's staged require pair");
|
||||
const hook = args[index + 1];
|
||||
const shim = join(profile, "launch-shim.cjs");
|
||||
await writeFile(
|
||||
shim,
|
||||
`require(${JSON.stringify(hook)});\nconst index = process.argv.indexOf("-r");\nif (index < 0 || process.argv[index + 1] !== __filename) throw new Error("Unexpected staged argv");\nprocess.argv.splice(index, 2);\n`,
|
||||
);
|
||||
args[index + 1] = shim;
|
||||
launchOptions = { ...options, args };
|
||||
}
|
||||
application = await _electron.launch(launchOptions);
|
||||
child = application.process();
|
||||
assert.equal(
|
||||
await application.evaluate(
|
||||
() => typeof globalThis.__reaElectronActiveSnapshot,
|
||||
),
|
||||
"function",
|
||||
"The staged active hook must be installed before app actions",
|
||||
);
|
||||
const page = await application.firstWindow({ timeout: 15_000 });
|
||||
const pageErrors = [];
|
||||
page.on("pageerror", (error) => {
|
||||
if (pageErrors.length < 5) pageErrors.push(String(error).slice(0, 600));
|
||||
});
|
||||
await page.getByPlaceholder("Search...").waitFor({ timeout: 15_000 });
|
||||
await page
|
||||
.getByText("1. Welcome to Joplin!", { exact: true })
|
||||
.first()
|
||||
.click({ timeout: 20_000 });
|
||||
const viewer = page.locator("iframe.noteTextViewer");
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 3 && !(await viewer.isVisible());
|
||||
attempt += 1
|
||||
) {
|
||||
await application.evaluate(({ Menu }) => {
|
||||
const item =
|
||||
Menu.getApplicationMenu()?.getMenuItemById("toggleVisiblePanes");
|
||||
if (!item) throw new Error("Missing Joplin editor layout menu");
|
||||
item.click();
|
||||
});
|
||||
await viewer
|
||||
.waitFor({ state: "visible", timeout: 3_000 })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
const image = page
|
||||
.frameLocator("iframe.noteTextViewer")
|
||||
.locator("img")
|
||||
.first();
|
||||
try {
|
||||
await image.waitFor({ timeout: 15_000 });
|
||||
} catch (cause) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
viewer_visible: await viewer.isVisible(),
|
||||
frames: await Promise.all(
|
||||
page
|
||||
.frames()
|
||||
.slice(0, 5)
|
||||
.map(async (frame) => ({
|
||||
url: frame.url(),
|
||||
state: await frame.evaluate(() => ({
|
||||
text: document.body?.innerText.slice(0, 500),
|
||||
images: document.images.length,
|
||||
})),
|
||||
})),
|
||||
),
|
||||
}),
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
const attachment = await image.evaluate((element) => ({
|
||||
src: element.getAttribute("src"),
|
||||
alt: element.getAttribute("alt"),
|
||||
width: element.naturalWidth,
|
||||
height: element.naturalHeight,
|
||||
}));
|
||||
assert.ok(
|
||||
attachment.width > 0 && attachment.height > 0,
|
||||
"The shipped welcome attachment must render",
|
||||
);
|
||||
const beforeShellCount = await application.evaluate(
|
||||
() =>
|
||||
globalThis
|
||||
.__reaElectronActiveSnapshot()
|
||||
.events.filter(
|
||||
(event) =>
|
||||
event.kind === "shell-attempt" &&
|
||||
event.event === "showItemInFolder",
|
||||
).length,
|
||||
);
|
||||
assert.equal(
|
||||
beforeShellCount,
|
||||
0,
|
||||
"A startup event cannot prove the requested reveal",
|
||||
);
|
||||
// Capture the app's actual context menu instead of opening a native popup.
|
||||
await application.evaluate(({ Menu }) => {
|
||||
const original = Menu.prototype.popup;
|
||||
globalThis.__reaShellMenuProbe = { original, menu: null };
|
||||
Menu.prototype.popup = function captureMenu() {
|
||||
globalThis.__reaShellMenuProbe.menu = this;
|
||||
};
|
||||
});
|
||||
await image.click({ button: "right" });
|
||||
const revealed = await application.evaluate(async ({ Menu }) => {
|
||||
const state = globalThis.__reaShellMenuProbe;
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (state.menu === null && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
Menu.prototype.popup = state.original;
|
||||
delete globalThis.__reaShellMenuProbe;
|
||||
const item = state.menu?.items.find(
|
||||
(entry) => entry.label === "Reveal file in folder",
|
||||
);
|
||||
if (!item) throw new Error("Missing actual Joplin resource reveal menu");
|
||||
await item.click();
|
||||
return item.label;
|
||||
});
|
||||
const shell = await application.evaluate(async () => {
|
||||
const read = () =>
|
||||
globalThis
|
||||
.__reaElectronActiveSnapshot()
|
||||
.events.filter(
|
||||
(event) =>
|
||||
event.kind === "shell-attempt" &&
|
||||
event.event === "showItemInFolder",
|
||||
);
|
||||
const deadline = Date.now() + 5_000;
|
||||
let events = read();
|
||||
while (events.length === 0 && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
events = read();
|
||||
}
|
||||
return events;
|
||||
});
|
||||
assert.equal(shell.length, 1);
|
||||
assert.equal(shell[0].phase, "blocked");
|
||||
assert.deepEqual(shell[0].argument_shapes, ["string"]);
|
||||
probe = {
|
||||
before_shell_count: beforeShellCount,
|
||||
note: "1. Welcome to Joplin!",
|
||||
attachment,
|
||||
revealed,
|
||||
image_visible: await image.isVisible(),
|
||||
shell,
|
||||
page_errors: pageErrors,
|
||||
};
|
||||
return application;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await captureElectronScenario(
|
||||
new PlaywrightElectronActiveProvider(process.env, launch),
|
||||
{
|
||||
executable_path: resolve(values.executable),
|
||||
application_path: resolve(values.application),
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-gpu",
|
||||
`--user-data-dir=${profile}`,
|
||||
"--profile",
|
||||
profile,
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
step_id: "settle-after-reveal",
|
||||
kind: "wait",
|
||||
window_index: 0,
|
||||
duration_ms: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await writeFile(join(profile, "result.json"), JSON.stringify(result));
|
||||
const capture = result.ok
|
||||
? result.value.normalized_result
|
||||
: result.error.partialObservation?.capture;
|
||||
const retainedShell = capture?.timeline.events.filter(
|
||||
(event) =>
|
||||
event.kind === "shell-attempt" && event.event === "showItemInFolder",
|
||||
);
|
||||
if (probe)
|
||||
assert.deepEqual(
|
||||
retainedShell,
|
||||
probe.shell,
|
||||
"The shared capture must retain the observed shell event",
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
profile,
|
||||
runtime,
|
||||
elapsed_ms: performance.now() - started,
|
||||
launch_transport: values["argv-shim-transport"]
|
||||
? "controlled-argv-shim"
|
||||
: "standard-staged-argv",
|
||||
probe: probe ?? null,
|
||||
provider_ok: result.ok,
|
||||
error: result.ok
|
||||
? null
|
||||
: {
|
||||
tag: result.error._tag,
|
||||
reason: result.error.reason,
|
||||
diagnostics: result.error.diagnostics,
|
||||
},
|
||||
coverage: capture?.coverage,
|
||||
retention: capture?.retention,
|
||||
actions: capture?.actions,
|
||||
deadline_triggered: controller.signal.aborted,
|
||||
workflow_status:
|
||||
result.ok && probe !== undefined ? "verified" : "partial-or-failed",
|
||||
}),
|
||||
);
|
||||
assert.ok(probe, "Real Joplin resource reveal was not verified");
|
||||
assert.ok(result.ok, "Provider cleanup remains unverified");
|
||||
} catch (cause) {
|
||||
console.log(
|
||||
JSON.stringify({ verifier_failure: String(cause), probe: probe ?? null }),
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
if (application && child?.exitCode === null && child?.signalCode === null) {
|
||||
let timer;
|
||||
await Promise.race([
|
||||
application.close().catch(() => undefined),
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(resolve, 5_000);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
// The external owned scope recovers leftovers; that is not provider cleanup.
|
||||
process.exit(process.exitCode ?? 0);
|
||||
@@ -335,9 +335,22 @@ const createElectronActiveBoundaryPatches = ({
|
||||
};
|
||||
};
|
||||
|
||||
// Each blocked method keeps the return contract of the Electron API it
|
||||
// replaces, so a target that checks the result sees an ordinary refusal.
|
||||
const blockedShellResults = {
|
||||
openExternal: () =>
|
||||
Promise.reject(new Error("External shell effects are blocked")),
|
||||
openPath: () =>
|
||||
Promise.reject(new Error("External shell effects are blocked")),
|
||||
// Synchronous `void`: reveals a path in the host file manager.
|
||||
showItemInFolder: () => undefined,
|
||||
// Synchronous `boolean`: creates or replaces a Windows shortcut file.
|
||||
writeShortcutLink: () => false,
|
||||
};
|
||||
|
||||
const patchShell = (shell) => {
|
||||
if (shell === null || shell === undefined) return;
|
||||
for (const method of ["openExternal", "openPath"]) {
|
||||
for (const [method, blockedResult] of Object.entries(blockedShellResults)) {
|
||||
const original = shell[method];
|
||||
if (typeof original !== "function") continue;
|
||||
shell[method] = function blockedShellCall(...args) {
|
||||
@@ -345,7 +358,7 @@ const createElectronActiveBoundaryPatches = ({
|
||||
argument_shape_values: args,
|
||||
error: true,
|
||||
});
|
||||
return Promise.reject(new Error("External shell effects are blocked"));
|
||||
return blockedResult();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -56,7 +56,13 @@ const utilityProcess = {
|
||||
return child;
|
||||
},
|
||||
};
|
||||
const shell = { openExternal: () => Promise.resolve(), openPath: () => Promise.resolve() };
|
||||
globalThis.__nativeShellCalls = [];
|
||||
const shell = {
|
||||
openExternal: () => Promise.resolve(),
|
||||
openPath: () => Promise.resolve(),
|
||||
showItemInFolder: (path) => { globalThis.__nativeShellCalls.push(["showItemInFolder", path]); },
|
||||
writeShortcutLink: (path) => { globalThis.__nativeShellCalls.push(["writeShortcutLink", path]); return true; },
|
||||
};
|
||||
const createSession = () => {
|
||||
const value = new EventEmitter();
|
||||
value.setPermissionRequestHandler = () => undefined;
|
||||
@@ -219,6 +225,60 @@ it("retains active hook evidence within its event-retention budget", async () =>
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks synchronous shell effects with their native return contracts and retains argument shapes only", async () => {
|
||||
const root = await createTestTempDirectory("rea-electron-hook-shell-");
|
||||
temporary.push(root);
|
||||
await writeElectronFixture(root);
|
||||
const script = join(root, "shell.cjs");
|
||||
await writeFile(
|
||||
script,
|
||||
`const { shell } = require("electron");
|
||||
const revealed = shell.showItemInFolder("/host/secret-folder/file.txt");
|
||||
const written = shell.writeShortcutLink("C:\\\\Users\\\\host\\\\app.lnk", "create", { target: "C:\\\\payload.exe" });
|
||||
const snapshot = globalThis.__reaElectronActiveSnapshot();
|
||||
process.stdout.write(JSON.stringify({
|
||||
revealed: revealed === undefined ? "undefined" : revealed,
|
||||
written,
|
||||
native_calls: globalThis.__nativeShellCalls,
|
||||
snapshot,
|
||||
}) + "\\n");
|
||||
`,
|
||||
);
|
||||
const execution = await runNode(script, root);
|
||||
expect(execution.code, execution.stderr).toBe(0);
|
||||
const result = JSON.parse(execution.stdout.trim()) as {
|
||||
readonly revealed: string;
|
||||
readonly written: boolean;
|
||||
readonly native_calls: readonly unknown[];
|
||||
readonly snapshot: {
|
||||
readonly hook_error: boolean;
|
||||
readonly events: readonly Record<string, unknown>[];
|
||||
};
|
||||
};
|
||||
expect(result.revealed).toBe("undefined");
|
||||
expect(result.written).toBe(false);
|
||||
expect(result.native_calls).toEqual([]);
|
||||
expect(result.snapshot.hook_error).toBe(false);
|
||||
const attempts = result.snapshot.events.filter(
|
||||
(event) => event.kind === "shell-attempt",
|
||||
);
|
||||
expect(attempts).toEqual([
|
||||
expect.objectContaining({
|
||||
event: "showItemInFolder",
|
||||
phase: "blocked",
|
||||
argument_shapes: ["string"],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
event: "writeShortcutLink",
|
||||
phase: "blocked",
|
||||
argument_shapes: ["string", "string", "object"],
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(result.snapshot)).not.toContain("secret-folder");
|
||||
expect(JSON.stringify(result.snapshot)).not.toContain("payload.exe");
|
||||
electronActiveTimelineEventSchema.array().parse(result.snapshot.events);
|
||||
});
|
||||
|
||||
it("classifies a dropped browser preload as preload configuration at capture", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-electron-hook-browser-preload-",
|
||||
|
||||
Reference in new issue
Block a user