Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"electron-find": "1.0.7",
"electron-react-titlebar": "1.2.1",
"electron-updater": "6.3.9",
"electron-webauthn-linux": "0.1.1",
"electron-window-state": "5.0.3",
"fast-folder-size": "2.2.0",
"fs-extra": "11.2.0",
Expand Down Expand Up @@ -127,6 +128,7 @@
"sqlite3": "5.1.6",
"tar": "6.2.1",
"tinycolor2": "1.6.0",
"tldts": "7.0.23",
"tslib": "2.7.0",
"useragent-generator": "1.1.1-amkt-22079-finish.0",
"uuid": "9.0.1",
Expand Down
29 changes: 29 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion src/components/util/ErrorBoundary/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
};
}

componentDidCatch(): void {
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
console.error('ErrorBoundary caught:', error);
console.error('Component stack:', errorInfo.componentStack);
this.setState({ hasError: true });
}

Expand Down
97 changes: 97 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import {
} from 'electron';

import { initialize } from 'electron-react-titlebar/main';
import { setupWebAuthn } from 'electron-webauthn-linux';
import windowStateKeeper from 'electron-window-state';
import { emptyDirSync, ensureFileSync } from 'fs-extra';
import minimist from 'minimist';
import ms from 'ms';
import { getDomain } from 'tldts';
import { enableWebContents, initializeRemote } from './electron-util';
import enforceMacOSAppLocation from './enforce-macos-app-location';

Expand Down Expand Up @@ -257,7 +259,92 @@ const createWindow = () => {
app.on('web-contents-created', (_e, contents) => {
if (contents.getType() === 'webview') {
enableWebContents(contents);

// Set permission handlers on service webview sessions.
// setPermissionRequestHandler allows safe permissions and denies unknown ones.
// setPermissionCheckHandler additionally allows hid/serial/usb feature detection
// (actual device access is gated by select-hid-device/select-usb-device events).
const ses = contents.session;
if (!(ses as any)._permissionHandlersSet) {
(ses as any)._permissionHandlersSet = true;

ses.setPermissionRequestHandler((webContents, permission, callback) => {
const allowedPermissions = [
'media',
'notifications',
'fullscreen',
'pointerLock',
'display-capture',
'idle-detection',
'clipboard-read',
'clipboard-sanitized-write',
'speaker-selection',
];

if (allowedPermissions.includes(permission)) {
callback(true);
return;
}

debug(
`Denied permission request: ${permission} from ${webContents?.getURL()}`,
);
callback(false);
});

ses.setPermissionCheckHandler((_webContents, permission) => {
const allowedChecks = [
'media',
'notifications',
'fullscreen',
'pointerLock',
'display-capture',
'idle-detection',
'clipboard-read',
'clipboard-sanitized-write',
'hid',
'serial',
'usb',
'speaker-selection',
];

return allowedChecks.includes(permission);
});
}

contents.setWindowOpenHandler(({ url }) => {
// Allow same-domain popups (e.g. Google auth) to open
// as child windows within Ferdium instead of the external browser.
// Uses tldts for correct eTLD+1 matching (handles .co.uk, .com.au, etc.)
try {
const popupHost = new URL(url).hostname;
const currentHost = new URL(contents.getURL()).hostname;
const popupDomain = getDomain(popupHost);
const currentDomain = getDomain(currentHost);
if (popupDomain && currentDomain && popupDomain === currentDomain) {
// On Linux, give popup windows a WebAuthn preload so passkey
// flows work in auth popups (they don't get the recipe preload).
if (isLinux) {
return {
action: 'allow',
overrideBrowserWindowOptions: {
webPreferences: {
preload: join(
__dirname,
'webview',
'webauthn-popup-preload.js',
),
contextIsolation: true,
sandbox: true,
},
},
};
}
return { action: 'allow' };
}
} catch {
// fall through
}
openExternalUrl(url);
return { action: 'deny' };
});
Expand Down Expand Up @@ -531,6 +618,16 @@ app.on('ready', () => {

initialize();

// Initialize WebAuthn/passkey support on Linux
if (isLinux) {
setupWebAuthn({
storagePath: userDataPath(),
enableHardwareKeys: true,
}).catch(error => {
debug('WebAuthn setup failed:', error.message);
});
}

createWindow();
});

Expand Down
5 changes: 2 additions & 3 deletions src/stores/AppStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,9 +410,8 @@ export default class AppStore extends TypedStore {
}

_readSandboxes() {
this.sandboxServices = readJsonSync(
userDataPath('config', 'sandboxes.json'),
);
const data = readJsonSync(userDataPath('config', 'sandboxes.json'));
this.sandboxServices = Array.isArray(data) ? data : [];
}

_writeSandboxes() {
Expand Down
23 changes: 23 additions & 0 deletions src/webview/recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,29 @@ contextBridge.exposeInMainWorld('ferdium', {
getDisplayMediaSelector,
});

// WebAuthn/passkey support on Linux.
// Expose the IPC bridge and inject the page script into the main world
// BEFORE page scripts run, so navigator.credentials is patched in time.
if (process.platform === 'linux') {
contextBridge.exposeInMainWorld('electronWebAuthn', {
create: (options: any) => ipcRenderer.invoke('webauthn:create', options),
get: (options: any) => ipcRenderer.invoke('webauthn:get', options),
hasCredentials: (rpId: string) =>
ipcRenderer.invoke('webauthn:hasCredentials', rpId),
});

// Inject page script at document-start (before any page JS).
// The <script> element runs in the main world (world 0), not the
// isolated preload world, which is what we need for monkey-patching.
const { webauthnPageScript } = require('electron-webauthn-linux');
process.once('document-start', () => {
const script = document.createElement('script');
script.textContent = webauthnPageScript;
document.documentElement.append(script);
script.remove();
});
}

ipcRenderer.sendToHost(
'inject-js-unsafe',
'window.open = window.ferdium.open;',
Expand Down
28 changes: 28 additions & 0 deletions src/webview/webauthn-popup-preload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Minimal preload for popup windows (e.g. Google auth popups) on Linux.
* Injects the WebAuthn page script into the main world via DOM script element
* and exposes the electronWebAuthn IPC bridge via contextBridge.
*
* This runs in popup BrowserWindows opened by setWindowOpenHandler with
* { action: 'allow' } -- these windows don't get the recipe preload.
*/
import { contextBridge, ipcRenderer } from 'electron';
import { webauthnPageScript } from 'electron-webauthn-linux';

// Inject the WebAuthn page script into the main world BEFORE page scripts run.
// process.once('document-start') fires at document creation, before any <script> tags.
// The DOM <script> element executes in world 0 (main world), not the isolated preload world.
process.once('document-start', () => {
const script = document.createElement('script');
script.textContent = webauthnPageScript;
document.documentElement.append(script);
script.remove();
});

// Expose the IPC bridge so the page script can route WebAuthn calls to main process.
contextBridge.exposeInMainWorld('electronWebAuthn', {
create: (options: any) => ipcRenderer.invoke('webauthn:create', options),
get: (options: any) => ipcRenderer.invoke('webauthn:get', options),
hasCredentials: (rpId: string) =>
ipcRenderer.invoke('webauthn:hasCredentials', rpId),
});