Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
301 lines
11 KiB
JavaScript
301 lines
11 KiB
JavaScript
/* eslint-disable no-undef */
|
|
function createExternalWindowApi(ctx) {
|
|
with (ctx) {
|
|
const fallbackBrowserWindows = new Set();
|
|
|
|
function resolveAppIconFromOptions(options = {}) {
|
|
const { appIcon, getAppIcon } = options;
|
|
if (typeof getAppIcon === "function") {
|
|
try {
|
|
const resolved = getAppIcon();
|
|
if (resolved) return resolved;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
return appIcon;
|
|
}
|
|
|
|
/**
|
|
* Open a URL in a minimal in-app BrowserWindow. Used as a fallback when the
|
|
* host OS cannot open the URL with the system browser (e.g. Tiny11 / Windows
|
|
* with no default browser configured — error 0x483). The window is
|
|
* intentionally stripped down:
|
|
* - no preload script (remote content must NEVER touch contextBridge)
|
|
* - sandboxed + contextIsolated renderer
|
|
* - a separate persisted session partition so cookies and storage do not
|
|
* leak into the main app session
|
|
*/
|
|
function openFallbackBrowser(url, options = {}) {
|
|
const { backgroundColor, appIcon, getAppIcon } = options;
|
|
const icon = resolveAppIconFromOptions(options);
|
|
const electron = require("electron");
|
|
const { BrowserWindow, screen } = electron;
|
|
|
|
// Size and center relative to the main window when possible.
|
|
let bounds = { width: 1100, height: 740 };
|
|
try {
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
const mainBounds = mainWindow.getBounds();
|
|
const display = screen.getDisplayMatching(mainBounds);
|
|
const area = display.workArea;
|
|
const w = Math.min(1200, Math.round(area.width * 0.85));
|
|
const h = Math.min(800, Math.round(area.height * 0.85));
|
|
bounds = {
|
|
width: w,
|
|
height: h,
|
|
x: Math.round(area.x + (area.width - w) / 2),
|
|
y: Math.round(area.y + (area.height - h) / 2),
|
|
};
|
|
}
|
|
} catch {
|
|
// Fall through to default bounds.
|
|
}
|
|
|
|
const win = new BrowserWindow({
|
|
...bounds,
|
|
title: url,
|
|
backgroundColor: backgroundColor || THEME_COLORS[currentTheme]?.background,
|
|
icon,
|
|
show: false,
|
|
autoHideMenuBar: true,
|
|
webPreferences: {
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
sandbox: true,
|
|
spellcheck: false,
|
|
webSecurity: true,
|
|
// Isolated session so users' browsing does not mix with main app state.
|
|
partition: "persist:netcatty-fallback-browser",
|
|
},
|
|
});
|
|
|
|
fallbackBrowserWindows.add(win);
|
|
win.on("closed", () => {
|
|
fallbackBrowserWindows.delete(win);
|
|
});
|
|
|
|
// Reflect the loaded page title in the window bar; fall back to the URL.
|
|
try {
|
|
win.webContents.on("page-title-updated", (_event, title) => {
|
|
try {
|
|
win.setTitle(title || url);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
});
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
// Popups inside the fallback browser: open them in another fallback window
|
|
// rather than looping back through shell.openExternal (which is what
|
|
// failed in the first place). These popups are fire-and-forget, so we
|
|
// explicitly catch the `loaded` rejection to avoid unhandledRejection.
|
|
try {
|
|
win.webContents.setWindowOpenHandler((details) => {
|
|
const targetUrl = details?.url;
|
|
if (targetUrl && typeof targetUrl === "string" && /^https?:/i.test(targetUrl)) {
|
|
try {
|
|
const popup = openFallbackBrowser(targetUrl, { backgroundColor, appIcon, getAppIcon });
|
|
popup.loaded.catch((err) => {
|
|
console.warn("[windowManager] fallback popup loadURL failed:", err?.message || err);
|
|
});
|
|
} catch (popupErr) {
|
|
console.warn("[windowManager] fallback popup open failed:", popupErr?.message || popupErr);
|
|
}
|
|
}
|
|
return { action: "deny" };
|
|
});
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
// Minimal keyboard navigation: Alt+← / Alt+→ / Ctrl/Cmd+R.
|
|
try {
|
|
win.webContents.on("before-input-event", (_event, input) => {
|
|
if (input.type !== "keyDown") return;
|
|
try {
|
|
const history = win.webContents.navigationHistory;
|
|
if (input.alt && input.key === "ArrowLeft" && history?.canGoBack?.()) {
|
|
history.goBack();
|
|
} else if (input.alt && input.key === "ArrowRight" && history?.canGoForward?.()) {
|
|
history.goForward();
|
|
} else if ((input.control || input.meta) && typeof input.key === "string" && input.key.toLowerCase() === "r") {
|
|
win.webContents.reload();
|
|
}
|
|
} catch {
|
|
// ignore navigation errors
|
|
}
|
|
});
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
win.once("ready-to-show", () => {
|
|
try {
|
|
win.show();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
});
|
|
|
|
// Return the window together with its initial-load Promise. Callers that
|
|
// care about whether the page actually loaded can await `loaded`; fire-
|
|
// and-forget callers must still catch the rejection themselves to avoid
|
|
// turning it into an unhandledRejection.
|
|
const loaded = win.loadURL(url);
|
|
|
|
return { window: win, loaded };
|
|
}
|
|
|
|
/**
|
|
* Try to open a URL with the OS default browser via shell.openExternal; if
|
|
* that fails (e.g. no default browser configured), fall back to the in-app
|
|
* BrowserWindow. Resolves on success (either via system browser, or when
|
|
* the in-app fallback window finishes its initial load). Throws on total
|
|
* failure so callers that rely on rejection semantics (e.g. OAuth flows
|
|
* waiting on a Promise.race) still abort cleanly when no browser path is
|
|
* available.
|
|
*/
|
|
async function tryOpenExternalWithFallback(shell, url, options = {}) {
|
|
if (!url || typeof url !== "string" || !/^https?:/i.test(url)) {
|
|
throw new Error("openExternal: invalid URL");
|
|
}
|
|
try {
|
|
await shell?.openExternal?.(url);
|
|
return;
|
|
} catch (err) {
|
|
const message = err?.message || String(err);
|
|
console.warn("[windowManager] shell.openExternal failed, using in-app fallback:", message);
|
|
|
|
let fallback;
|
|
try {
|
|
fallback = openFallbackBrowser(url, options);
|
|
} catch (createErr) {
|
|
console.warn("[windowManager] fallback browser creation failed:", createErr?.message || createErr);
|
|
throw err instanceof Error ? err : new Error(message);
|
|
}
|
|
|
|
try {
|
|
// Wait for the fallback window's initial load. If the URL is
|
|
// unreachable or malformed, loadURL rejects — surface that as a real
|
|
// failure so callers (e.g. OAuth flows) can cancel early instead of
|
|
// waiting for a downstream timeout.
|
|
await fallback.loaded;
|
|
return;
|
|
} catch (loadErr) {
|
|
console.warn("[windowManager] fallback browser loadURL failed:", loadErr?.message || loadErr);
|
|
try {
|
|
if (fallback.window && !fallback.window.isDestroyed()) {
|
|
fallback.window.close();
|
|
}
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
throw err instanceof Error ? err : new Error(message);
|
|
}
|
|
}
|
|
}
|
|
|
|
function createExternalOnlyWindowOpenHandler(shell, options = {}) {
|
|
return (details) => {
|
|
const targetUrl = details?.url;
|
|
if (targetUrl && typeof targetUrl === "string" && /^https?:/i.test(targetUrl)) {
|
|
// Run async fallback path without blocking the window-open decision.
|
|
tryOpenExternalWithFallback(shell, targetUrl, options).catch((err) => {
|
|
console.warn("[windowManager] tryOpenExternalWithFallback threw:", err?.message || err);
|
|
});
|
|
}
|
|
return { action: "deny" };
|
|
};
|
|
}
|
|
|
|
function createAppWindowOpenHandler(shell, { backgroundColor, appIcon, getAppIcon }) {
|
|
const allowedPopupHosts = new Set([
|
|
// OAuth (PKCE loopback)
|
|
"accounts.google.com",
|
|
"login.microsoftonline.com",
|
|
"login.live.com",
|
|
]);
|
|
|
|
const isAllowedInAppPopupUrl = (rawUrl) => {
|
|
try {
|
|
const u = new URL(String(rawUrl));
|
|
if (u.protocol === "https:") {
|
|
return allowedPopupHosts.has(u.hostname);
|
|
}
|
|
if (u.protocol === "http:") {
|
|
// Allow ONLY the loopback OAuth callback page, and only while an
|
|
// OAuth flow is actively prepared — the acceptable port matches
|
|
// whatever oauthBridge just bound for this session.
|
|
const isLoopback =
|
|
u.hostname === "127.0.0.1" || u.hostname === "localhost";
|
|
if (!isLoopback || u.pathname !== "/oauth/callback") return false;
|
|
const activePort = oauthBridge.getActiveOAuthPort?.();
|
|
return activePort != null && u.port === String(activePort);
|
|
}
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
return (details) => {
|
|
const targetUrl = details?.url;
|
|
const currentIcon = resolveAppIconFromOptions({ appIcon, getAppIcon });
|
|
if (!targetUrl || typeof targetUrl !== "string" || !/^https?:/i.test(targetUrl)) {
|
|
return { action: "deny" };
|
|
}
|
|
|
|
// Default: open in system browser to reduce remote-content attack surface.
|
|
if (!isAllowedInAppPopupUrl(targetUrl)) {
|
|
// Try system browser first, fall back to an in-app BrowserWindow when
|
|
// the OS has no handler for the URL (see tryOpenExternalWithFallback).
|
|
tryOpenExternalWithFallback(shell, targetUrl, {
|
|
backgroundColor,
|
|
appIcon: currentIcon,
|
|
getAppIcon,
|
|
}).catch((err) => {
|
|
console.warn("[windowManager] tryOpenExternalWithFallback threw:", err?.message || err);
|
|
});
|
|
return { action: "deny" };
|
|
}
|
|
|
|
const size = parseWindowOpenFeatures(details?.features);
|
|
return {
|
|
action: "allow",
|
|
overrideBrowserWindowOptions: {
|
|
width: size.width || OAUTH_DEFAULT_WIDTH,
|
|
height: size.height || OAUTH_DEFAULT_HEIGHT,
|
|
minWidth: 420,
|
|
minHeight: 560,
|
|
backgroundColor,
|
|
icon: currentIcon,
|
|
autoHideMenuBar: true,
|
|
menuBarVisible: false,
|
|
title: "Netcatty Authorization",
|
|
webPreferences: {
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
// Sandboxed because this window renders remote content and does not need a preload bridge.
|
|
sandbox: true,
|
|
spellcheck: false,
|
|
v8CacheOptions: V8_CACHE_OPTIONS,
|
|
},
|
|
},
|
|
};
|
|
};
|
|
}
|
|
|
|
return {
|
|
openFallbackBrowser,
|
|
tryOpenExternalWithFallback,
|
|
createExternalOnlyWindowOpenHandler,
|
|
createAppWindowOpenHandler,
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = { createExternalWindowApi };
|