From 8b1c6f6181df693ce55818c6cbf8aeb0931e6abe Mon Sep 17 00:00:00 2001 From: Reagan Hsu Date: Tue, 5 May 2026 17:12:02 -0700 Subject: [PATCH 1/3] when I start a new browser use desktop instance, I shouldnt get a "window already opened error". also, when I need to update and double click a new dmg or something if I install a separate one, then it should auto-update current window. --- app/src/main/index.ts | 134 +++++++++++++++--- app/src/main/startup/singleInstance.ts | 90 ++++++++++++ app/tests/unit/startup/singleInstance.test.ts | 63 ++++++++ 3 files changed, 271 insertions(+), 16 deletions(-) create mode 100644 app/src/main/startup/singleInstance.ts create mode 100644 app/tests/unit/startup/singleInstance.test.ts diff --git a/app/src/main/index.ts b/app/src/main/index.ts index 6bdd635d..a35420c5 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -8,6 +8,7 @@ */ import { config as loadDotEnv } from 'dotenv'; +import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -16,8 +17,14 @@ import path from 'node:path'; // dev-time fallback. loadDotEnv({ path: path.resolve(__dirname, '..', '..', '.env') }); -import { app, BrowserWindow, crashReporter, globalShortcut, ipcMain, Menu, MenuItemConstructorOptions, nativeImage, shell } from 'electron'; +import { app, BrowserWindow, crashReporter, globalShortcut, ipcMain, Menu, MenuItemConstructorOptions, nativeImage, shell, type Event } from 'electron'; import { mergeChromiumFeature } from './startup/chromiumFeatures'; +import { + createSingleInstanceLaunchData, + parseSingleInstanceLaunchData, + shouldHandoffToNewerInstance, + type SingleInstanceLaunchData, +} from './startup/singleInstance'; if (process.platform === 'linux') { app.commandLine.appendSwitch( @@ -42,22 +49,24 @@ crashReporter.start({ }); // Enforce a single running instance. Launching a second copy would race on -// the sessions SQLite db, the .vite dev cache, and the user-data dir — and -// most commonly just confuses the user. When the second instance tries to -// start, surface the existing window instead. -if (!app.requestSingleInstanceLock()) { - app.quit(); - throw new Error('another instance is already running'); -} -app.on('second-instance', () => { - const windows = BrowserWindow.getAllWindows(); - const main = windows.find((w) => !w.isDestroyed() && !w.isMinimized()) ?? windows[0]; - if (main) { - if (main.isMinimized()) main.restore(); - main.show(); - main.focus(); - } +// the sessions SQLite db, the .vite dev cache, and the user-data dir. When the +// second instance is the same version, surface the existing window. When it is +// a newer installed binary, quit this process and hand off to that binary so a +// manual install can complete without users first finding and quitting the old +// process. +const singleInstanceLaunchData = createSingleInstanceLaunchData({ + version: app.getVersion(), + execPath: process.execPath, + argv: process.argv, + cwd: process.cwd(), + appPath: app.getAppPath(), + pid: process.pid, + platform: process.platform, }); +if (!app.requestSingleInstanceLock(singleInstanceLaunchData)) { + app.exit(0); +} +app.on('second-instance', handleSecondInstanceLaunch); // Populate the native About dialog (macOS + Linux) instead of showing the // default Electron panel with no branding. @@ -176,6 +185,7 @@ if (started) { let shellWindow: BrowserWindow | null = null; let onboardingWindow: BrowserWindow | null = null; let isQuitting = false; +let pendingNewerInstanceLaunch: SingleInstanceLaunchData | null = null; const sessionManager = new SessionManager(path.join(app.getPath('userData'), 'sessions.db')); // Bootstrap the editable helpers harness — writes stock helpers.js + TOOLS.json @@ -203,6 +213,98 @@ const accountStore = new AccountStore(); const whatsAppAdapter = new WhatsAppAdapter(); const channelRouter = new ChannelRouter(sessionManager, whatsAppAdapter); +// --------------------------------------------------------------------------- +// Single-instance handoff +// --------------------------------------------------------------------------- +function handleSecondInstanceLaunch(_event: Event, _argv: string[], _workingDirectory: string, additionalData: unknown): void { + const incoming = parseSingleInstanceLaunchData(additionalData); + const incomingVersion = incoming ? incoming.version : null; + if (shouldHandoffToNewerInstance(app.getVersion(), incoming)) { + scheduleNewerInstanceHandoff(incoming); + return; + } + + mainLogger.info('main.singleInstance.focusExisting', { + currentVersion: app.getVersion(), + incomingVersion, + }); + showAndFocusPrimaryWindow(); +} + +function showAndFocusPrimaryWindow(): void { + const windows = BrowserWindow.getAllWindows().filter((win) => !win.isDestroyed()); + const preferred = [shellWindow, onboardingWindow, BrowserWindow.getFocusedWindow(), ...windows] + .find((win): win is BrowserWindow => Boolean(win && !win.isDestroyed())); + + if (preferred) { + if (preferred.isMinimized()) preferred.restore(); + preferred.show(); + preferred.focus(); + return; + } + + setTimeout(() => { + if (BrowserWindow.getAllWindows().some((win) => !win.isDestroyed())) return; + if (accountStore.isOnboardingComplete()) { + openShellAndWire(); + return; + } + onboardingWindow = createOnboardingWindow(); + onboardingWindow.on('closed', () => { + mainLogger.info('main.onboardingWindow.closed'); + onboardingWindow = null; + }); + }, 100); +} + +function scheduleNewerInstanceHandoff(incoming: SingleInstanceLaunchData): void { + if (pendingNewerInstanceLaunch) { + mainLogger.info('main.singleInstance.newerLaunchAlreadyPending', { + currentVersion: app.getVersion(), + incomingVersion: incoming.version, + execPath: incoming.execPath, + }); + return; + } + + pendingNewerInstanceLaunch = incoming; + mainLogger.info('main.singleInstance.newerLaunch', { + currentVersion: app.getVersion(), + incomingVersion: incoming.version, + execPath: incoming.execPath, + appPath: incoming.appPath, + }); + + app.once('will-quit', () => { + const launch = pendingNewerInstanceLaunch; + if (!launch) return; + app.releaseSingleInstanceLock(); + launchNewerInstance(launch); + }); + + isQuitting = true; + app.quit(); +} + +function launchNewerInstance(incoming: SingleInstanceLaunchData): void { + const args = incoming.argv + .slice(1) + .filter((arg) => !arg.startsWith('--original-process-start-time=')); + try { + const child = spawn(incoming.execPath, args, { + cwd: incoming.cwd, + detached: true, + stdio: 'ignore', + }); + child.unref(); + } catch (err) { + mainLogger.warn('main.singleInstance.newerLaunchFailed', { + error: (err as Error)?.message ?? String(err), + execPath: incoming.execPath, + }); + } +} + // --------------------------------------------------------------------------- // Shell window factory // --------------------------------------------------------------------------- diff --git a/app/src/main/startup/singleInstance.ts b/app/src/main/startup/singleInstance.ts new file mode 100644 index 00000000..52e31abc --- /dev/null +++ b/app/src/main/startup/singleInstance.ts @@ -0,0 +1,90 @@ +export type SingleInstanceLaunchData = { + version: string; + execPath: string; + argv: string[]; + cwd: string; + appPath: string; + pid: number; + platform: NodeJS.Platform; +}; + +export function createSingleInstanceLaunchData(input: { + version: string; + execPath: string; + argv: string[]; + cwd: string; + appPath: string; + pid: number; + platform: NodeJS.Platform; +}): SingleInstanceLaunchData { + return { + version: input.version, + execPath: input.execPath, + argv: input.argv, + cwd: input.cwd, + appPath: input.appPath, + pid: input.pid, + platform: input.platform, + }; +} + +export function parseSingleInstanceLaunchData(value: unknown): SingleInstanceLaunchData | null { + if (!value || typeof value !== 'object') return null; + const record = value as Record; + if ( + typeof record.version !== 'string' + || typeof record.execPath !== 'string' + || !Array.isArray(record.argv) + || !record.argv.every((arg) => typeof arg === 'string') + || typeof record.cwd !== 'string' + || typeof record.appPath !== 'string' + || typeof record.pid !== 'number' + || typeof record.platform !== 'string' + ) { + return null; + } + + return { + version: record.version, + execPath: record.execPath, + argv: record.argv, + cwd: record.cwd, + appPath: record.appPath, + pid: record.pid, + platform: record.platform as NodeJS.Platform, + }; +} + +export function compareAppVersions(left: string, right: string): number { + const leftVersion = parseVersion(left); + const rightVersion = parseVersion(right); + const length = Math.max(leftVersion.parts.length, rightVersion.parts.length); + for (let index = 0; index < length; index += 1) { + const leftPart = leftVersion.parts[index] ?? 0; + const rightPart = rightVersion.parts[index] ?? 0; + if (leftPart > rightPart) return 1; + if (leftPart < rightPart) return -1; + } + if (leftVersion.prerelease && !rightVersion.prerelease) return -1; + if (!leftVersion.prerelease && rightVersion.prerelease) return 1; + if (leftVersion.prerelease && rightVersion.prerelease) { + return leftVersion.prerelease.localeCompare(rightVersion.prerelease); + } + return 0; +} + +export function shouldHandoffToNewerInstance(currentVersion: string, incoming: SingleInstanceLaunchData | null): incoming is SingleInstanceLaunchData { + return Boolean(incoming?.execPath && compareAppVersions(incoming.version, currentVersion) > 0); +} + +function parseVersion(version: string): { parts: number[]; prerelease: string | null } { + const [main, prerelease = null] = version + .trim() + .replace(/^v/i, '') + .split('-', 2); + const parts = main + .split('.') + .map((part) => Number.parseInt(part, 10)) + .filter((part) => Number.isFinite(part)); + return { parts, prerelease }; +} diff --git a/app/tests/unit/startup/singleInstance.test.ts b/app/tests/unit/startup/singleInstance.test.ts new file mode 100644 index 00000000..98d40ea2 --- /dev/null +++ b/app/tests/unit/startup/singleInstance.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { + compareAppVersions, + createSingleInstanceLaunchData, + parseSingleInstanceLaunchData, + shouldHandoffToNewerInstance, +} from '../../../src/main/startup/singleInstance'; + +describe('single instance startup helpers', () => { + it('round-trips launch metadata passed through Electron additionalData', () => { + const data = createSingleInstanceLaunchData({ + version: '0.0.31', + execPath: '/Applications/Browser Use.app/Contents/MacOS/Browser Use', + argv: ['Browser Use', '--flag=value'], + cwd: '/tmp', + appPath: '/Applications/Browser Use.app/Contents/Resources/app.asar', + pid: 1234, + platform: 'darwin', + }); + + expect(parseSingleInstanceLaunchData(data)).toEqual(data); + }); + + it('rejects malformed launch metadata', () => { + expect(parseSingleInstanceLaunchData(null)).toBeNull(); + expect(parseSingleInstanceLaunchData({ version: '0.0.31' })).toBeNull(); + expect(parseSingleInstanceLaunchData({ + version: '0.0.31', + execPath: '/app', + argv: ['Browser Use', 42], + cwd: '/tmp', + appPath: '/app/resources', + pid: 1234, + platform: 'darwin', + })).toBeNull(); + }); + + it('compares app versions with v prefixes and patch numbers', () => { + expect(compareAppVersions('0.0.31', '0.0.30')).toBe(1); + expect(compareAppVersions('v0.0.30', '0.0.30')).toBe(0); + expect(compareAppVersions('0.1.0', '0.0.99')).toBe(1); + expect(compareAppVersions('0.0.29', '0.0.30')).toBe(-1); + expect(compareAppVersions('0.0.30-beta.1', '0.0.30')).toBe(-1); + expect(compareAppVersions('0.0.31-beta.1', '0.0.30')).toBe(1); + }); + + it('only hands off when the second launch is newer', () => { + const incoming = createSingleInstanceLaunchData({ + version: '0.0.31', + execPath: '/new/Browser Use', + argv: ['/new/Browser Use'], + cwd: '/tmp', + appPath: '/new/resources', + pid: 1234, + platform: 'darwin', + }); + + expect(shouldHandoffToNewerInstance('0.0.30', incoming)).toBe(true); + expect(shouldHandoffToNewerInstance('0.0.31', incoming)).toBe(false); + expect(shouldHandoffToNewerInstance('0.0.32', incoming)).toBe(false); + expect(shouldHandoffToNewerInstance('0.0.30', null)).toBe(false); + }); +}); From ae5e2a140a9fa2644e872dd0575e89f28d2505f8 Mon Sep 17 00:00:00 2001 From: Reagan Hsu Date: Wed, 6 May 2026 01:00:56 -0700 Subject: [PATCH 2/3] fix prerelease path --- app/src/main/startup/singleInstance.ts | 50 +++++++++++++++++-- app/tests/unit/startup/singleInstance.test.ts | 5 ++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/app/src/main/startup/singleInstance.ts b/app/src/main/startup/singleInstance.ts index 52e31abc..b18246f0 100644 --- a/app/src/main/startup/singleInstance.ts +++ b/app/src/main/startup/singleInstance.ts @@ -68,7 +68,7 @@ export function compareAppVersions(left: string, right: string): number { if (leftVersion.prerelease && !rightVersion.prerelease) return -1; if (!leftVersion.prerelease && rightVersion.prerelease) return 1; if (leftVersion.prerelease && rightVersion.prerelease) { - return leftVersion.prerelease.localeCompare(rightVersion.prerelease); + return comparePrereleaseVersions(leftVersion.prerelease, rightVersion.prerelease); } return 0; } @@ -78,13 +78,57 @@ export function shouldHandoffToNewerInstance(currentVersion: string, incoming: S } function parseVersion(version: string): { parts: number[]; prerelease: string | null } { - const [main, prerelease = null] = version + const [versionWithoutBuild] = version .trim() .replace(/^v/i, '') - .split('-', 2); + .split('+', 1); + const prereleaseSeparator = versionWithoutBuild.indexOf('-'); + const main = prereleaseSeparator === -1 + ? versionWithoutBuild + : versionWithoutBuild.slice(0, prereleaseSeparator); + const prerelease = prereleaseSeparator === -1 + ? null + : versionWithoutBuild.slice(prereleaseSeparator + 1); const parts = main .split('.') .map((part) => Number.parseInt(part, 10)) .filter((part) => Number.isFinite(part)); return { parts, prerelease }; } + +function comparePrereleaseVersions(left: string, right: string): number { + const leftIdentifiers = left.split('.'); + const rightIdentifiers = right.split('.'); + const length = Math.max(leftIdentifiers.length, rightIdentifiers.length); + for (let index = 0; index < length; index += 1) { + const leftIdentifier = leftIdentifiers[index]; + const rightIdentifier = rightIdentifiers[index]; + if (leftIdentifier === undefined) return -1; + if (rightIdentifier === undefined) return 1; + + const result = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier); + if (result !== 0) return result; + } + return 0; +} + +function comparePrereleaseIdentifier(left: string, right: string): number { + const leftIsNumeric = isNumericIdentifier(left); + const rightIsNumeric = isNumericIdentifier(right); + if (leftIsNumeric && rightIsNumeric) { + const leftNumber = BigInt(left); + const rightNumber = BigInt(right); + if (leftNumber > rightNumber) return 1; + if (leftNumber < rightNumber) return -1; + return 0; + } + if (leftIsNumeric && !rightIsNumeric) return -1; + if (!leftIsNumeric && rightIsNumeric) return 1; + if (left > right) return 1; + if (left < right) return -1; + return 0; +} + +function isNumericIdentifier(value: string): boolean { + return /^\d+$/.test(value); +} diff --git a/app/tests/unit/startup/singleInstance.test.ts b/app/tests/unit/startup/singleInstance.test.ts index 98d40ea2..dbe2aaf5 100644 --- a/app/tests/unit/startup/singleInstance.test.ts +++ b/app/tests/unit/startup/singleInstance.test.ts @@ -42,6 +42,11 @@ describe('single instance startup helpers', () => { expect(compareAppVersions('0.0.29', '0.0.30')).toBe(-1); expect(compareAppVersions('0.0.30-beta.1', '0.0.30')).toBe(-1); expect(compareAppVersions('0.0.31-beta.1', '0.0.30')).toBe(1); + expect(compareAppVersions('0.0.30-beta.10', '0.0.30-beta.2')).toBe(1); + expect(compareAppVersions('0.0.30-beta.2', '0.0.30-beta.10')).toBe(-1); + expect(compareAppVersions('0.0.30-alpha.1', '0.0.30-alpha.beta')).toBe(-1); + expect(compareAppVersions('0.0.30-beta.2', '0.0.30-beta')).toBe(1); + expect(compareAppVersions('0.0.30+build.2', '0.0.30+build.1')).toBe(0); }); it('only hands off when the second launch is newer', () => { From d19d34dcd9b5cf085c20eb646260774049c1b604 Mon Sep 17 00:00:00 2001 From: Reagan Hsu Date: Wed, 6 May 2026 01:58:20 -0700 Subject: [PATCH 3/3] Prefer predictable duplicate-launch focus The version-aware handoff depended on a newer app process reliably reaching Electron's second-instance path, which is not guaranteed for normal macOS app launches. Keep duplicate launches simple: the lock loser exits, and the primary instance restores or recreates the appropriate window. Constraint: macOS may activate the existing app instead of starting a second packaged app process Rejected: Newer-binary handoff | relies on launch behavior we cannot prove with unit tests Confidence: high Scope-risk: narrow Directive: Keep duplicate-launch handling version-agnostic unless packaged two-version smoke tests cover takeover behavior Tested: npm run typecheck Tested: npm run lint Tested: npm run test --- app/src/main/index.ts | 89 +----------- app/src/main/startup/singleInstance.ts | 134 ------------------ app/tests/unit/startup/singleInstance.test.ts | 68 --------- 3 files changed, 6 insertions(+), 285 deletions(-) delete mode 100644 app/src/main/startup/singleInstance.ts delete mode 100644 app/tests/unit/startup/singleInstance.test.ts diff --git a/app/src/main/index.ts b/app/src/main/index.ts index 61c595cf..bfa37ad7 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -8,7 +8,6 @@ */ import { config as loadDotEnv } from 'dotenv'; -import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -17,14 +16,8 @@ import path from 'node:path'; // dev-time fallback. loadDotEnv({ path: path.resolve(__dirname, '..', '..', '.env') }); -import { app, BrowserWindow, crashReporter, globalShortcut, ipcMain, Menu, MenuItemConstructorOptions, nativeImage, shell, type Event } from 'electron'; +import { app, BrowserWindow, crashReporter, globalShortcut, ipcMain, Menu, MenuItemConstructorOptions, nativeImage, shell } from 'electron'; import { mergeChromiumFeature } from './startup/chromiumFeatures'; -import { - createSingleInstanceLaunchData, - parseSingleInstanceLaunchData, - shouldHandoffToNewerInstance, - type SingleInstanceLaunchData, -} from './startup/singleInstance'; if (process.platform === 'linux') { app.commandLine.appendSwitch( @@ -48,22 +41,9 @@ crashReporter.start({ compress: true, }); -// Enforce a single running instance. Launching a second copy would race on -// the sessions SQLite db, the .vite dev cache, and the user-data dir. When the -// second instance is the same version, surface the existing window. When it is -// a newer installed binary, quit this process and hand off to that binary so a -// manual install can complete without users first finding and quitting the old -// process. -const singleInstanceLaunchData = createSingleInstanceLaunchData({ - version: app.getVersion(), - execPath: process.execPath, - argv: process.argv, - cwd: process.cwd(), - appPath: app.getAppPath(), - pid: process.pid, - platform: process.platform, -}); -if (!app.requestSingleInstanceLock(singleInstanceLaunchData)) { +// Enforce a single running instance. The lock loser exits, while the primary +// process handles `second-instance` by focusing or recreating its main window. +if (!app.requestSingleInstanceLock()) { app.exit(0); } app.on('second-instance', handleSecondInstanceLaunch); @@ -186,7 +166,6 @@ if (started) { let shellWindow: BrowserWindow | null = null; let onboardingWindow: BrowserWindow | null = null; let isQuitting = false; -let pendingNewerInstanceLaunch: SingleInstanceLaunchData | null = null; const sessionManager = new SessionManager(path.join(app.getPath('userData'), 'sessions.db')); // Bootstrap the editable helpers harness — writes stock helpers.js + TOOLS.json @@ -215,19 +194,11 @@ const whatsAppAdapter = new WhatsAppAdapter(); const channelRouter = new ChannelRouter(sessionManager, whatsAppAdapter); // --------------------------------------------------------------------------- -// Single-instance handoff +// Single-instance focus // --------------------------------------------------------------------------- -function handleSecondInstanceLaunch(_event: Event, _argv: string[], _workingDirectory: string, additionalData: unknown): void { - const incoming = parseSingleInstanceLaunchData(additionalData); - const incomingVersion = incoming ? incoming.version : null; - if (shouldHandoffToNewerInstance(app.getVersion(), incoming)) { - scheduleNewerInstanceHandoff(incoming); - return; - } - +function handleSecondInstanceLaunch(): void { mainLogger.info('main.singleInstance.focusExisting', { currentVersion: app.getVersion(), - incomingVersion, }); showAndFocusPrimaryWindow(); } @@ -258,54 +229,6 @@ function showAndFocusPrimaryWindow(): void { }, 100); } -function scheduleNewerInstanceHandoff(incoming: SingleInstanceLaunchData): void { - if (pendingNewerInstanceLaunch) { - mainLogger.info('main.singleInstance.newerLaunchAlreadyPending', { - currentVersion: app.getVersion(), - incomingVersion: incoming.version, - execPath: incoming.execPath, - }); - return; - } - - pendingNewerInstanceLaunch = incoming; - mainLogger.info('main.singleInstance.newerLaunch', { - currentVersion: app.getVersion(), - incomingVersion: incoming.version, - execPath: incoming.execPath, - appPath: incoming.appPath, - }); - - app.once('will-quit', () => { - const launch = pendingNewerInstanceLaunch; - if (!launch) return; - app.releaseSingleInstanceLock(); - launchNewerInstance(launch); - }); - - isQuitting = true; - app.quit(); -} - -function launchNewerInstance(incoming: SingleInstanceLaunchData): void { - const args = incoming.argv - .slice(1) - .filter((arg) => !arg.startsWith('--original-process-start-time=')); - try { - const child = spawn(incoming.execPath, args, { - cwd: incoming.cwd, - detached: true, - stdio: 'ignore', - }); - child.unref(); - } catch (err) { - mainLogger.warn('main.singleInstance.newerLaunchFailed', { - error: (err as Error)?.message ?? String(err), - execPath: incoming.execPath, - }); - } -} - // --------------------------------------------------------------------------- // Shell window factory // --------------------------------------------------------------------------- diff --git a/app/src/main/startup/singleInstance.ts b/app/src/main/startup/singleInstance.ts deleted file mode 100644 index b18246f0..00000000 --- a/app/src/main/startup/singleInstance.ts +++ /dev/null @@ -1,134 +0,0 @@ -export type SingleInstanceLaunchData = { - version: string; - execPath: string; - argv: string[]; - cwd: string; - appPath: string; - pid: number; - platform: NodeJS.Platform; -}; - -export function createSingleInstanceLaunchData(input: { - version: string; - execPath: string; - argv: string[]; - cwd: string; - appPath: string; - pid: number; - platform: NodeJS.Platform; -}): SingleInstanceLaunchData { - return { - version: input.version, - execPath: input.execPath, - argv: input.argv, - cwd: input.cwd, - appPath: input.appPath, - pid: input.pid, - platform: input.platform, - }; -} - -export function parseSingleInstanceLaunchData(value: unknown): SingleInstanceLaunchData | null { - if (!value || typeof value !== 'object') return null; - const record = value as Record; - if ( - typeof record.version !== 'string' - || typeof record.execPath !== 'string' - || !Array.isArray(record.argv) - || !record.argv.every((arg) => typeof arg === 'string') - || typeof record.cwd !== 'string' - || typeof record.appPath !== 'string' - || typeof record.pid !== 'number' - || typeof record.platform !== 'string' - ) { - return null; - } - - return { - version: record.version, - execPath: record.execPath, - argv: record.argv, - cwd: record.cwd, - appPath: record.appPath, - pid: record.pid, - platform: record.platform as NodeJS.Platform, - }; -} - -export function compareAppVersions(left: string, right: string): number { - const leftVersion = parseVersion(left); - const rightVersion = parseVersion(right); - const length = Math.max(leftVersion.parts.length, rightVersion.parts.length); - for (let index = 0; index < length; index += 1) { - const leftPart = leftVersion.parts[index] ?? 0; - const rightPart = rightVersion.parts[index] ?? 0; - if (leftPart > rightPart) return 1; - if (leftPart < rightPart) return -1; - } - if (leftVersion.prerelease && !rightVersion.prerelease) return -1; - if (!leftVersion.prerelease && rightVersion.prerelease) return 1; - if (leftVersion.prerelease && rightVersion.prerelease) { - return comparePrereleaseVersions(leftVersion.prerelease, rightVersion.prerelease); - } - return 0; -} - -export function shouldHandoffToNewerInstance(currentVersion: string, incoming: SingleInstanceLaunchData | null): incoming is SingleInstanceLaunchData { - return Boolean(incoming?.execPath && compareAppVersions(incoming.version, currentVersion) > 0); -} - -function parseVersion(version: string): { parts: number[]; prerelease: string | null } { - const [versionWithoutBuild] = version - .trim() - .replace(/^v/i, '') - .split('+', 1); - const prereleaseSeparator = versionWithoutBuild.indexOf('-'); - const main = prereleaseSeparator === -1 - ? versionWithoutBuild - : versionWithoutBuild.slice(0, prereleaseSeparator); - const prerelease = prereleaseSeparator === -1 - ? null - : versionWithoutBuild.slice(prereleaseSeparator + 1); - const parts = main - .split('.') - .map((part) => Number.parseInt(part, 10)) - .filter((part) => Number.isFinite(part)); - return { parts, prerelease }; -} - -function comparePrereleaseVersions(left: string, right: string): number { - const leftIdentifiers = left.split('.'); - const rightIdentifiers = right.split('.'); - const length = Math.max(leftIdentifiers.length, rightIdentifiers.length); - for (let index = 0; index < length; index += 1) { - const leftIdentifier = leftIdentifiers[index]; - const rightIdentifier = rightIdentifiers[index]; - if (leftIdentifier === undefined) return -1; - if (rightIdentifier === undefined) return 1; - - const result = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier); - if (result !== 0) return result; - } - return 0; -} - -function comparePrereleaseIdentifier(left: string, right: string): number { - const leftIsNumeric = isNumericIdentifier(left); - const rightIsNumeric = isNumericIdentifier(right); - if (leftIsNumeric && rightIsNumeric) { - const leftNumber = BigInt(left); - const rightNumber = BigInt(right); - if (leftNumber > rightNumber) return 1; - if (leftNumber < rightNumber) return -1; - return 0; - } - if (leftIsNumeric && !rightIsNumeric) return -1; - if (!leftIsNumeric && rightIsNumeric) return 1; - if (left > right) return 1; - if (left < right) return -1; - return 0; -} - -function isNumericIdentifier(value: string): boolean { - return /^\d+$/.test(value); -} diff --git a/app/tests/unit/startup/singleInstance.test.ts b/app/tests/unit/startup/singleInstance.test.ts deleted file mode 100644 index dbe2aaf5..00000000 --- a/app/tests/unit/startup/singleInstance.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - compareAppVersions, - createSingleInstanceLaunchData, - parseSingleInstanceLaunchData, - shouldHandoffToNewerInstance, -} from '../../../src/main/startup/singleInstance'; - -describe('single instance startup helpers', () => { - it('round-trips launch metadata passed through Electron additionalData', () => { - const data = createSingleInstanceLaunchData({ - version: '0.0.31', - execPath: '/Applications/Browser Use.app/Contents/MacOS/Browser Use', - argv: ['Browser Use', '--flag=value'], - cwd: '/tmp', - appPath: '/Applications/Browser Use.app/Contents/Resources/app.asar', - pid: 1234, - platform: 'darwin', - }); - - expect(parseSingleInstanceLaunchData(data)).toEqual(data); - }); - - it('rejects malformed launch metadata', () => { - expect(parseSingleInstanceLaunchData(null)).toBeNull(); - expect(parseSingleInstanceLaunchData({ version: '0.0.31' })).toBeNull(); - expect(parseSingleInstanceLaunchData({ - version: '0.0.31', - execPath: '/app', - argv: ['Browser Use', 42], - cwd: '/tmp', - appPath: '/app/resources', - pid: 1234, - platform: 'darwin', - })).toBeNull(); - }); - - it('compares app versions with v prefixes and patch numbers', () => { - expect(compareAppVersions('0.0.31', '0.0.30')).toBe(1); - expect(compareAppVersions('v0.0.30', '0.0.30')).toBe(0); - expect(compareAppVersions('0.1.0', '0.0.99')).toBe(1); - expect(compareAppVersions('0.0.29', '0.0.30')).toBe(-1); - expect(compareAppVersions('0.0.30-beta.1', '0.0.30')).toBe(-1); - expect(compareAppVersions('0.0.31-beta.1', '0.0.30')).toBe(1); - expect(compareAppVersions('0.0.30-beta.10', '0.0.30-beta.2')).toBe(1); - expect(compareAppVersions('0.0.30-beta.2', '0.0.30-beta.10')).toBe(-1); - expect(compareAppVersions('0.0.30-alpha.1', '0.0.30-alpha.beta')).toBe(-1); - expect(compareAppVersions('0.0.30-beta.2', '0.0.30-beta')).toBe(1); - expect(compareAppVersions('0.0.30+build.2', '0.0.30+build.1')).toBe(0); - }); - - it('only hands off when the second launch is newer', () => { - const incoming = createSingleInstanceLaunchData({ - version: '0.0.31', - execPath: '/new/Browser Use', - argv: ['/new/Browser Use'], - cwd: '/tmp', - appPath: '/new/resources', - pid: 1234, - platform: 'darwin', - }); - - expect(shouldHandoffToNewerInstance('0.0.30', incoming)).toBe(true); - expect(shouldHandoffToNewerInstance('0.0.31', incoming)).toBe(false); - expect(shouldHandoffToNewerInstance('0.0.32', incoming)).toBe(false); - expect(shouldHandoffToNewerInstance('0.0.30', null)).toBe(false); - }); -});