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: 1. Run the Electron build:
```bash ```bash
npm run build -- --mode electron npm run build:electron
``` ```
2. The built files will be in `dist-electron`. 2. The built files will be in `dist-electron`.
3. To create installable packages: 3. To run the desktop app:
```bash ```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) ## Mobile Builds (Capacitor)
### iOS Build ### 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", "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-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", "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:electron": "npm run clean:electron && vite build --mode electron && node scripts/build-electron.js",
"build:capacitor": "vite build --mode capacitor", "build:capacitor": "vite build --mode capacitor",
"build:web": "vite build", "build:web": "vite build",
"electron:build:linux": "npm run build:electron && electron-builder --linux", "electron:dev": "npm run build:electron && electron dist-electron --inspect",
"electron:build:mac": "npm run build:electron && npx electron-builder --mac", "electron:start": "electron dist-electron"
"electron:build:win": "npm run build:electron && npx electron-builder --win"
}, },
"dependencies": { "dependencies": {
"@capacitor/android": "^6.2.0", "@capacitor/android": "^6.2.0",
@ -107,7 +106,6 @@
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"electron": "^33.2.1", "electron": "^33.2.1",
"electron-builder": "^25.1.8",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1", "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 path = require('path');
const fs = require('fs-extra');
async function main() { async function main() {
try { try {
console.log('Starting electron build process...'); 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 // Create dist directory if it doesn't exist
const webDist = path.resolve(__dirname, '../dist'); const distElectronDir = path.resolve(__dirname, '../dist-electron');
if (!await fs.pathExists(webDist)) { await fs.ensureDir(distElectronDir);
console.log('Web dist not found, building web app first...');
throw new Error('Please run \'npm run build\' first to build the web app');
}
// Copy web files to www directory // Copy web files
const wwwDir = path.join(distElectronDir, 'www'); 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'); const indexPath = path.join(wwwDir, 'index.html');
let indexContent = await fs.readFile(indexPath, 'utf8'); let indexContent = await fs.readFile(indexPath, 'utf8');
// Fix paths in index.html
indexContent = indexContent indexContent = indexContent
// Fix absolute paths to be relative
.replace(/src="\//g, 'src="\./') .replace(/src="\//g, 'src="\./')
.replace(/href="\//g, 'href="\./') .replace(/href="\//g, 'href="\./')
// Fix relative asset paths
.replace(/src="\.\.\/assets\//g, 'src="./www/assets/') .replace(/src="\.\.\/assets\//g, 'src="./www/assets/')
.replace(/href="\.\.\/assets\//g, 'href="./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/');
await fs.writeFile(indexPath, indexContent); await fs.writeFile(indexPath, indexContent);
console.log('Copied and fixed web files in:', wwwDir); console.log('Copied and fixed web files in:', wwwDir);
// Copy main process files // Copy main process files
console.log('Copying main process files...');
const mainProcessFiles = [ const mainProcessFiles = [
'src/electron/main.js', ['src/electron/main.js', 'main.js'],
['src/electron/preload.js', 'preload.js']
]; ];
for (const file of mainProcessFiles) { for (const [src, dest] of mainProcessFiles) {
const destPath = path.join(distElectronDir, path.basename(file)); const destPath = path.join(distElectronDir, dest);
await fs.copy(file, destPath); 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 devPackageJson = require('../package.json');
const prodPackageJson = { const prodPackageJson = {
name: devPackageJson.name, name: devPackageJson.name,
@ -72,26 +59,26 @@ async function main() {
{ spaces: 2 } { spaces: 2 }
); );
// Verify the structure // Verify the build
console.log('\nVerifying build structure:'); console.log('\nVerifying build structure:');
const printDir = async (dir, prefix = '') => { const files = await fs.readdir(distElectronDir);
const items = await fs.readdir(dir); console.log('Files in dist-electron:', files);
for (const item of items) {
const fullPath = path.join(dir, item); if (!files.includes('main.js')) {
const stat = await fs.stat(fullPath); throw new Error('main.js not found in build directory');
console.log(`${prefix}${item}${stat.isDirectory() ? '/' : ''}`); }
if (stat.isDirectory()) { if (!files.includes('preload.js')) {
await printDir(fullPath, `${prefix} `); 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');
await printDir(distElectronDir); }
console.log('\nBuild completed successfully!'); console.log('Build completed successfully!');
} catch (error) { } catch (error) {
console.error('Build failed:', error); console.error('Build failed:', error);
process.exit(1); 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 path = require("path");
const fs = require("fs"); const fs = require("fs");
// Check if running in dev mode
const isDev = process.argv.includes('--inspect');
function createWindow() { 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. // Create the browser window.
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 1200, width: 1200,
height: 800, height: 800,
webPreferences: { webPreferences: {
nodeIntegration: true, nodeIntegration: false,
contextIsolation: false, contextIsolation: true,
webSecurity: true, webSecurity: true,
allowRunningInsecureContent: false, allowRunningInsecureContent: false,
preload: path.join(__dirname, 'preload.js')
}, },
}); });
// Disable service worker in Electron // Debug info
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
console.log("Debug Info:"); console.log("Debug Info:");
console.log("Running in dev mode:", isDev);
console.log("App is packaged:", app.isPackaged); console.log("App is packaged:", app.isPackaged);
console.log("Process resource path:", process.resourcesPath); console.log("Process resource path:", process.resourcesPath);
console.log("App path:", appPath); console.log("App path:", app.getAppPath());
console.log("__dirname:", __dirname); console.log("__dirname:", __dirname);
console.log("process.cwd():", process.cwd()); console.log("process.cwd():", process.cwd());
console.log("www path:", path.join(process.resourcesPath, "www"));
console.log( const indexPath = path.join(__dirname, 'www', 'index.html');
"www assets path:", console.log("www path:", path.join(__dirname, 'www'));
path.join(process.resourcesPath, "www", "assets"), console.log("www assets path:", path.join(__dirname, 'www', 'assets'));
);
if (!fs.existsSync(indexPath)) {
// Try both possible www locations console.error(`Index file not found at: ${indexPath}`);
const possiblePaths = [ throw new Error('Index file not found');
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;
}
} }
// 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 // Load the index.html
mainWindow mainWindow
.loadFile(indexPath) .loadFile(indexPath)
.then(() => { .then(() => {
console.log("Successfully loaded index.html"); console.log("Successfully loaded index.html");
// Always open DevTools in packaged app for debugging if (isDev) {
mainWindow.webContents.openDevTools(); mainWindow.webContents.openDevTools();
console.log("DevTools opened - running in dev mode");
}
}) })
.catch((err) => { .catch((err) => {
console.error("Failed to load index.html:", err); console.error("Failed to load index.html:", err);
console.error("Attempted path:", indexPath); 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 // Listen for console messages from the renderer
mainWindow.webContents.on("console-message", (_event, level, message) => { mainWindow.webContents.on("console-message", (_event, level, message) => {
console.log("Renderer Console:", message); console.log("Renderer Console:", message);
}); });
}
// Handle app ready // Add right after creating the BrowserWindow
app.whenReady().then(() => { mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
createWindow(); console.error('Page failed to load:', errorCode, errorDescription);
});
app.on("activate", () => { mainWindow.webContents.on('preload-error', (event, preloadPath, error) => {
if (BrowserWindow.getAllWindows().length === 0) { console.error('Preload script error:', preloadPath, error);
createWindow();
}
}); });
});
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 // Handle all windows closed
app.on("window-all-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 // Handle any errors
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
console.error("Uncaught Exception:", 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", { // Use a more direct path resolution approach
logMessage: (message) => console.log(`[Electron]: ${message}`), 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"; import { register } from "register-service-worker";
// NODE_ENV is "production" by default with "vite build". See https://vitejs.dev/guide/env-and-mode // Only register service worker if explicitly enabled and in production
if (import.meta.env.NODE_ENV === "production") { if (process.env.VITE_PWA_ENABLED === 'true' && process.env.NODE_ENV === "production") {
register("/sw_scripts-combined.js", { register(`${process.env.BASE_URL}sw.js`, {
ready() { ready() {
console.log( console.log("Service worker is active.");
"App is being served from cache by a service worker.\n" +
"For more details, visit https://goo.gl/AFskqB",
);
}, },
registered() { registered() {
console.log("Service worker has been 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."); console.log("New content is available; please refresh.");
}, },
offline() { offline() {
console.log( console.log("No internet connection found. App is running in offline mode.");
"No internet connection found. App is running in offline mode.",
);
}, },
error(error) { error(error) {
console.error("Error during service worker registration:", 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 const initialPath = isElectron
? window.location.pathname.replace("/dist-electron/index.html", "/") ? window.location.pathname.split('/dist-electron/www/')[1] || '/'
: window.location.pathname; : window.location.pathname;
const history = isElectron const history = isElectron

37
src/vite.config.utils.js

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

114
vite.config.mjs

@ -4,10 +4,15 @@ import vue from "@vitejs/plugin-vue";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { loadAppConfig } from "./vite.config.utils"; import { loadAppConfig } from "./vite.config.utils";
import path from "path"; import path from "path";
import { fileURLToPath } from 'url';
// Load environment variables from .env file // Load environment variables from .env file
dotenv.config(); dotenv.config();
// Get dirname in ESM context
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load application configuration // Load application configuration
const appConfig = loadAppConfig(); const appConfig = loadAppConfig();
@ -15,48 +20,101 @@ export default defineConfig(({ mode }) => {
const isElectron = mode === "electron"; const isElectron = mode === "electron";
const isCapacitor = mode === "capacitor"; const isCapacitor = mode === "capacitor";
// Set output directory based on build mode // Completely disable PWA features for electron builds
const outDir = isElectron if (isElectron) {
? "dist-electron/www" process.env.VITE_PWA_ENABLED = 'false';
: isCapacitor }
? "dist-capacitor"
: "dist";
return { return {
base: isElectron ? "./" : "/", base: isElectron ? "./" : "/",
server: { server: {
port: process.env.VITE_PORT || 8080, port: process.env.VITE_PORT || 8080,
fs: {
strict: false
}
}, },
build: { build: {
outDir, outDir: isElectron ? "dist-electron" : "dist",
assetsDir: 'assets',
rollupOptions: { rollupOptions: {
...(isElectron && { external: ['electron', 'path'],
input: { input: {
index: path.resolve(__dirname, 'index.html') main: path.resolve(__dirname, 'index.html')
}, },
output: { output: {
dir: outDir, manualChunks(id) {
format: 'cjs', if (isElectron && (
entryFileNames: 'assets/[name].[hash].js', id.includes('registerServiceWorker') ||
chunkFileNames: 'assets/[name].[hash].js', id.includes('register-service-worker') ||
assetFileNames: 'assets/[name].[hash][extname]' 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: [ plugins: [
vue(), vue(),
...(isElectron {
? [] name: 'remove-sw-imports',
: [ transform(code, id) {
VitePWA({ if (isElectron) {
...appConfig.pwaConfig, if (
disable: isElectron 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: { resolve: {
alias: appConfig.aliasConfig, alias: appConfig.aliasConfig
}, },
optimizeDeps: {
exclude: isElectron ? [
'register-service-worker',
'workbox-window',
'web-push',
'serviceworker-webpack-plugin'
] : []
}
}; };
}); });

Loading…
Cancel
Save