AgentSkillsCN

electron-develop

Electron 应用开发模式,尤其是结合 electron-tabs 实现类浏览器标签页的功能。 当您:(1) 构建带有标签页的 Electron 应用;(2) 使用 electron-tabs 库;(3) 在 Electron 中实现键盘快捷键;(4) 管理开发服务器进程;(5) 处理在子进程中出现的 nodenv/anyenv PATH 问题;(6) 使用 pnpm dlx 和 electron-builder 打包 Electron 应用;(7) 在多个 Electron 应用之间共享模块(extraResources 模式);(8) 在打包后的应用中实现动态项目根路径解析。

SKILL.md
--- frontmatter
name: electron-develop
description: |
  Electron app development patterns, especially with electron-tabs for browser-like tabs.
  Use when: (1) Building Electron apps with tabs, (2) Using electron-tabs library,
  (3) Implementing keyboard shortcuts in Electron, (4) Managing dev server processes,
  (5) Handling nodenv/anyenv PATH issues in spawned processes,
  (6) Packaging Electron apps with pnpm dlx and electron-builder,
  (7) Sharing modules across multiple Electron apps (extraResources pattern),
  (8) Dynamic project root resolution in packaged apps.

Electron Development

Common Pattern: Thin Wrapper App

Electron as thin wrapper around a dev server (e.g., Vite, Docusaurus):

  1. Show splash screen
  2. Spawn dev server as background process
  3. Wait for server ready
  4. Show BrowserWindow pointing to localhost URL
  5. Clean up server on quit

See references/background-process.md for implementation.

Packaging & Build

pnpm dlx + electron-builder (pnpm 10)

pnpm 10 breaks pnpm dlx for Electron. Required flags:

bash
pnpm --config.trustPolicy=accept --config.strictDepBuilds=false dlx \
  --package=electron@36 --package=electron-builder@26 \
  electron-builder --mac -c.electronVersion=36.9.5

Shared Modules: Use extraResources, NOT files glob

json
// WRONG - shared module won't be in the asar
"files": ["main.js", "../../../shared/module/**/*"]

// CORRECT - copies to app's Resources directory
"extraResources": [{ "from": "../../../shared/module", "to": "module" }]

Then resolve dynamically in main.js:

javascript
function getSharedCorePath() {
  if (app.isPackaged) {
    return path.join(process.resourcesPath, 'electron-app-core');
  }
  return path.join(__dirname, '..', '..', '..', 'shared', 'electron-app-core');
}

Dynamic Project Root (Don't Hardcode Paths)

Resolve from app exe location instead of hardcoding worktree paths. See references/packaging.md.

Critical Pitfalls

electron-tabs: Tab Order Bug

getTabs() returns CREATION order, NOT visual order.

javascript
// WRONG
tabs[index].activate();

// CORRECT
tabGroup.getTabByPosition(index).activate(); // 0-indexed

electron-tabs: Required Settings

Must use these settings (less secure, only for trusted content):

javascript
webPreferences: {
  nodeIntegration: true,
  contextIsolation: false,
  webviewTag: true,
}

Keyboard Shortcuts in Webview

Menu accelerators and document.keydown don't work when webview has focus. Use globalShortcut instead:

javascript
globalShortcut.register("CommandOrControl+1", () => {
  win.webContents.send("goto-tab", 0);
});

Pitfall: If using both menu accelerator AND globalShortcut for same key, shortcuts fire twice. Remove menu accelerator.

globalShortcut Conflicts with Other Apps

globalShortcut is system-wide - it intercepts shortcuts even when other Electron apps (e.g., Slack) are focused. Solution: register/unregister based on window focus:

javascript
let shortcutsRegistered = false;

function registerShortcuts() {
  if (shortcutsRegistered) return;
  globalShortcut.register("CommandOrControl+1", handler);
  shortcutsRegistered = true;
}

function unregisterShortcuts() {
  if (!shortcutsRegistered) return;
  globalShortcut.unregister("CommandOrControl+1");
  shortcutsRegistered = false;
}

// In window creation:
win.on("focus", registerShortcuts);
win.on("blur", () => {
  setTimeout(() => {
    if (!BrowserWindow.getFocusedWindow()) unregisterShortcuts();
  }, 100);
});

The setTimeout + focus check prevents unregistering when switching between your own windows.

Critical: Multiple cleanup points required. Focus/blur alone is not enough - shortcuts can remain "stolen" after app closes if cleanup fails. Add these handlers:

javascript
// On macOS, app stays running when all windows close - must unregister
app.on("window-all-closed", () => {
  unregisterShortcuts();
  if (process.platform !== "darwin") app.quit();
});

// Ensure cleanup before quit begins
app.on("before-quit", () => {
  unregisterShortcuts();
});

// Final fallback cleanup
app.on("will-quit", () => {
  globalShortcut.unregisterAll();
});

Without all these cleanup points, shortcuts may remain intercepted even after your app closes, breaking shortcuts in other apps like Slack.

electron-tabs: Menu Reload Resets Tabs

Built-in { role: "reload" } reloads the main window (tabbed-window.html), NOT the active tab's webview. This causes all tabs to reinitialize and navigate back to the default URL.

Solution: Use custom menu items that send IPC to reload the active tab's webview:

javascript
// menu.js - Replace built-in roles with custom handlers
{
  label: "Reload",
  accelerator: "CmdOrCtrl+R",
  click: () => sendToFocusedWindow("menu-reload"),
},
{
  label: "Force Reload",
  accelerator: "CmdOrCtrl+Shift+R",
  click: () => sendToFocusedWindow("menu-force-reload"),
},
javascript
// tabbed-window.html - Reload active tab's webview
ipcRenderer.on("menu-reload", () => {
  const activeTab = tabGroup.getActiveTab();
  if (activeTab && activeTab.webview) {
    activeTab.webview.reload();
  }
});

ipcRenderer.on("menu-force-reload", () => {
  const activeTab = tabGroup.getActiveTab();
  if (activeTab && activeTab.webview) {
    activeTab.webview.reloadIgnoringCache();
  }
});

Shadow DOM Manipulation

Avoid manipulating electron-tabs shadow DOM directly - it breaks the component. CSS ::part() selectors may not work reliably.

macOS Title Bar

Hidden title bar (titleBarStyle: "hiddenInset") requires tab bar padding for traffic lights. Simpler: use standard title bar (no titleBarStyle option).

Open External Links in Default Browser (Cmd+Click)

Electron doesn't open links in the system browser by default. Use setWindowOpenHandler to intercept Cmd+click (which triggers a new-window request) and route external URLs to the default browser via shell.openExternal:

javascript
const { shell } = require('electron');

const devServerOrigin = new URL(config.devServerUrl).origin;
win.webContents.setWindowOpenHandler(({ url }) => {
  try {
    const urlOrigin = new URL(url).origin;
    if (urlOrigin !== devServerOrigin) {
      shell.openExternal(url);
      return { action: 'deny' };
    }
  } catch {
    // Invalid URL - let Electron handle it
  }
  return { action: 'deny' };
});

This compares origins so same-origin links stay in the app while external URLs open in the default browser.

nodenv/anyenv PATH Issues

Spawned processes don't inherit version managers. Source shell profile first. See references/background-process.md.

Detailed References