# Add a Single-Instance Lock to an Electron App

Yes. An agent adds requestSingleInstanceLock to your Electron main process so a second launch focuses the window, with a unit test, lint and a smoke run.

[Run this on Rush](https://cloud.getrush.ai/run/add-single-instance-lock/electron/), in a Project that has your repository.

## What the agent does

The agent clones the app into its own cloud sandbox and reads the main process before it changes startup. In an app built from electron-react-boilerplate/electron-react-boilerplate, `src/main/main.ts` is the whole main process: an `ipcMain.on('ipc-example')` handler, an `isDebug` flag from `NODE_ENV` or `DEBUG_PROD`, a `createWindow` that builds a 1024 by 728 `BrowserWindow` with the preload at `../preload/preload.js`, shows it on `ready-to-show` unless `START_MINIMIZED` is set, wires `MenuBuilder`, sends external links to the browser through `setWindowOpenHandler`, and loads `resolveHtmlPath('index.html')`. Startup is `app.whenReady().then(createWindow)` followed by `startAutoUpdates()` and an `activate` handler for macOS, and `window-all-closed` quits everywhere but Darwin. Nothing calls `app.requestSingleInstanceLock()`, so a second launch opens a second window with its own `electron-log` file and its own updater check.

The repository already shows how it likes main-process concerns kept: `src/main/updates.ts` exports one function, `startAutoUpdates`, guards itself with a `started` flag, and is tested in `src/__tests__/updates.test.ts` under a `/** @jest-environment node */` docblock with `jest.mock('electron', ...)`, `jest.mock('electron-updater', ...)` and `jest.resetModules()` in `beforeEach`. The agent follows that shape with a `src/main/single-instance.ts` that exports `acquireSingleInstanceLock(getWindow)`: it calls `app.requestSingleInstanceLock()`, and when the lock is held elsewhere it logs through `electron-log` and calls `app.quit()` and returns `false`; otherwise it registers `app.on('second-instance', ...)`, which restores the window if `isMinimized()` and calls `focus()`, and returns `true`. In `main.ts` the call sits before `app.whenReady()`, so a losing instance never creates a window or starts the updater, and the handler reads `mainWindow` through a getter so it sees the window created later.

The test mirrors `updates.test.ts`: mock `electron` with an `app` whose `requestSingleInstanceLock` is a `jest.fn()` and whose `on` records listeners, mock `electron-log`, and assert three things: that a refused lock calls `app.quit` and returns `false`; that a granted lock registers a `second-instance` listener; and that firing that listener on a minimized window calls `restore()` then `focus()`. `jest.config.js` keeps `jsdom` as the default environment with `check-build-exists.ts` in `setupFiles`, so the docblock override to `node` is what makes the mocked `electron` import work, as it does for the updates test.

Verification follows `.github/workflows/test.yml`, which on Linux, macOS and Windows runs `npm ci`, `npm run package`, `npm run lint`, `npm exec tsc -- --noEmit`, `npm test`, and then `xvfb-run --auto-servernum npm run test:smoke` on Linux. `.erb/scripts/smoke-test.cjs` starts the app with `npm start -- --remoteDebuggingPort 9335`, waits up to 120 seconds for a page at `http://127.0.0.1:9335/json/list`, and fails if `npm start` exits first. The agent runs lint, the type check, the Jest suite and the smoke test under `xvfb-run`, then launches a second copy of the built app while the first is running and checks that the second process exits and the first window receives focus, which the transcript records.

## Start it with one API call

`POST https://api.prix.dev/o/{org}/sessions`

```json
{
  "agent": "claude",
  "input": "Make this Electron app single-instance. Read src/main/main.ts, src/main/updates.ts, src/__tests__/updates.test.ts, jest.config.js, electron.vite.config.ts, package.json and .github/workflows/test.yml. Add src/main/single-instance.ts exporting a function that calls app.requestSingleInstanceLock, quits and returns false when the lock is held by another process, and otherwise registers a second-instance handler that restores a minimized main window and focuses it. Call it in main.ts before app.whenReady so a losing instance creates no window and starts no updater. Write a Jest test in src/__tests__/ in the style of updates.test.ts, with the node environment docblock and mocked electron and electron-log modules, covering the refused lock, the registered handler and the restore-then-focus behaviour. Run npm run lint, npm exec tsc -- --noEmit, npm test, and xvfb-run npm run test:smoke; then start the app twice under xvfb and confirm the second process exits with the first still running. Open a pull request with the test and command output in its description.",
  "resources": [
    {
      "url": "https://github.com/your-org/your-app"
    }
  ]
}
```

1. **Get an API key.** In the console, open Settings, then API keys, and create a key for your org. Send it in the Authorization header as a Bearer token.
2. **Send the request.** POST the body in request.json to https://api.prix.dev/o/{org}/sessions, with your repository as the resources URL. The run starts in its own cloud sandbox.
3. **Read what came back.** Follow the run on its live stream or wait for the run.completed webhook, then read the result and its trajectory.

## What comes back

What comes back is a pull request on its own branch adding `src/main/single-instance.ts` and `src/__tests__/single-instance.test.ts` and changing `src/main/main.ts` by a few lines at startup. The description shows the Jest summary, the lint and type-check results, the smoke test's output, and the lines from the second launch showing the second process exiting while the first stayed up.

The session read names the repository and the pull request, and the transcript at `GET /o/{org}/sessions/{id}/messages` keeps every command with its output, including the `xvfb-run` session and both launches. The workflow on the pull request repeats the checks on all three operating systems; a check that fails there ends the run `needs_review` rather than as a merged claim.

Before merging, a reviewer reads the order of calls in `main.ts` to confirm the lock is requested before `whenReady`, checks that the `second-instance` handler does nothing when no window exists yet, and decides whether a second launch with a file argument should also be forwarded to the first instance, which the handler's `argv` parameter makes possible in a follow-up on the same session.

## Run it on every GitHub issue labelled agent

In the console, add an automation to the Project with this prompt and a `github` trigger on `issues.labeled`, filtered to the `agent` label. When a maintainer labels an issue such as "clicking the dock icon twice opens two windows", the trigger puts the issue after the prompt and the run reads it as the request; only labels applied by a repository owner, member or collaborator fire it, and the automation starts at most `max_runs_per_hour` runs in an hour.

From a terminal, the same body goes to `POST /o/{org}/sessions` with the repository in `resources` and a `request_id` made from the issue number, so a retried call returns the run already started. Give the key a webhook URL and Rush POSTs `run.completed` with `data.session_url` when the turn ends, which a handler can use to comment the pull request link on the issue.

## Why run it on Rush?

A single-instance lock is a few lines that are easy to put in the wrong place: after `whenReady`, where the losing instance has already started an updater, or without a window getter, where the handler focuses nothing. The agent runs the repository's own lint, type check, Jest suite and smoke test in its sandbox, then actually launches the app twice under a virtual display, and keeps the output, so the pull request shows the behaviour rather than describing it.

You start it from a terminal and come back to a pull request. The sandbox parks when the turn ends and wakes for a follow-up, so forwarding a file argument from the second launch to the first window is one more message on the same session, with the same checkout and built `release/app/dist`.

## Frequently asked questions

### Why request the lock before app.whenReady?

Because the losing instance should do nothing: no window, no `electron-log` file, no `checkForUpdatesAndNotify` from `startAutoUpdates`. Requesting the lock first and returning early when it is refused keeps the rest of `main.ts` from running at all in the second process.

### How is it tested without a real second process?

The same way `updates.test.ts` tests the updater: the `electron` module is mocked in a `node` Jest environment, `requestSingleInstanceLock` returns `false` or `true` per test, and the `second-instance` listener is captured from the mocked `app.on` and invoked with a fake minimized window. The two-launch check under `xvfb-run` is the real-process complement, recorded in the transcript.

### What about macOS, where a second click goes to activate?

Reopening from the Dock fires `activate`, which `main.ts` already handles by creating a window only when `mainWindow` is null. The lock covers launches that start a new process, such as a second `open -a` or a packaged binary run twice, and the handler focuses the existing window in that case. The pull request notes both paths.

### Does it change the smoke test?

No. `.erb/scripts/smoke-test.cjs` starts one instance and waits for the remote debugging endpoint, which still works. The agent runs it unchanged under `xvfb-run` and adds the second-launch check as a separate step whose output is in the transcript, not as a change to the script.
