mirror of
https://github.com/jhbruhn/respira.git
synced 2026-03-14 02:38:41 +00:00
Compare commits
No commits in common. "d31cb2f29e85ce35e5b8eb5c66324106c34492a6" and "a253901fb4e6a7674c3264c5ab2750b647dbaa34" have entirely different histories.
d31cb2f29e
...
a253901fb4
2 changed files with 32 additions and 193 deletions
192
electron/main.ts
192
electron/main.ts
|
|
@ -1,5 +1,5 @@
|
||||||
import { app, BrowserWindow, ipcMain, dialog, protocol } from "electron";
|
import { app, BrowserWindow, ipcMain, dialog } from "electron";
|
||||||
import { join, resolve, normalize, isAbsolute } from "path";
|
import { join } from "path";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import Store from "electron-store";
|
import Store from "electron-store";
|
||||||
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
|
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
|
||||||
|
|
@ -22,79 +22,8 @@ updateElectronApp({
|
||||||
app.commandLine.appendSwitch("enable-web-bluetooth", "true");
|
app.commandLine.appendSwitch("enable-web-bluetooth", "true");
|
||||||
app.commandLine.appendSwitch("enable-experimental-web-platform-features");
|
app.commandLine.appendSwitch("enable-experimental-web-platform-features");
|
||||||
|
|
||||||
// Register custom protocol for serving app files with proper COOP/COEP headers
|
|
||||||
// This is required for SharedArrayBuffer support in production builds
|
|
||||||
protocol.registerSchemesAsPrivileged([
|
|
||||||
{
|
|
||||||
scheme: "app",
|
|
||||||
privileges: {
|
|
||||||
standard: true,
|
|
||||||
secure: true,
|
|
||||||
supportFetchAPI: true,
|
|
||||||
corsEnabled: false,
|
|
||||||
bypassCSP: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const store = new Store();
|
const store = new Store();
|
||||||
|
|
||||||
// Setup custom protocol handler for production builds
|
|
||||||
async function setupCustomProtocol() {
|
|
||||||
protocol.handle("app", async (request) => {
|
|
||||||
// Parse the URL to get the file path
|
|
||||||
const url = new URL(request.url);
|
|
||||||
let filePath = decodeURIComponent(url.pathname);
|
|
||||||
|
|
||||||
// Handle Windows paths (remove leading slash)
|
|
||||||
if (process.platform === "win32" && filePath.startsWith("/")) {
|
|
||||||
filePath = filePath.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve relative to app resources
|
|
||||||
const resourcePath = join(
|
|
||||||
__dirname,
|
|
||||||
"..",
|
|
||||||
"renderer",
|
|
||||||
MAIN_WINDOW_VITE_NAME,
|
|
||||||
filePath,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await fs.readFile(resourcePath);
|
|
||||||
|
|
||||||
// Determine content type from extension
|
|
||||||
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
||||||
const contentTypes: Record<string, string> = {
|
|
||||||
html: "text/html",
|
|
||||||
js: "application/javascript",
|
|
||||||
css: "text/css",
|
|
||||||
json: "application/json",
|
|
||||||
png: "image/png",
|
|
||||||
jpg: "image/jpeg",
|
|
||||||
jpeg: "image/jpeg",
|
|
||||||
svg: "image/svg+xml",
|
|
||||||
wasm: "application/wasm",
|
|
||||||
whl: "application/zip",
|
|
||||||
};
|
|
||||||
const contentType = contentTypes[ext || ""] || "application/octet-stream";
|
|
||||||
|
|
||||||
// Return response with proper COOP/COEP/CORP headers
|
|
||||||
return new Response(data, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": contentType,
|
|
||||||
"Cross-Origin-Opener-Policy": "same-origin",
|
|
||||||
"Cross-Origin-Embedder-Policy": "require-corp",
|
|
||||||
"Cross-Origin-Resource-Policy": "same-origin",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[CustomProtocol] Failed to load:", resourcePath, err);
|
|
||||||
return new Response("Not Found", { status: 404 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1600,
|
width: 1600,
|
||||||
|
|
@ -109,7 +38,8 @@ function createWindow() {
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
webSecurity: true,
|
webSecurity: true,
|
||||||
// SharedArrayBuffer enabled via proper COOP/COEP headers below
|
// Enable SharedArrayBuffer for Pyodide
|
||||||
|
additionalArguments: ["--enable-features=SharedArrayBuffer"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -176,16 +106,21 @@ function createWindow() {
|
||||||
// Set COOP/COEP headers for Pyodide SharedArrayBuffer support
|
// Set COOP/COEP headers for Pyodide SharedArrayBuffer support
|
||||||
mainWindow.webContents.session.webRequest.onHeadersReceived(
|
mainWindow.webContents.session.webRequest.onHeadersReceived(
|
||||||
(details, callback) => {
|
(details, callback) => {
|
||||||
// Apply security headers to enable cross-origin isolation (needed for SharedArrayBuffer)
|
// Apply headers to ALL resources including workers
|
||||||
const headers: Record<string, string[]> = {
|
const headers: Record<string, string[]> = {
|
||||||
...details.responseHeaders,
|
...details.responseHeaders,
|
||||||
"Cross-Origin-Opener-Policy": ["same-origin"],
|
"Cross-Origin-Opener-Policy": ["unsafe-none"],
|
||||||
"Cross-Origin-Embedder-Policy": ["require-corp"],
|
"Cross-Origin-Embedder-Policy": ["unsafe-none"],
|
||||||
// Add CORP to ALL resources since this is a local-only app
|
|
||||||
// This allows workers, Pyodide assets, and all other resources to load
|
|
||||||
"Cross-Origin-Resource-Policy": ["same-origin"],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// For same-origin resources (including workers), add CORP header
|
||||||
|
if (
|
||||||
|
details.url.startsWith("http://localhost") ||
|
||||||
|
details.url.startsWith("file://")
|
||||||
|
) {
|
||||||
|
headers["Cross-Origin-Resource-Policy"] = ["same-origin"];
|
||||||
|
}
|
||||||
|
|
||||||
callback({ responseHeaders: headers });
|
callback({ responseHeaders: headers });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -196,18 +131,14 @@ function createWindow() {
|
||||||
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
|
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
|
||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools();
|
||||||
} else {
|
} else {
|
||||||
// Production: Use custom protocol to serve files with proper COOP/COEP/CORP headers
|
// MAIN_WINDOW_VITE_NAME is the renderer name from forge.config.js
|
||||||
// This enables SharedArrayBuffer support for Pyodide
|
mainWindow.loadFile(
|
||||||
mainWindow.loadURL("app://./index.html");
|
join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(createWindow);
|
||||||
// Setup custom protocol for production builds
|
|
||||||
await setupCustomProtocol();
|
|
||||||
|
|
||||||
createWindow();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
if (process.platform !== "darwin") {
|
if (process.platform !== "darwin") {
|
||||||
|
|
@ -304,9 +235,6 @@ ipcMain.handle("dialog:openFile", async (_event, options) => {
|
||||||
const filePath = result.filePaths[0];
|
const filePath = result.filePaths[0];
|
||||||
const fileName = filePath.split(/[\\/]/).pop() || "";
|
const fileName = filePath.split(/[\\/]/).pop() || "";
|
||||||
|
|
||||||
// Approve path for file operations since user explicitly selected it
|
|
||||||
approvePath(filePath);
|
|
||||||
|
|
||||||
console.log("[Dialog] File selected:", fileName);
|
console.log("[Dialog] File selected:", fileName);
|
||||||
return { filePath, fileName };
|
return { filePath, fileName };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -326,9 +254,6 @@ ipcMain.handle("dialog:saveFile", async (_event, options) => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approve path for file operations since user explicitly selected it
|
|
||||||
approvePath(result.filePath);
|
|
||||||
|
|
||||||
console.log("[Dialog] Save file selected:", result.filePath);
|
console.log("[Dialog] Save file selected:", result.filePath);
|
||||||
return result.filePath;
|
return result.filePath;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -337,67 +262,8 @@ ipcMain.handle("dialog:saveFile", async (_event, options) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// File system handlers with dialog-based path validation
|
// File system handlers
|
||||||
// Track paths approved by user through OS file dialogs
|
ipcMain.handle("fs:readFile", async (_event, filePath) => {
|
||||||
// This prevents arbitrary file access while allowing users full freedom
|
|
||||||
const userApprovedPaths = new Set<string>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that a file path was approved by the user through a dialog
|
|
||||||
* Also performs basic sanitization to prevent obvious attacks
|
|
||||||
*/
|
|
||||||
function isPathApproved(filePath: string): boolean {
|
|
||||||
// Reject empty, null, or undefined paths
|
|
||||||
if (!filePath) {
|
|
||||||
console.warn("[FS Security] Rejected empty path");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject relative paths - must be absolute
|
|
||||||
if (!isAbsolute(filePath)) {
|
|
||||||
console.warn("[FS Security] Rejected relative path:", filePath);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject paths with null bytes (security vulnerability)
|
|
||||||
if (filePath.includes("\0")) {
|
|
||||||
console.warn("[FS Security] Rejected path with null byte:", filePath);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize the path to prevent traversal tricks
|
|
||||||
const normalizedPath = normalize(resolve(filePath));
|
|
||||||
|
|
||||||
// Check if path was approved through a dialog
|
|
||||||
const isApproved = userApprovedPaths.has(normalizedPath);
|
|
||||||
|
|
||||||
if (!isApproved) {
|
|
||||||
console.warn(
|
|
||||||
"[FS Security] Rejected path - not approved through file dialog:",
|
|
||||||
filePath,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return isApproved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Approves a path for file operations after user selection via dialog
|
|
||||||
*/
|
|
||||||
function approvePath(filePath: string): void {
|
|
||||||
const normalizedPath = normalize(resolve(filePath));
|
|
||||||
userApprovedPaths.add(normalizedPath);
|
|
||||||
console.log("[FS Security] Approved path:", normalizedPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.handle("fs:readFile", async (_event, filePath: string) => {
|
|
||||||
// Validate path was approved by user
|
|
||||||
if (!isPathApproved(filePath)) {
|
|
||||||
throw new Error(
|
|
||||||
"Access denied: File path not approved. Please select the file through the file dialog.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await fs.readFile(filePath);
|
const buffer = await fs.readFile(filePath);
|
||||||
console.log("[FS] Read file:", filePath, "Size:", buffer.length);
|
console.log("[FS] Read file:", filePath, "Size:", buffer.length);
|
||||||
|
|
@ -408,16 +274,7 @@ ipcMain.handle("fs:readFile", async (_event, filePath: string) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle("fs:writeFile", async (_event, filePath, data) => {
|
||||||
"fs:writeFile",
|
|
||||||
async (_event, filePath: string, data: number[]) => {
|
|
||||||
// Validate path was approved by user
|
|
||||||
if (!isPathApproved(filePath)) {
|
|
||||||
throw new Error(
|
|
||||||
"Access denied: File path not approved. Please select the file through the save dialog.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(filePath, Buffer.from(data));
|
await fs.writeFile(filePath, Buffer.from(data));
|
||||||
console.log("[FS] Wrote file:", filePath);
|
console.log("[FS] Wrote file:", filePath);
|
||||||
|
|
@ -426,5 +283,4 @@ ipcMain.handle(
|
||||||
console.error("[FS] Failed to write file:", err);
|
console.error("[FS] Failed to write file:", err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
|
||||||
17
index.html
17
index.html
|
|
@ -2,23 +2,6 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<!-- Content Security Policy - Strict local-only configuration -->
|
|
||||||
<meta
|
|
||||||
http-equiv="Content-Security-Policy"
|
|
||||||
content="
|
|
||||||
default-src 'self' app:;
|
|
||||||
script-src 'self' app: 'wasm-unsafe-eval';
|
|
||||||
style-src 'self' app: 'unsafe-inline';
|
|
||||||
img-src 'self' app: data: blob:;
|
|
||||||
font-src 'self' app: data:;
|
|
||||||
connect-src 'self' app:;
|
|
||||||
worker-src 'self' app: blob:;
|
|
||||||
child-src 'none';
|
|
||||||
object-src 'none';
|
|
||||||
base-uri 'self';
|
|
||||||
form-action 'none';
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Respira</title>
|
<title>Respira</title>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue