mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: native web view for plugins in the desktop app
This commit is contained in:
1 parent
895488d400
commit
b22b3679e4
12 files changed
+641
No files matched your search
@@ -0,0 +1,165 @@
|
||||
const { isolatedUrl } = require("./isolated-window.cjs");
|
||||
|
||||
function cleanBounds(bounds) {
|
||||
const value = (n) => Math.max(0, Math.round(Number(n) || 0));
|
||||
return {
|
||||
x: value(bounds?.x),
|
||||
y: value(bounds?.y),
|
||||
width: value(bounds?.width),
|
||||
height: value(bounds?.height),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Native web views drawn inside the main window, for pages that do not work
|
||||
* in a sandboxed iframe. Each one is a WebContentsView in its own
|
||||
* non-persistent session, with the same limits as an isolated window: no
|
||||
* permissions, no downloads, http(s) and ws(s) only. The renderer owns the
|
||||
* placement and sends the bounds of the element the view should cover.
|
||||
*/
|
||||
function createEmbeddedViews({ WebContentsView, session, getMainWindow }) {
|
||||
const views = new Map();
|
||||
|
||||
function destroy(id) {
|
||||
const entry = views.get(id);
|
||||
if (!entry) return;
|
||||
views.delete(id);
|
||||
const main = getMainWindow();
|
||||
if (main && !main.isDestroyed()) {
|
||||
try {
|
||||
main.contentView.removeChildView(entry.view);
|
||||
} catch {
|
||||
/* already detached */
|
||||
}
|
||||
}
|
||||
if (!entry.view.webContents.isDestroyed()) entry.view.webContents.close();
|
||||
void Promise.allSettled([
|
||||
entry.session.clearStorageData(),
|
||||
entry.session.clearCache(),
|
||||
entry.session.closeAllConnections(),
|
||||
]);
|
||||
}
|
||||
|
||||
function destroyAll() {
|
||||
for (const id of [...views.keys()]) destroy(id);
|
||||
}
|
||||
|
||||
function open(options = {}) {
|
||||
const main = getMainWindow();
|
||||
if (!main) throw new Error("No window to attach a view to");
|
||||
const id = String(options.id || "");
|
||||
if (!id) throw new Error("A view needs an id");
|
||||
const url = isolatedUrl(options.url);
|
||||
const existing = views.get(id);
|
||||
if (existing) {
|
||||
if (
|
||||
existing.url === url.href &&
|
||||
existing.ignoreCert === !!options.ignoreCert
|
||||
) {
|
||||
setBounds(id, options.bounds);
|
||||
setVisible(id, options.visible !== false);
|
||||
return { success: true };
|
||||
}
|
||||
destroy(id);
|
||||
}
|
||||
|
||||
const isolated = session.fromPartition(
|
||||
`embedded-view-${id}-${Date.now()}`,
|
||||
{
|
||||
cache: false,
|
||||
},
|
||||
);
|
||||
isolated.setPermissionRequestHandler((_c, _p, callback) => callback(false));
|
||||
isolated.setPermissionCheckHandler(() => false);
|
||||
isolated.on("will-download", (event) => event.preventDefault());
|
||||
isolated.webRequest.onBeforeRequest((details, callback) => {
|
||||
const protocol = new URL(details.url).protocol;
|
||||
callback({
|
||||
cancel: !["http:", "https:", "ws:", "wss:"].includes(protocol),
|
||||
});
|
||||
});
|
||||
|
||||
const view = new WebContentsView({
|
||||
webPreferences: {
|
||||
session: isolated,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webviewTag: false,
|
||||
webSecurity: true,
|
||||
},
|
||||
});
|
||||
const canNavigate = (target) => {
|
||||
try {
|
||||
return target === "about:blank" || !!isolatedUrl(target);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
for (const name of ["will-navigate", "will-redirect"]) {
|
||||
view.webContents.on(name, (event, target) => {
|
||||
if (!canNavigate(target)) event.preventDefault();
|
||||
});
|
||||
}
|
||||
view.webContents.setWindowOpenHandler(({ url: target }) =>
|
||||
canNavigate(target)
|
||||
? {
|
||||
action: "allow",
|
||||
overrideBrowserWindowOptions: {
|
||||
webPreferences: { session: isolated, sandbox: true },
|
||||
},
|
||||
}
|
||||
: { action: "deny" },
|
||||
);
|
||||
|
||||
views.set(id, {
|
||||
view,
|
||||
session: isolated,
|
||||
url: url.href,
|
||||
origin: url.origin,
|
||||
ignoreCert: !!options.ignoreCert,
|
||||
});
|
||||
main.contentView.addChildView(view);
|
||||
setBounds(id, options.bounds);
|
||||
setVisible(id, options.visible !== false);
|
||||
view.webContents.loadURL(url.href).catch(() => {});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function setBounds(id, bounds) {
|
||||
const entry = views.get(id);
|
||||
if (entry) entry.view.setBounds(cleanBounds(bounds));
|
||||
}
|
||||
|
||||
function setVisible(id, visible) {
|
||||
const entry = views.get(id);
|
||||
if (entry) entry.view.setVisible(!!visible);
|
||||
}
|
||||
|
||||
function handleCertificateError(event, contents, url, callback) {
|
||||
for (const entry of views.values()) {
|
||||
if (entry.view.webContents !== contents) continue;
|
||||
event.preventDefault();
|
||||
let allowed = false;
|
||||
try {
|
||||
allowed = entry.ignoreCert && new URL(url).origin === entry.origin;
|
||||
} catch {
|
||||
/* malformed URL is refused */
|
||||
}
|
||||
callback(allowed);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
setBounds,
|
||||
setVisible,
|
||||
destroy,
|
||||
destroyAll,
|
||||
handleCertificateError,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createEmbeddedViews, cleanBounds };
|
||||
Reference in new issue
Block a user