Skip to main content

2 posts tagged with "Electron"

View all tags

Packaged Electron App Can't Read .env? It Lives in userData

Β· 5 min read

Configuration loads fine in development. After packaging the Electron app with electron-builder and installing it, every process.env.XXX is undefined and the modules that depend on them fail one after another.

Encountered this while building a data-collection tool for a client β€” recording the root cause and the fix.

TL;DR​

After packaging, .env is not in process.cwd() β€” it belongs in app.getPath('userData').

Three steps:

  1. On startup, the main process checks whether userData/.env exists; if not, it copies the bundled .env.example over (seeding)
  2. Store the full path in process.env.ENV_PATH
  3. The config module reads it with dotenv.config({ path: process.env.ENV_PATH || '.env' })

Symptoms​

In development (electron .) everything works. After installing the packaged build (launched from the dock or the start menu), the configuration is empty:

undefined
undefined
TypeError: Cannot read properties of undefined (reading 'xxx')

Extra confusing part: launching the same packaged binary from a terminal inside its own directory sometimes works β€” because process.cwd() happens to be the app directory in that case. Same build, different launch method, different behavior.

Root Cause: process.cwd() Is Unreliable After Packaging​

dotenv.config() without arguments reads process.cwd()/.env. But process.cwd() is "the directory the process was started from", not "the directory the app is installed in" β€” they only coincide during development:

Launch methodprocess.cwd()
Dev: electron . from the project rootproject root βœ“ .env is here
Windows: double-click the exethe exe's directory
macOS: double-click the .app/ (filesystem root)

After packaging, there are exactly two places .env could live, and both fail:

  • asar archives are read-only: electron-builder packs app resources into app.asar, mounted read-only at runtime. Even if you bundle .env inside, users cannot edit it β€” every config change would require a rebuild
  • The install directory is not writable: Program Files and /Applications require elevated permissions to write; a normally-launched app cannot write there

Electron provides a standard directory for "runtime data belonging to this user": app.getPath('userData'). It resolves per platform (macOS ~/Library/Application Support/<app>, Windows %APPDATA%\<app>, Linux ~/.config/<app>), and it is guaranteed to exist, be writable, and survive reinstalls. That is where .env should live.

The Fix: Seed + ENV_PATH​

The change touches two files, about a dozen lines total.

Step 1: seed on startup (electron/main.js, before any window or the Express server starts):

const fs = require('fs');
const path = require('path');
const { app } = require('electron');

// ── .env path setup ──
const userDataPath = app.getPath('userData');
const envPath = path.join(userDataPath, '.env');
// rootDir = the packaged app root; use app.getAppPath() or your project's convention
const envExamplePath = path.join(rootDir, '.env.example');

if (!fs.existsSync(envPath) && fs.existsSync(envExamplePath)) {
fs.copyFileSync(envExamplePath, envPath);
console.log(`[electron] .env copied to ${envPath}`);
}
process.env.ENV_PATH = envPath;

Logic: if userData has no .env, copy the bundled .env.example (read-only inside asar is fine β€” it is a template), then expose the final path as process.env.ENV_PATH.

Step 2: the config module follows the pointer (src/config.js):

import dotenv from 'dotenv';
dotenv.config({ path: process.env.ENV_PATH || '.env' });

The || fallback keeps non-Electron contexts working (running the same code under plain Node), so development behavior is unchanged.

Step 3: config changes without reinstalling. To change an environment, edit the .env inside the userData directory and fully quit and relaunch the app β€” dotenv reads the file once, at process start; edits do not apply to a running process.

This "userData holds runtime state" pattern carries more than env files: login sessions, caches, and logs all belong there. The same tool stores its cookie-based login state this way β€” that investigation is covered in Puppeteer Blocked by Anti-Bot? From Chrome CDP to an Electron Alternative.

Watch out

process.env.ENV_PATH must be set before the config module is imported β€” dotenv.config() reads the file at call time, not later. Do the seeding at the very top of the main-process entry file, not inside an app.whenReady() callback: if any module earlier in the import chain calls require('dotenv').config(), the path will not be there yet.

FAQ​

How do I use app.getPath('userdata') for config files?​

Call it in the main process before any window or server starts: app.getPath('userData') returns the per-user data directory β€” ~/Library/Application Support/<app> on macOS, %APPDATA%\<app> on Windows, ~/.config/<app> on Linux. Build your .env path with path.join(userDataPath, '.env') and it will be writable in every packaged build.

Why can't my packaged Electron app read .env?​

Because dotenv defaults to process.cwd(), which is the launcher's directory after installation β€” on macOS a double-click even sets it to / β€” while your app resources sit inside a read-only asar archive. The fix: seed .env into userData on startup and hand that exact path to dotenv.config({ path }).

Should .env.example be bundled into the app?​

Yes. Ship it as the first-run template inside the asar (read-only is fine for a template). On startup, if userData/.env does not exist, copy the example over. Users can then edit the copy directly to change configuration without reinstalling anything.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Puppeteer Blocked by Anti-Bot? From Chrome CDP to Electron Alternative

Β· 7 min read

Encountered this issue while building a data collection tool for a client. Here's the full troubleshooting journey from Puppeteer to Electron.

TL;DR​

Puppeteer stealth plugin cannot bypass advanced CAPTCHA-based anti-bot systems. Switching to Chrome CDP remote debugging led to WSL2 network isolation and Chrome single-instance issues. The final solution: use Electron BrowserWindow to load the target site, let the user log in manually, and automatically extract cookies via session.cookies.get() β€” eliminating anti-bot detection and cross-platform problems entirely.

Scenario 1: Puppeteer Blocked by Anti-Bot​

Problem​

Using puppeteer-extra + puppeteer-extra-plugin-stealth for automated login, the browser triggers CAPTCHA interception immediately after launch. Even after passing the CAPTCHA, subsequent pages detect the automation environment and force an exit.

import puppeteer from 'puppeteer-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';

puppeteer.use(StealthPlugin());

const browser = await puppeteer.launch({
headless: false,
args: [
'--disable-blink-features=AutomationControlled',
'--no-sandbox',
'--disable-infobars',
],
});

const page = await browser.newPage();
await page.goto('https://target-site.com/login');
// Anti-bot CAPTCHA system detects automation, page is blocked

Root Cause​

Advanced anti-bot CAPTCHA systems don't just check basic fingerprints like navigator.webdriver. They detect automation through multiple dimensions: Chromium build signatures, Canvas/WebGL rendering differences, mouse trajectory patterns, and even DevTools Protocol call stacks. The stealth plugin fixes known fingerprint leaks but cannot eliminate the fundamental differences between Puppeteer's Chromium and a real Chrome browser.

After 8 rounds of debugging (removing timeouts, listening for disconnect events, investigating environment differences, fully configuring stealth plugin, etc.), we confirmed that no configuration could bypass the detection.

Solution​

Abandoned Puppeteer automated login entirely. Switched to Chrome DevTools Protocol (CDP) to connect to the user's real browser and extract cookies.

Scenario 2: WSL2 Cannot Connect to Windows Chrome CDP​

Problem​

Running a Node.js script in WSL2, connecting to Windows Chrome's debugging port via chrome-remote-interface results in a connection timeout:

import CDP from 'chrome-remote-interface';

const client = await CDP({
host: 'localhost',
port: 9222,
});
// Error: connect ECONNREFUSED 127.0.0.1:9222

Chrome launched on Windows with:

chrome.exe --remote-debugging-port=9222

Running curl localhost:9222/json in Windows PowerShell works fine, but WSL2 cannot connect.

Root Cause​

Chrome's --remote-debugging-port=9222 binds to 127.0.0.1 by default β€” the Windows loopback address. WSL2 and Windows have separate network stacks. localhost inside WSL2 points to the Linux loopback address, not Windows. So accessing localhost:9222 from WSL2 actually hits Linux's port 9222, not Windows Chrome.

Solution​

Add --remote-debugging-address=0.0.0.0 when launching Chrome to make CDP listen on all network interfaces:

chrome.exe --remote-debugging-port=9222 --remote-debugging-address=0.0.0.0

Or configure port forwarding using Windows netsh:

netsh interface portproxy add v4tov4 listenport=9222 listenaddress=0.0.0.0 connectport=9222 connectaddress=127.0.0.1

Important

--remote-debugging-address=0.0.0.0 exposes the CDP port to the local network, which is a security risk. Only use this in internal development environments. For production, always combine with firewall rules to restrict access.

Scenario 3: Chrome Ignores --remote-debugging-port​

Problem​

Chrome is already running. Launching Chrome again with --remote-debugging-port=9222 silently ignores the flag. Chrome simply opens a new tab in the existing window, and the CDP port is not opened:

# Chrome already running
chrome.exe --remote-debugging-port=9222
# No error, but port 9222 is not listening

Root Cause​

Chrome is designed as a single-instance application. When it detects an existing Chrome process, the newly launched Chrome forwards the URL (and other command-line arguments) to the existing process and exits. Flags like --remote-debugging-port only take effect when the process is first created β€” the existing process never dynamically loads these parameters.

Solution​

Close all Chrome processes first, then relaunch with the flag:

# Windows
taskkill /F /IM chrome.exe
chrome.exe --remote-debugging-port=9222

# macOS
pkill -f "Google Chrome"
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222

You can also use --user-data-dir to specify a separate profile directory, avoiding conflicts with your daily Chrome:

chrome.exe --remote-debugging-port=9222 --user-data-dir="C:\chrome-debug-profile"

The three scenarios above show that relying on an external Chrome process for cookie extraction is too fragile under WSL2 + anti-bot detection + process management constraints. The final solution: replace external Chrome with Electron's BrowserWindow.

Why Electron Works​

  1. No anti-bot detection: Electron's built-in Chromium shares the same rendering engine as Chrome. Anti-bot systems do not flag it as an automated environment.
  2. No external Chrome dependency: No need to manage Chrome processes, CDP ports, or profile directories.
  3. No WSL2 networking issues: Electron runs natively on the target OS β€” no cross-OS network isolation problems.
  4. No single-instance conflicts: Electron creates its own BrowserWindow β€” no conflict with the user's daily Chrome.

Complete Implementation​

Open login window and poll for cookies:

import { BrowserWindow } from 'electron';

let cookieWindow = null;

function openLoginWindow() {
cookieWindow = new BrowserWindow({
width: 1000,
height: 700,
title: 'Login to Target Platform',
webPreferences: {
// Key: use isolated session, separate from main window
partition: 'cookie-login',
contextIsolation: true,
nodeIntegration: false,
},
});

cookieWindow.loadURL('https://target-site.com/login');

// Poll for target cookie
const interval = setInterval(async () => {
const cookies = await cookieWindow.webContents.session.cookies.get({
domain: '.target-site.com',
});

const sessionCookie = cookies.find((c) => c.name === 'session_token');
if (sessionCookie) {
clearInterval(interval);

// Build full cookie string
const cookieStr = cookies
.map((c) => c.name + '=' + c.value)
.join('; ');

// Write to environment variable
process.env.SESSION_COOKIE = cookieStr;
updateEnvFile('SESSION_COOKIE', cookieStr);

cookieWindow.close();
}
}, 2000);

cookieWindow.on('closed', () => {
clearInterval(interval);
cookieWindow = null;
});
}

Preload script exposing IPC interface:

import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('electronAPI', {
extractCookie: () => ipcRenderer.invoke('extract-cookie'),
onCookieExtracted: (callback) =>
ipcRenderer.on('cookie-extracted', (_, data) => callback(data)),
});

Renderer process usage:

// Detect Electron environment
if (window.electronAPI) {
document.getElementById('extractBtn').addEventListener('click', () => {
window.electronAPI.extractCookie();
});

window.electronAPI.onCookieExtracted((data) => {
console.log('Cookie extracted:', data);
});
}

Important

  • partition: 'cookie-login' creates an isolated session. The login window's cookies won't pollute the main window. If you need to share login state, remove the partition option or use the same partition name.
  • A 2-second polling interval balances UX and performance. Don't replace setInterval with a while + await loop β€” that would block the renderer process.
  • session.cookies.get() only returns cookies from the current session. You cannot read cookies across partitions.

FAQ​

What to do when Puppeteer stealth plugin is still detected by anti-bot?​

The stealth plugin only bypasses basic fingerprint checks (like navigator.webdriver). Advanced anti-bot systems detect automation through browser behavior and low-level API characteristics, including Chromium build signatures, Canvas rendering differences, and mouse trajectory patterns. The stealth plugin cannot fully cover all detection vectors. Use Electron BrowserWindow to load the target site and extract cookies via session.cookies.get() after the user logs in manually.

Why is Chrome --remote-debugging-port not working?​

Chrome uses a single-process architecture. When a Chrome process is already running, the --remote-debugging-port flag is silently ignored. The newly launched Chrome simply opens a new tab in the existing instance without enabling the debugging port. You need to close all Chrome processes first using taskkill /F /IM chrome.exe (Windows) or pkill -f "Google Chrome" (macOS), then relaunch with the flag. Alternatively, use --user-data-dir to specify a separate profile directory to avoid conflicts.

How to connect to Chrome debugging port from WSL2?​

Chrome's --remote-debugging-port binds to Windows 127.0.0.1 by default, but WSL2 has its own network stack where localhost points to the Linux loopback address. Two solutions: first, add --remote-debugging-address=0.0.0.0 when launching Chrome to make CDP listen on all interfaces; second, configure port forwarding on Windows using netsh interface portproxy to forward WSL2 requests to the Windows CDP port.


Need help with data collection or automation tools?

Contact Us