mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-22 20:31:03 +02:00
606 lines
20 KiB
JavaScript
606 lines
20 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
import { isIP } from "node:net";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { appFromEnvironment } from "../lib/app.mjs";
|
|
import { CdpClient } from "../lib/cdp.mjs";
|
|
import {
|
|
extensionZipBase64,
|
|
prepareWayfern,
|
|
wireGuardFixture,
|
|
} from "../lib/fixtures.mjs";
|
|
|
|
function proxySettings(raw, expectedKind) {
|
|
assert.ok(raw, `${expectedKind} residential proxy URL is required`);
|
|
const url = new URL(raw);
|
|
const rawType = url.protocol.slice(0, -1).toLowerCase();
|
|
const proxyType =
|
|
rawType === "socks" || rawType === "socks5h" ? "socks5" : rawType;
|
|
if (expectedKind === "HTTP") {
|
|
assert.ok(
|
|
proxyType === "http" || proxyType === "https",
|
|
`Expected an HTTP proxy URL, got ${rawType}`,
|
|
);
|
|
} else {
|
|
assert.equal(proxyType, "socks5");
|
|
}
|
|
const port = Number(url.port);
|
|
assert.ok(url.hostname && port > 0 && port <= 65535);
|
|
return {
|
|
proxy_type: proxyType,
|
|
host: url.hostname,
|
|
port,
|
|
username: url.username ? decodeURIComponent(url.username) : null,
|
|
password: url.password ? decodeURIComponent(url.password) : null,
|
|
};
|
|
}
|
|
|
|
function wireGuardFields(config) {
|
|
let section = "";
|
|
const fields = new Map();
|
|
for (const rawLine of config.split(/\r?\n/)) {
|
|
const line = rawLine.trim();
|
|
if (!line || line.startsWith("#")) continue;
|
|
if (line === "[Interface]") {
|
|
section = "interface";
|
|
continue;
|
|
}
|
|
if (line === "[Peer]") {
|
|
section = "peer";
|
|
continue;
|
|
}
|
|
const separator = line.indexOf("=");
|
|
if (separator === -1) continue;
|
|
fields.set(
|
|
`${section}.${line.slice(0, separator).trim()}`,
|
|
line.slice(separator + 1).trim(),
|
|
);
|
|
}
|
|
return {
|
|
privateKey: fields.get("interface.PrivateKey"),
|
|
address: fields.get("interface.Address"),
|
|
dns: fields.get("interface.DNS") ?? "",
|
|
peerPublicKey: fields.get("peer.PublicKey"),
|
|
peerEndpoint: fields.get("peer.Endpoint"),
|
|
allowedIps: fields.get("peer.AllowedIPs") ?? "0.0.0.0/0",
|
|
persistentKeepalive: fields.get("peer.PersistentKeepalive") ?? "",
|
|
presharedKey: fields.get("peer.PresharedKey") ?? "",
|
|
};
|
|
}
|
|
|
|
async function request(url, { method = "GET", token, body } = {}) {
|
|
const response = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
},
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
const text = await response.text();
|
|
let value = text;
|
|
if (text) {
|
|
try {
|
|
value = JSON.parse(text);
|
|
} catch {
|
|
// Plain-text responses are intentional for some endpoints.
|
|
}
|
|
}
|
|
return { response, value };
|
|
}
|
|
|
|
async function createGroupThroughUi(app) {
|
|
await app.clickSelector('[aria-label="Groups"]');
|
|
await app.waitForText("Profile groups");
|
|
await app.clickSelector('[aria-label="Create"]');
|
|
await app.waitForText("Create New Group");
|
|
await app.fillSelector("#group-name", "Visible UI Group");
|
|
await app.clickTextIn('[role="dialog"]', "Create", { roles: ["button"] });
|
|
await app.waitForText("Visible UI Group");
|
|
const groups = await app.invoke("get_profile_groups");
|
|
return groups.find((group) => group.name === "Visible UI Group");
|
|
}
|
|
|
|
async function createProxyThroughUi(app, settings) {
|
|
await app.clickSelector('[aria-label="Network"]');
|
|
await app.waitForText("New proxy");
|
|
await app.clickSelector('[aria-label="New proxy"]');
|
|
await app.waitForText("Add Proxy");
|
|
await app.fillSelector("#proxy-name", "Visible Residential HTTP");
|
|
await app.fillSelector("#proxy-host", settings.host);
|
|
await app.fillSelector("#proxy-port", String(settings.port));
|
|
if (settings.username)
|
|
await app.fillSelector("#proxy-username", settings.username);
|
|
if (settings.password)
|
|
await app.fillSelector("#proxy-password", settings.password);
|
|
await app.clickTextIn('[role="dialog"]', "Add Proxy", {
|
|
roles: ["button"],
|
|
});
|
|
await app.waitForText("Visible Residential HTTP");
|
|
const proxies = await app.invoke("get_stored_proxies");
|
|
return proxies.find((proxy) => proxy.name === "Visible Residential HTTP");
|
|
}
|
|
|
|
async function createVpnThroughUi(app, config) {
|
|
const fields = wireGuardFields(config);
|
|
assert.ok(
|
|
fields.privateKey &&
|
|
fields.address &&
|
|
fields.peerPublicKey &&
|
|
fields.peerEndpoint,
|
|
"WireGuard fixture is missing required fields",
|
|
);
|
|
await app.clickText("VPNs", { exact: false, roles: ["tab"] });
|
|
await app.clickSelector('[aria-label="New VPN"]');
|
|
await app.waitForText("Create WireGuard VPN");
|
|
await app.fillSelector("#wg-name", "Visible Local WireGuard");
|
|
await app.fillSelector("#wg-private-key", fields.privateKey);
|
|
await app.fillSelector("#wg-address", fields.address);
|
|
if (fields.dns) await app.fillSelector("#wg-dns", fields.dns);
|
|
await app.fillSelector("#wg-peer-public-key", fields.peerPublicKey);
|
|
await app.fillSelector("#wg-peer-endpoint", fields.peerEndpoint);
|
|
await app.fillSelector("#wg-allowed-ips", fields.allowedIps);
|
|
if (fields.persistentKeepalive) {
|
|
await app.fillSelector("#wg-keepalive", fields.persistentKeepalive);
|
|
}
|
|
if (fields.presharedKey) {
|
|
await app.fillSelector("#wg-preshared-key", fields.presharedKey);
|
|
}
|
|
await app.clickTextIn('[role="dialog"]', "Create VPN", {
|
|
roles: ["button"],
|
|
});
|
|
await app.waitForText("Visible Local WireGuard");
|
|
const vpns = await app.invoke("list_vpn_configs");
|
|
return vpns.find((vpn) => vpn.name === "Visible Local WireGuard");
|
|
}
|
|
|
|
async function createExtensionsThroughUi(app) {
|
|
const extensionFile = path.join(app.root, "visible-extension.zip");
|
|
await writeFile(extensionFile, Buffer.from(extensionZipBase64(), "base64"));
|
|
await app.clickSelector('[aria-label="Extensions"]');
|
|
await app.waitForText("Upload");
|
|
|
|
await app.execute(`
|
|
const input = document.querySelector("#ext-file-input");
|
|
input.classList.remove("hidden");
|
|
input.style.position = "fixed";
|
|
input.style.left = "12px";
|
|
input.style.bottom = "12px";
|
|
`);
|
|
const input = await app.session.findCss("#ext-file-input");
|
|
await app.session.sendKeys(input, extensionFile);
|
|
await app.waitForText("visible-extension.zip");
|
|
await app.fillSelector(
|
|
'input[placeholder="Extension name"]',
|
|
"Visible UI Extension",
|
|
);
|
|
await app.clickText("Add", { roles: ["button"] });
|
|
await app.waitForText("Donut E2E Fixture");
|
|
|
|
await app.clickText("Groups", { exact: false, roles: ["tab"] });
|
|
await app.clickSelector('[aria-label="New group"]');
|
|
await app.fillSelector(
|
|
'input[placeholder="Group name"]',
|
|
"Visible Extension Group",
|
|
);
|
|
await app.clickText("Create", { roles: ["button"] });
|
|
await app.waitForText("Visible Extension Group");
|
|
|
|
let [extensions, groups] = await Promise.all([
|
|
app.invoke("list_extensions"),
|
|
app.invoke("list_extension_groups"),
|
|
]);
|
|
const extension = extensions.find(
|
|
(item) => item.name === "Donut E2E Fixture",
|
|
);
|
|
let group = groups.find((item) => item.name === "Visible Extension Group");
|
|
assert.ok(extension && group);
|
|
|
|
const editButton = await app.execute(
|
|
`
|
|
const row = [...document.querySelectorAll("tr")].find((candidate) =>
|
|
(candidate.innerText || "").includes(arguments[0])
|
|
);
|
|
return row?.querySelector("td:last-child button") ?? null;
|
|
`,
|
|
[group.name],
|
|
);
|
|
assert.ok(editButton, "Extension group edit control was not visible");
|
|
await app.session.click(editButton);
|
|
await app.waitForText("Edit Group");
|
|
const extensionPicker = await app.execute(`
|
|
const dialogs = [...document.querySelectorAll('[role="dialog"]')];
|
|
return dialogs.reverse().find(
|
|
(dialog) => (dialog.innerText || "").includes("Edit Group")
|
|
)?.querySelector('[role="combobox"]') ?? null;
|
|
`);
|
|
assert.ok(extensionPicker, "Extension picker was not visible");
|
|
await app.session.click(extensionPicker);
|
|
await app.clickText(extension.name, { roles: ["option"] });
|
|
await app.clickTextIn('[role="dialog"]', "Save", { roles: ["button"] });
|
|
await app.waitFor(
|
|
async () => {
|
|
groups = await app.invoke("list_extension_groups");
|
|
group = groups.find((item) => item.name === "Visible Extension Group");
|
|
return group?.extension_ids.includes(extension.id);
|
|
},
|
|
{ description: "uploaded extension added to visible extension group" },
|
|
);
|
|
|
|
return {
|
|
extension,
|
|
group,
|
|
};
|
|
}
|
|
|
|
async function createProfileThroughUi(app, groupName) {
|
|
await app.clickSelector('[aria-label="Profiles"]');
|
|
await app.clickText(groupName, { exact: false, roles: ["button"] });
|
|
await app.clickText("New", { roles: ["button"] });
|
|
await app.waitFor(
|
|
async () => {
|
|
const text = await app.bodyText();
|
|
return (
|
|
text.includes("Create New Profile") ||
|
|
text.includes("Create New Chromium Profile")
|
|
);
|
|
},
|
|
{ description: "profile creation dialog" },
|
|
);
|
|
if (!(await app.visibleTextIncludes("Create New Chromium Profile"))) {
|
|
await app.clickText("Chromium", { exact: false, roles: ["button"] });
|
|
await app.waitForText("Create New Chromium Profile");
|
|
}
|
|
await app.fillSelector("#profile-name", "Visible Network Profile");
|
|
await app.clickTextIn('[role="dialog"]', "Create", { roles: ["button"] });
|
|
await app.waitForText("Visible Network Profile", 60_000);
|
|
await app.waitFor(
|
|
async () =>
|
|
!(await app.execute(`
|
|
return [...document.querySelectorAll('[role="dialog"]')].some(
|
|
(dialog) =>
|
|
(dialog.innerText || "").includes("Create New Chromium Profile")
|
|
);
|
|
`)),
|
|
{ description: "profile creation dialog to unmount" },
|
|
);
|
|
const profiles = await app.invoke("list_browser_profiles");
|
|
return profiles.find((profile) => profile.name === "Visible Network Profile");
|
|
}
|
|
|
|
async function assignNetworkThroughUi(app, profileName, currentName, newName) {
|
|
const trigger = await app.execute(
|
|
`
|
|
const row = [...document.querySelectorAll("tr")].find((candidate) =>
|
|
(candidate.innerText || "").includes(arguments[0])
|
|
);
|
|
const expected = arguments[1].toLocaleLowerCase();
|
|
return [...(row?.querySelectorAll('[aria-haspopup="dialog"]') ?? [])].find(
|
|
(trigger) => (trigger.innerText || trigger.textContent || "")
|
|
.toLocaleLowerCase()
|
|
.includes(expected)
|
|
) ?? null;
|
|
`,
|
|
[profileName, currentName],
|
|
);
|
|
assert.ok(trigger, `Network selector for ${profileName} was not visible`);
|
|
await app.session.click(trigger);
|
|
await app.clickText(newName, { exact: false, roles: ["option"] });
|
|
await app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
`
|
|
return ![...document.querySelectorAll('[data-slot="popover-content"]')]
|
|
.some((content) => (content.innerText || "").includes(arguments[0]));
|
|
`,
|
|
[newName],
|
|
),
|
|
{ description: `${newName} network picker to unmount` },
|
|
);
|
|
}
|
|
|
|
async function assignExtensionGroupThroughUi(
|
|
app,
|
|
profileName,
|
|
currentName,
|
|
newName,
|
|
) {
|
|
const trigger = await app.execute(
|
|
`
|
|
const row = [...document.querySelectorAll("tr")].find((candidate) =>
|
|
(candidate.innerText || "").includes(arguments[0])
|
|
);
|
|
return [...(row?.querySelectorAll("button") ?? [])].find(
|
|
(button) => (button.innerText || button.textContent || "")
|
|
.trim()
|
|
.includes(arguments[1])
|
|
) ?? null;
|
|
`,
|
|
[profileName, currentName],
|
|
);
|
|
assert.ok(trigger, `Extension selector for ${profileName} was not visible`);
|
|
await app.session.click(trigger);
|
|
await app.clickText(newName, { exact: false, roles: ["option"] });
|
|
await app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
`
|
|
return ![...document.querySelectorAll('[data-slot="popover-content"]')]
|
|
.some((content) => (content.innerText || "").includes(arguments[0]));
|
|
`,
|
|
[newName],
|
|
),
|
|
{ description: `${newName} extension picker to unmount` },
|
|
);
|
|
}
|
|
|
|
async function runProfile(_app, base, token, profileId, url) {
|
|
const launched = await request(`${base}/v1/profiles/${profileId}/run`, {
|
|
method: "POST",
|
|
token,
|
|
body: { url, headless: true },
|
|
});
|
|
assert.equal(launched.response.status, 200, JSON.stringify(launched.value));
|
|
const cdp = await CdpClient.connect(launched.value.remote_debugging_port);
|
|
return { launched: launched.value, cdp };
|
|
}
|
|
|
|
async function stopProfile(app, base, token, profileId, cdp) {
|
|
cdp.close();
|
|
const stopped = await request(`${base}/v1/profiles/${profileId}/kill`, {
|
|
method: "POST",
|
|
token,
|
|
});
|
|
assert.equal(stopped.response.status, 204);
|
|
await app.waitFor(
|
|
async () => {
|
|
const profile = (await app.invoke("list_browser_profiles")).find(
|
|
(item) => item.id === profileId,
|
|
);
|
|
return !profile?.process_id;
|
|
},
|
|
{ timeoutMs: 20_000, description: "network profile process cleanup" },
|
|
);
|
|
}
|
|
|
|
async function assertProxyWorkerLogsRedacted(app, settings) {
|
|
const files = (await readdir(path.join(app.root, "tmp"))).filter(
|
|
(file) => file.startsWith("donut-proxy-") && file.endsWith(".log"),
|
|
);
|
|
assert.ok(files.length > 0, "No proxy worker diagnostic logs were created");
|
|
const contents = (
|
|
await Promise.all(
|
|
files.map((file) => readFile(path.join(app.root, "tmp", file), "utf8")),
|
|
)
|
|
).join("\n");
|
|
for (const item of settings) {
|
|
if (!item.username) continue;
|
|
const rawAuth = `${item.username}:${item.password ?? ""}@`;
|
|
const encodedAuth = `${encodeURIComponent(item.username)}:${encodeURIComponent(item.password ?? "")}@`;
|
|
assert.equal(
|
|
contents.includes(rawAuth) || contents.includes(encodedAuth),
|
|
false,
|
|
"Proxy worker logs exposed upstream credentials",
|
|
);
|
|
}
|
|
}
|
|
|
|
function wireGuardTargetWasReached() {
|
|
const container = process.env.DONUT_E2E_WIREGUARD_CONTAINER;
|
|
assert.ok(container, "WireGuard fixture container name is required");
|
|
return (
|
|
spawnSync(
|
|
"docker",
|
|
[
|
|
"exec",
|
|
container,
|
|
"grep",
|
|
"-q",
|
|
"GET /donut-e2e-wireguard ",
|
|
"/tmp/donut-e2e-target-requests",
|
|
],
|
|
{ stdio: "ignore", timeout: 2_000 },
|
|
).status === 0
|
|
);
|
|
}
|
|
|
|
test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions, and extension groups", async () => {
|
|
const httpSettings = proxySettings(
|
|
process.env.RESIDENTIAL_PROXY_URL_ONE_HTTP,
|
|
"HTTP",
|
|
);
|
|
const socksSettings = proxySettings(
|
|
process.env.RESIDENTIAL_PROXY_URL_ONE_SOCKS,
|
|
"SOCKS",
|
|
);
|
|
const realWireGuardConfig = process.env.DONUT_E2E_WIREGUARD_CONFIG_BASE64
|
|
? Buffer.from(
|
|
process.env.DONUT_E2E_WIREGUARD_CONFIG_BASE64,
|
|
"base64",
|
|
).toString("utf8")
|
|
: null;
|
|
const app = appFromEnvironment("network-visible-ui", {
|
|
wayfernTermsAccepted: false,
|
|
});
|
|
let apiPort;
|
|
let activeCdp;
|
|
let activeVpnId;
|
|
try {
|
|
const prepared = await prepareWayfern(
|
|
app,
|
|
process.env.DONUT_E2E_PROJECT_ROOT,
|
|
);
|
|
if (!app.session) await app.start();
|
|
if (!(await app.invoke("check_wayfern_terms_accepted"))) {
|
|
await app.invoke("accept_wayfern_terms");
|
|
await app.restart();
|
|
}
|
|
assert.equal(
|
|
await app.visibleTextIncludes("Welcome to Donut Browser"),
|
|
false,
|
|
"completed test sessions must not leave the Welcome dialog over the UI",
|
|
);
|
|
const group = await createGroupThroughUi(app);
|
|
assert.ok(group);
|
|
await app.capture("01-profile-group-created");
|
|
|
|
const httpProxy = await createProxyThroughUi(app, httpSettings);
|
|
assert.ok(httpProxy);
|
|
assert.equal(
|
|
httpProxy.proxy_settings.proxy_type === httpSettings.proxy_type &&
|
|
httpProxy.proxy_settings.host === httpSettings.host &&
|
|
httpProxy.proxy_settings.port === httpSettings.port &&
|
|
httpProxy.proxy_settings.username === httpSettings.username &&
|
|
httpProxy.proxy_settings.password === httpSettings.password,
|
|
true,
|
|
"The HTTP proxy created through the UI did not preserve its settings",
|
|
);
|
|
const vpn = await createVpnThroughUi(
|
|
app,
|
|
realWireGuardConfig ?? wireGuardFixture(),
|
|
);
|
|
assert.ok(vpn);
|
|
activeVpnId = vpn.id;
|
|
await app.capture("02-proxy-and-vpn-created");
|
|
|
|
const extensionEntities = await createExtensionsThroughUi(app);
|
|
assert.ok(extensionEntities.extension);
|
|
assert.ok(extensionEntities.group);
|
|
await app.capture("03-extension-and-group-created");
|
|
|
|
const profile = await createProfileThroughUi(app, group.name);
|
|
assert.ok(profile);
|
|
assert.equal(profile.version, prepared.version);
|
|
assert.equal(profile.group_id, group.id);
|
|
await assignExtensionGroupThroughUi(
|
|
app,
|
|
profile.name,
|
|
"Default",
|
|
extensionEntities.group.name,
|
|
);
|
|
await app.waitFor(
|
|
async () =>
|
|
(await app.invoke("list_browser_profiles")).find(
|
|
(item) => item.id === profile.id,
|
|
)?.extension_group_id === extensionEntities.group.id,
|
|
{ description: "extension group assignment persisted" },
|
|
);
|
|
await app.capture("04-profile-created");
|
|
|
|
const socksProxy = await app.invoke("create_stored_proxy", {
|
|
name: "Residential SOCKS5",
|
|
proxySettings: socksSettings,
|
|
});
|
|
const [httpCheck, socksCheck] = await Promise.all([
|
|
app.invoke("check_proxy_validity", {
|
|
proxyId: httpProxy.id,
|
|
proxySettings: null,
|
|
}),
|
|
app.invoke("check_proxy_validity", {
|
|
proxyId: socksProxy.id,
|
|
proxySettings: null,
|
|
}),
|
|
]);
|
|
assert.equal(httpCheck.is_valid, true);
|
|
assert.equal(socksCheck.is_valid, true);
|
|
assert.ok(isIP(httpCheck.ip));
|
|
assert.ok(isIP(socksCheck.ip));
|
|
|
|
await assignNetworkThroughUi(
|
|
app,
|
|
profile.name,
|
|
"Not selected",
|
|
httpProxy.name,
|
|
);
|
|
await app.waitFor(
|
|
async () =>
|
|
(await app.invoke("list_browser_profiles")).find(
|
|
(item) => item.id === profile.id,
|
|
)?.proxy_id === httpProxy.id,
|
|
{ description: "HTTP proxy assignment persisted" },
|
|
);
|
|
|
|
const settings = await app.invoke("get_app_settings");
|
|
const saved = await app.invoke("save_app_settings", {
|
|
settings: {
|
|
...settings,
|
|
api_enabled: true,
|
|
api_port: 0,
|
|
api_token: null,
|
|
},
|
|
});
|
|
apiPort = await app.invoke("start_api_server", { port: 0 });
|
|
const base = `http://127.0.0.1:${apiPort}`;
|
|
|
|
const proxied = await runProfile(
|
|
app,
|
|
base,
|
|
saved.api_token,
|
|
profile.id,
|
|
"https://api.ipify.org/",
|
|
);
|
|
activeCdp = proxied.cdp;
|
|
const browserExitIp = await activeCdp.waitFor(
|
|
`(() => {
|
|
const value = document.body?.innerText?.trim() ?? "";
|
|
return /^[0-9a-f:.]+$/i.test(value) ? value : false;
|
|
})()`,
|
|
{ timeoutMs: 30_000, description: "Wayfern residential proxy exit IP" },
|
|
);
|
|
assert.ok(isIP(browserExitIp));
|
|
await stopProfile(app, base, saved.api_token, profile.id, activeCdp);
|
|
activeCdp = null;
|
|
await assertProxyWorkerLogsRedacted(app, [httpSettings, socksSettings]);
|
|
|
|
await assignNetworkThroughUi(app, profile.name, httpProxy.name, vpn.name);
|
|
await app.waitFor(
|
|
async () =>
|
|
(await app.invoke("list_browser_profiles")).find(
|
|
(item) => item.id === profile.id,
|
|
)?.vpn_id === vpn.id,
|
|
{ description: "WireGuard assignment persisted" },
|
|
);
|
|
await app.capture("05-proxy-and-vpn-assigned");
|
|
|
|
if (realWireGuardConfig) {
|
|
const tunneled = await runProfile(
|
|
app,
|
|
base,
|
|
saved.api_token,
|
|
profile.id,
|
|
process.env.DONUT_E2E_WIREGUARD_TARGET_URL,
|
|
);
|
|
activeCdp = tunneled.cdp;
|
|
await app.waitFor(wireGuardTargetWasReached, {
|
|
timeoutMs: 30_000,
|
|
description: "Wayfern GET through local WireGuard peer",
|
|
});
|
|
await stopProfile(app, base, saved.api_token, profile.id, activeCdp);
|
|
activeCdp = null;
|
|
}
|
|
} catch (error) {
|
|
await app.capture("failure");
|
|
throw error;
|
|
} finally {
|
|
activeCdp?.close();
|
|
if (app.session) {
|
|
if (apiPort) await app.invoke("stop_api_server").catch(() => {});
|
|
for (const profile of await app
|
|
.invoke("list_browser_profiles")
|
|
.catch(() => [])) {
|
|
if (profile.process_id) {
|
|
await app.invoke("kill_browser_profile", { profile }).catch(() => {});
|
|
}
|
|
}
|
|
if (activeVpnId) {
|
|
await app
|
|
.invoke("disconnect_vpn", { vpnId: activeVpnId })
|
|
.catch(() => {});
|
|
}
|
|
}
|
|
await app.close();
|
|
}
|
|
});
|