Browse Source

WIP (fix): improve electron build configuration and service worker handling

- Properly disable service workers in electron builds
- Add CSP headers for electron security
- Fix path resolution in electron context
- Improve preload script error handling and IPC setup
- Update build scripts for better electron/capacitor compatibility
- Fix router path handling in electron context
- Remove electron-builder dependency
- Streamline build process and output structure

This change improves the stability and security of electron builds while
maintaining PWA functionality in web builds. Service workers are now
properly disabled in electron context, and path resolution issues are
fixed.
pull/126/head
Matthew Raymer 1 week ago
parent
commit
f0d0f63672
  1. 11
      BUILDING.md
  2. 2711
      package-lock.json
  3. 8
      package.json
  4. 83
      scripts/build-electron.js
  5. 124
      src/electron/main.js
  6. 58
      src/electron/preload.js
  7. 19
      src/registerServiceWorker.ts
  8. 4
      src/router/index.ts
  9. 37
      src/vite.config.utils.js
  10. 114
      vite.config.mjs

11
BUILDING.md

@ -46,21 +46,16 @@ To build the desktop application:
1. Run the Electron build:
```bash
npm run build -- --mode electron
npm run build:electron
```
2. The built files will be in `dist-electron`.
3. To create installable packages:
3. To run the desktop app:
```bash
npm run electron:build
electron dist-electron
```
This will create platform-specific installers in `dist-electron-build`:
- Windows: `.exe` installer
- macOS: `.dmg` file
- Linux: `.AppImage` file
## Mobile Builds (Capacitor)
### iOS Build

2711
package-lock.json

File diff suppressed because it is too large

8
package.json

@ -14,13 +14,12 @@
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js",
"test-local": "npx playwright test -c playwright.config-local.ts --trace on",
"test-all": "npm run build && npx playwright test -c playwright.config-local.ts --trace on",
"clean:electron": "rimraf dist-electron dist-electron-build",
"clean:electron": "rimraf dist-electron",
"build:electron": "npm run clean:electron && vite build --mode electron && node scripts/build-electron.js",
"build:capacitor": "vite build --mode capacitor",
"build:web": "vite build",
"electron:build:linux": "npm run build:electron && electron-builder --linux",
"electron:build:mac": "npm run build:electron && npx electron-builder --mac",
"electron:build:win": "npm run build:electron && npx electron-builder --win"
"electron:dev": "npm run build:electron && electron dist-electron --inspect",
"electron:start": "electron dist-electron"
},
"dependencies": {
"@capacitor/android": "^6.2.0",
@ -107,7 +106,6 @@
"@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.19",
"electron": "^33.2.1",
"electron-builder": "^25.1.8",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",

83
scripts/build-electron.js

@ -1,61 +1,48 @@
const fs = require('fs-extra');
const path = require('path');
const fs = require('fs-extra');
async function main() {
try {
console.log('Starting electron build process...');
// Clean directories
const distElectronDir = path.resolve(__dirname, '../dist-electron');
const buildDir = path.resolve(__dirname, '../dist-electron-build');
await fs.emptyDir(distElectronDir);
await fs.emptyDir(buildDir);
console.log('Cleaned directories');
// First build the web app if it doesn't exist
const webDist = path.resolve(__dirname, '../dist');
if (!await fs.pathExists(webDist)) {
console.log('Web dist not found, building web app first...');
throw new Error('Please run \'npm run build\' first to build the web app');
}
// Create dist directory if it doesn't exist
const distElectronDir = path.resolve(__dirname, '../dist-electron');
await fs.ensureDir(distElectronDir);
// Copy web files to www directory
// Copy web files
const wwwDir = path.join(distElectronDir, 'www');
await fs.copy(webDist, wwwDir);
await fs.ensureDir(wwwDir);
await fs.copy('dist', wwwDir);
// Fix paths in index.html
// Copy and fix index.html
const indexPath = path.join(wwwDir, 'index.html');
let indexContent = await fs.readFile(indexPath, 'utf8');
// Fix paths in index.html
indexContent = indexContent
// Fix absolute paths to be relative
.replace(/src="\//g, 'src="\./')
.replace(/href="\//g, 'href="\./')
// Fix relative asset paths
.replace(/src="\.\.\/assets\//g, 'src="./www/assets/')
.replace(/href="\.\.\/assets\//g, 'href="./www/assets/')
// Fix modulepreload paths specifically
.replace(/<link [^>]*rel="modulepreload"[^>]*href="(?!\.?\/www\/)(\/\.\/)?assets\//g, '<link rel="modulepreload" as="script" crossorigin="" href="./www/assets/')
.replace(/<link [^>]*rel="modulepreload"[^>]*href="(?!\.?\/www\/)(\/)?assets\//g, '<link rel="modulepreload" as="script" crossorigin="" href="./www/assets/')
// Fix stylesheet paths
.replace(/<link [^>]*rel="stylesheet"[^>]*href="(?!\.?\/www\/)(\/\.\/)?assets\//g, '<link rel="stylesheet" crossorigin="" href="./www/assets/')
.replace(/<link [^>]*rel="stylesheet"[^>]*href="(?!\.?\/www\/)(\/)?assets\//g, '<link rel="stylesheet" crossorigin="" href="./www/assets/')
// Fix any remaining asset paths that don't already have www
.replace(/(['"]\/?)((?!www\/)(assets\/))/, '"./www/assets/');
.replace(/href="\.\.\/assets\//g, 'href="./www/assets/');
await fs.writeFile(indexPath, indexContent);
console.log('Copied and fixed web files in:', wwwDir);
// Copy main process files
console.log('Copying main process files...');
const mainProcessFiles = [
'src/electron/main.js',
['src/electron/main.js', 'main.js'],
['src/electron/preload.js', 'preload.js']
];
for (const file of mainProcessFiles) {
const destPath = path.join(distElectronDir, path.basename(file));
await fs.copy(file, destPath);
for (const [src, dest] of mainProcessFiles) {
const destPath = path.join(distElectronDir, dest);
console.log(`Copying ${src} to ${destPath}`);
await fs.copy(src, destPath);
}
// Create the production package.json
// Create package.json for production
const devPackageJson = require('../package.json');
const prodPackageJson = {
name: devPackageJson.name,
@ -72,26 +59,26 @@ async function main() {
{ spaces: 2 }
);
// Verify the structure
// Verify the build
console.log('\nVerifying build structure:');
const printDir = async (dir, prefix = '') => {
const items = await fs.readdir(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = await fs.stat(fullPath);
console.log(`${prefix}${item}${stat.isDirectory() ? '/' : ''}`);
if (stat.isDirectory()) {
await printDir(fullPath, `${prefix} `);
}
}
};
await printDir(distElectronDir);
const files = await fs.readdir(distElectronDir);
console.log('Files in dist-electron:', files);
if (!files.includes('main.js')) {
throw new Error('main.js not found in build directory');
}
if (!files.includes('preload.js')) {
throw new Error('preload.js not found in build directory');
}
if (!files.includes('package.json')) {
throw new Error('package.json not found in build directory');
}
console.log('\nBuild completed successfully!');
console.log('Build completed successfully!');
} catch (error) {
console.error('Build failed:', error);
process.exit(1);
}
}
main().catch(console.error);
main();

124
src/electron/main.js

@ -2,99 +2,103 @@ const { app, BrowserWindow } = require("electron");
const path = require("path");
const fs = require("fs");
// Check if running in dev mode
const isDev = process.argv.includes('--inspect');
function createWindow() {
// Add before createWindow function
const preloadPath = path.join(__dirname, 'preload.js');
console.log('Checking preload path:', preloadPath);
console.log('Preload exists:', fs.existsSync(preloadPath));
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
nodeIntegration: false,
contextIsolation: true,
webSecurity: true,
allowRunningInsecureContent: false,
preload: path.join(__dirname, 'preload.js')
},
});
// Disable service worker in Electron
mainWindow.webContents.session.setPermissionRequestHandler(
(webContents, permission, callback) => {
if (permission === "serviceWorker") {
return callback(false);
}
callback(true);
},
);
// Get the correct app path for packaged and development environments
const appPath = app.isPackaged ? process.resourcesPath : app.getAppPath();
// Add debug logging for paths
// Debug info
console.log("Debug Info:");
console.log("Running in dev mode:", isDev);
console.log("App is packaged:", app.isPackaged);
console.log("Process resource path:", process.resourcesPath);
console.log("App path:", appPath);
console.log("App path:", app.getAppPath());
console.log("__dirname:", __dirname);
console.log("process.cwd():", process.cwd());
console.log("www path:", path.join(process.resourcesPath, "www"));
console.log(
"www assets path:",
path.join(process.resourcesPath, "www", "assets"),
);
// Try both possible www locations
const possiblePaths = [
path.join(appPath, "www", "index.html"),
path.join(appPath, "..", "www", "index.html"),
path.join(process.resourcesPath, "www", "index.html"),
];
let indexPath;
for (const testPath of possiblePaths) {
console.log("Testing path:", testPath);
if (fs.existsSync(testPath)) {
indexPath = testPath;
console.log("Found valid path:", indexPath);
break;
}
const indexPath = path.join(__dirname, 'www', 'index.html');
console.log("www path:", path.join(__dirname, 'www'));
console.log("www assets path:", path.join(__dirname, 'www', 'assets'));
if (!fs.existsSync(indexPath)) {
console.error(`Index file not found at: ${indexPath}`);
throw new Error('Index file not found');
}
// Set CSP headers
mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"default-src 'self';" +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;" +
"font-src 'self' https://fonts.gstatic.com;" +
"script-src 'self' 'unsafe-eval' 'unsafe-inline';" +
"img-src 'self' data: https:;"
]
}
});
});
// Load the index.html
mainWindow
.loadFile(indexPath)
.then(() => {
console.log("Successfully loaded index.html");
// Always open DevTools in packaged app for debugging
mainWindow.webContents.openDevTools();
if (isDev) {
mainWindow.webContents.openDevTools();
console.log("DevTools opened - running in dev mode");
}
})
.catch((err) => {
console.error("Failed to load index.html:", err);
console.error("Attempted path:", indexPath);
});
// Listen for page errors
mainWindow.webContents.on(
"did-fail-load",
(event, errorCode, errorDescription) => {
console.error("Page failed to load:", errorCode, errorDescription);
},
);
// Listen for console messages from the renderer
mainWindow.webContents.on("console-message", (_event, level, message) => {
console.log("Renderer Console:", message);
});
}
// Handle app ready
app.whenReady().then(() => {
createWindow();
// Add right after creating the BrowserWindow
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
console.error('Page failed to load:', errorCode, errorDescription);
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
mainWindow.webContents.on('preload-error', (event, preloadPath, error) => {
console.error('Preload script error:', preloadPath, error);
});
});
mainWindow.webContents.on('console-message', (event, level, message, line, sourceId) => {
console.log('Renderer Console:', message);
});
// Enable remote debugging when in dev mode
if (isDev) {
mainWindow.webContents.openDevTools();
}
}
// Handle app ready
app.whenReady().then(createWindow);
// Handle all windows closed
app.on("window-all-closed", () => {
@ -103,6 +107,12 @@ app.on("window-all-closed", () => {
}
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// Handle any errors
process.on("uncaughtException", (error) => {
console.error("Uncaught Exception:", error);

58
src/electron/preload.js

@ -1,5 +1,55 @@
const { contextBridge } = require("electron");
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld("api", {
logMessage: (message) => console.log(`[Electron]: ${message}`),
});
// Use a more direct path resolution approach
const getPath = (pathType) => {
switch(pathType) {
case 'userData':
return process.env.APPDATA || (
process.platform === 'darwin'
? `${process.env.HOME}/Library/Application Support`
: `${process.env.HOME}/.local/share`
);
case 'home':
return process.env.HOME;
case 'appPath':
return process.resourcesPath;
default:
return '';
}
};
console.log('Preload script starting...');
try {
contextBridge.exposeInMainWorld('electronAPI', {
// Path utilities
getPath,
// IPC functions
send: (channel, data) => {
const validChannels = ['toMain'];
if (validChannels.includes(channel)) {
ipcRenderer.send(channel, data);
}
},
receive: (channel, func) => {
const validChannels = ['fromMain'];
if (validChannels.includes(channel)) {
ipcRenderer.on(channel, (event, ...args) => func(...args));
}
},
// Environment info
env: {
isElectron: true,
isDev: process.env.NODE_ENV === 'development'
},
// Path utilities
getBasePath: () => {
return process.env.NODE_ENV === 'development' ? '/' : './';
}
});
console.log('Preload script completed successfully');
} catch (error) {
console.error('Error in preload script:', error);
}

19
src/registerServiceWorker.ts

@ -2,14 +2,11 @@
import { register } from "register-service-worker";
// NODE_ENV is "production" by default with "vite build". See https://vitejs.dev/guide/env-and-mode
if (import.meta.env.NODE_ENV === "production") {
register("/sw_scripts-combined.js", {
// Only register service worker if explicitly enabled and in production
if (process.env.VITE_PWA_ENABLED === 'true' && process.env.NODE_ENV === "production") {
register(`${process.env.BASE_URL}sw.js`, {
ready() {
console.log(
"App is being served from cache by a service worker.\n" +
"For more details, visit https://goo.gl/AFskqB",
);
console.log("Service worker is active.");
},
registered() {
console.log("Service worker has been registered.");
@ -24,12 +21,12 @@ if (import.meta.env.NODE_ENV === "production") {
console.log("New content is available; please refresh.");
},
offline() {
console.log(
"No internet connection found. App is running in offline mode.",
);
console.log("No internet connection found. App is running in offline mode.");
},
error(error) {
console.error("Error during service worker registration:", error);
},
}
});
} else {
console.log("Service worker registration skipped - not enabled or not in production");
}

4
src/router/index.ts

@ -284,9 +284,9 @@ const routes: Array<RouteRecordRaw> = [
},
];
const isElectron = window.location.protocol === "file:"; // Check if running in Electron
const isElectron = window.location.protocol === "file:";
const initialPath = isElectron
? window.location.pathname.replace("/dist-electron/index.html", "/")
? window.location.pathname.split('/dist-electron/www/')[1] || '/'
: window.location.pathname;
const history = isElectron

37
src/vite.config.utils.js

@ -1,16 +1,14 @@
import * as path from "path";
import { promises as fs } from "fs";
import { fileURLToPath } from 'url';
export async function loadAppConfig() {
const packageJson = await loadPackageJson();
const appName = process.env.TIME_SAFARI_APP_TITLE || packageJson.name;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
return {
pwaConfig: {
registerType: "autoUpdate",
strategies: "injectManifest",
srcDir: ".",
filename: "sw_scripts-combined.js",
manifest: {
name: appName,
short_name: appName,
@ -36,34 +34,21 @@ export async function loadAppConfig() {
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
share_target: {
action: "/share-target",
method: "POST",
enctype: "multipart/form-data",
params: {
files: [
{
name: "photo",
accept: ["image/*"],
},
],
},
},
},
}
]
}
},
aliasConfig: {
"@": path.resolve(__dirname, "./src"),
buffer: path.resolve(__dirname, "node_modules", "buffer"),
"dexie-export-import/dist/import":
"dexie-export-import/dist/import/index.js",
},
"@": path.resolve(path.dirname(__dirname), "src"),
buffer: path.resolve(path.dirname(__dirname), "node_modules", "buffer"),
"dexie-export-import/dist/import": "dexie-export-import/dist/import/index.js",
}
};
}
async function loadPackageJson() {
const packageJsonPath = path.resolve(__dirname, "package.json");
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packageJsonPath = path.resolve(path.dirname(__dirname), "package.json");
const packageJsonData = await fs.readFile(packageJsonPath, "utf-8");
return JSON.parse(packageJsonData);
}

114
vite.config.mjs

@ -4,10 +4,15 @@ import vue from "@vitejs/plugin-vue";
import dotenv from "dotenv";
import { loadAppConfig } from "./vite.config.utils";
import path from "path";
import { fileURLToPath } from 'url';
// Load environment variables from .env file
dotenv.config();
// Get dirname in ESM context
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load application configuration
const appConfig = loadAppConfig();
@ -15,48 +20,101 @@ export default defineConfig(({ mode }) => {
const isElectron = mode === "electron";
const isCapacitor = mode === "capacitor";
// Set output directory based on build mode
const outDir = isElectron
? "dist-electron/www"
: isCapacitor
? "dist-capacitor"
: "dist";
// Completely disable PWA features for electron builds
if (isElectron) {
process.env.VITE_PWA_ENABLED = 'false';
}
return {
base: isElectron ? "./" : "/",
server: {
port: process.env.VITE_PORT || 8080,
fs: {
strict: false
}
},
build: {
outDir,
outDir: isElectron ? "dist-electron" : "dist",
assetsDir: 'assets',
rollupOptions: {
...(isElectron && {
input: {
index: path.resolve(__dirname, 'index.html')
},
output: {
dir: outDir,
format: 'cjs',
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash][extname]'
external: ['electron', 'path'],
input: {
main: path.resolve(__dirname, 'index.html')
},
output: {
manualChunks(id) {
if (isElectron && (
id.includes('registerServiceWorker') ||
id.includes('register-service-worker') ||
id.includes('workbox') ||
id.includes('sw_scripts') ||
id.includes('PushNotificationPermission')
)) {
return null; // Exclude these modules completely
}
}
})
}
}
},
chunkSizeWarningLimit: 1000
},
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
'process.env.VITE_PWA_ENABLED': JSON.stringify(!isElectron),
__dirname: isElectron ? JSON.stringify(process.cwd()) : '""',
'navigator.serviceWorker': isElectron ? 'undefined' : 'navigator.serviceWorker'
},
plugins: [
vue(),
...(isElectron
? []
: [
VitePWA({
...appConfig.pwaConfig,
disable: isElectron
}),
]),
{
name: 'remove-sw-imports',
transform(code, id) {
if (isElectron) {
if (
id.includes('registerServiceWorker') ||
id.includes('register-service-worker') ||
id.includes('sw_scripts') ||
id.includes('PushNotificationPermission') ||
code.includes('navigator.serviceWorker')
) {
return {
code: code
.replace(/import.*registerServiceWorker.*$/mg, '')
.replace(/import.*register-service-worker.*$/mg, '')
.replace(/navigator\.serviceWorker/g, 'undefined')
.replace(/if\s*\([^)]*serviceWorker[^)]*\)\s*{[^}]*}/g, '')
};
}
}
}
},
...(!isElectron && !isCapacitor ? [
VitePWA({
disable: true,
registerType: 'autoUpdate',
injectRegister: null,
workbox: {
cleanupOutdatedCaches: true,
skipWaiting: true,
clientsClaim: true,
sourcemap: true
},
manifest: appConfig.pwaConfig?.manifest,
devOptions: {
enabled: false
}
}),
] : []),
],
resolve: {
alias: appConfig.aliasConfig,
alias: appConfig.aliasConfig
},
optimizeDeps: {
exclude: isElectron ? [
'register-service-worker',
'workbox-window',
'web-push',
'serviceworker-webpack-plugin'
] : []
}
};
});

Loading…
Cancel
Save