npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

electron-launch-handler

v0.2.0

Published

Single instance enforcement and deep link handling for Electron apps

Downloads

301

Readme

electron-launch-handler

CI npm version npm downloads

Single-instance and deep-link plumbing for Electron apps.

Electron gives you deep links through different paths depending on the platform: open-url on macOS, command-line arguments on Windows and Linux, and second-instance when the app is already running. This package normalizes that startup path so your app code can handle URLs and relaunches from one place.

What It Handles

  • Single-instance lock acquisition
  • Custom protocol registration
  • Launch-time and already-running deep links
  • Plain relaunches without a deep link
  • Readiness queues for startup, auth, onboarding, or workspace loading
  • Staged startup, where handlers only exist after the app has bootstrapped
  • Squirrel.Windows installer events

Installation

npm install electron-launch-handler
# or
pnpm add electron-launch-handler

Requires Node.js 24 or newer and Electron 41 or newer.

Quick Start

import { setupInstance } from 'electron-launch-handler'
import { app, BrowserWindow } from 'electron'

let mainWindow: BrowserWindow | null = null

const instance = setupInstance({
  protocols: ['myapp'],
  onDeepLink: (url, context) => {
    if (!mainWindow) {
      mainWindow = new BrowserWindow({ width: 1200, height: 800 })
    }

    console.log('Deep link:', url)
    console.log('Intent:', context.intent)
  },
})

if (instance.shouldQuit) {
  app.quit()
} else {
  app.whenReady().then(async () => {
    await instance.processPendingDeepLinks()
  })
}

Call setupInstance() at module scope, as above. If your handlers are not available that early, use createInstance() instead.

How It Works

  1. The library acquires a single-instance lock.
  2. Deep links are collected until you call processPendingDeepLinks().
  3. Once processed, future deep links are delivered directly to onDeepLink.

When another instance launches with a deep link, the running app receives it via onDeepLink. Relaunches without a deep link go to onSecondInstance instead.

Staged Startup

setupInstance() assumes you can build your handlers immediately. Many real apps cannot — the deep-link handler needs a window manager, a database, or a signed-in user, none of which exist on the first line of main.

Waiting and calling setupInstance() later is the wrong fix. On macOS a cold launch fires open-url before app.whenReady(), so a late setupInstance() never sees the URL that started the app.

Use createInstance() + configure() instead. createInstance() takes the lock and installs the listeners right away; configure() supplies the handlers whenever you have them. Anything that arrives in between is queued.

import { createInstance } from 'electron-launch-handler'
import { app } from 'electron'

// First line of main: listeners are live from here on.
const instance = createInstance({ protocols: ['myapp'] })

if (instance.shouldQuit) {
  app.quit()
  process.exit(0)
}

app.whenReady().then(async () => {
  const windowManager = await bootstrapApp() // migrations, auth, windows...

  instance.configure({
    onDeepLink: (url, context) => windowManager.open(url, context),
    onSecondInstance: ({ deepLinkUrl }) => {
      if (!deepLinkUrl) {
        windowManager.focus()
      }
    },
  })

  // Resolves once every queued link has been handled.
  await instance.processPendingDeepLinks()
  windowManager.reveal()
})

configure() only replaces the keys you pass, so onDeepLink and onSecondInstance can be set from different places. Second-instance events received before onSecondInstance exists are replayed when it is configured, as long as that happens before processPendingDeepLinks(). Processing ends the startup buffering window; without an onSecondInstance handler, buffered plain relaunch callbacks are discarded while their deep links still flow through the deep-link queue. Set onSecondInstance: undefined explicitly to opt out sooner.

processPendingDeepLinks() is the readiness boundary and always marks the deep-link queue as processed. Configure onDeepLink before calling it if links should be delivered. When no handler exists, queued links are discarded and future links are not retained; this keeps apps that intentionally use only onSecondInstance stable.

Awaiting Dispatch

processPendingDeepLinks() and processDeferredDeepLinks() return promises that resolve once every handler they dispatched has settled, including handlers that queued more work while running. Await them when the next step depends on the deep link having been applied — showing a window, for example.

There is one necessary re-entrant exception: when an active onDeepLink handler calls processDeferredDeepLinks(), the deferred work is queued behind that handler and the returned promise resolves once it is scheduled. Waiting for the queued work from inside the handler would deadlock; it cannot start until the current handler returns.

All dispatch runs through a single serialized chain, so handlers never overlap. A deep link that arrives while the queue is draining lines up behind it instead of racing it.

Bring Your Own Lock

Apps that do expensive work before whenReady() usually want the single-instance lock at the very top of main, before that work starts. Take it yourself and tell the library with singleInstanceLock: 'external':

import { createInstance } from 'electron-launch-handler'
import { app } from 'electron'

if (!app.requestSingleInstanceLock()) {
  app.exit(0)
  process.exit(0)
}

const instance = createInstance({
  protocols: ['myapp'],
  singleInstanceLock: 'external',
})

// The lock and listeners are now live, so relaunches during migrations queue.
await runDatabaseMigrations()

In 'external' mode the library still installs the 'second-instance' listener, but never acquires the lock and never releases it on dispose() — the lock stays yours. Use 'disabled' to skip single-instance behavior entirely and use the library only for deep links.

Lock Failure Handling

In the default 'auto' mode, if another instance is already running, the new process will have instance.shouldQuit === true. You can also hook onInstanceLockFailed to record telemetry or perform cleanup before exiting. In 'external' mode, handle failure when you acquire the lock yourself; 'disabled' mode never attempts lock acquisition.

const instance = setupInstance({
  protocols: ['myapp'],
  onInstanceLockFailed: () => {
    console.log('Another instance is already running')
  },
})

if (instance.shouldQuit) {
  app.quit()
}

Deep Link Intent

DeepLinkContext.intent tells you how the deep link was delivered:

  • launch: the URL was found in this process's startup arguments
  • open-url: the URL arrived through an Electron runtime event (open-url or second-instance)
onDeepLink: (url, context) => {
  if (context.intent === 'launch') {
    // URL found in this process's startup arguments
  } else {
    // URL delivered by an Electron event
  }
}

Electron exposes different delivery mechanisms across platforms:

| Delivery mechanism | macOS | Windows / Linux | Intent | |--------------------|-------|-----------------|--------| | URL in this process's startup arguments | Uncommon/manual | Normal cold-start path | launch | | Electron runtime event | open-url | second-instance | open-url |

On macOS, open-url can fire before the app is ready, but Electron does not say whether that event started the process. app.isReady() describes lifecycle state, not causality: a normally started app can also receive one or more URLs during slow startup. Native macOS protocol events are therefore reported as open-url regardless of readiness.

The library must still be installed before app.whenReady() — see Staged Startup. If you construct it later, macOS may already have delivered the open-url event and the link is lost.

Readiness

Deep links are queued until your app opts in to handling them:

app.whenReady().then(async () => {
  await instance.processPendingDeepLinks()
})

Use queueDeepLink(url) for URLs your own code receives before that point. After readiness, return { action: 'defer' } from onDeepLink when the URL is valid but another app condition is not ready yet. Deferred links are held until processDeferredDeepLinks().

Common Patterns

Onboarding Flows

let isOnboardingComplete = false

const instance = setupInstance({
  protocols: ['myapp'],
  onDeepLink: (url) => {
    if (!isOnboardingComplete) {
      return { action: 'defer' }
    }

    handleDeepLink(url)
  },
})

app.whenReady().then(async () => {
  await instance.processPendingDeepLinks()
})

const completeOnboarding = async () => {
  isOnboardingComplete = true
  await instance.processDeferredDeepLinks()
}

Plain Relaunches

setupInstance({
  protocols: ['myapp'],
  onDeepLink: handleDeepLink,
  onSecondInstance: ({ deepLinkUrl }) => {
    if (!deepLinkUrl) {
      focusMainWindow()
    }
  },
})

Development Protocols

const isDev = !app.isPackaged

const instance = setupInstance({
  protocols: isDev ? ['myapp-dev'] : ['myapp'],
  onDeepLink: (url) => {
    console.log('Deep link received:', url)
  },
})

Logging

Provide a logger to capture lifecycle events such as lock acquisition, protocol registration, queueing, dispatch, and errors.

import log from 'electron-log'

const instance = setupInstance({
  protocols: ['myapp'],
  onDeepLink: (url) => {
    // ...
  },
  logger: {
    debug: (msg) => log.debug(msg),
    info: (msg) => log.info(msg),
    error: (msg) => log.error(msg),
  },
})

Platform-Specific Options

const instance = setupInstance({
  protocols: ['myapp'],
  onDeepLink: (url) => {
    // ...
  },

  // Windows: Handle Squirrel.Windows installer events
  windows: {
    handleSquirrelEvents: true,
    squirrelOptions: {
      createDesktopShortcut: true,
      createStartMenuShortcut: true,
      shortcutName: 'My App',
    },
  },

  // Linux: Specify desktop file name
  linux: {
    desktopFileName: 'my-app',
  },
})

Squirrel.Windows Notes

When windows.handleSquirrelEvents is enabled, the library handles --squirrel-* events for install/update/uninstall:

  • --squirrel-install: Creates shortcuts
  • --squirrel-updated: Updates shortcuts
  • --squirrel-uninstall: Removes shortcuts
  • --squirrel-obsolete: Exits cleanly during version replacement

Use windows.squirrelOptions to control shortcut behavior.

API Reference

setupInstance(options)

Main entry point. Returns an InstanceManager object.

Options

| Option | Type | Default | Description | |--------|------|---------|-------------| | protocols | string[] | [] | Protocol schemes to register | | onDeepLink | DeepLinkHandler | - | Called when a deep link is received. Can return a DeepLinkDeferral. | | onSecondInstance | SecondInstanceHandler | - | Called when another instance launches | | onInstanceLockFailed | () => void | - | Called when lock acquisition fails | | singleInstanceLock | SingleInstanceLockMode | 'auto' | How the lock is obtained. See below. | | logger | Logger | no-op | Logger instance | | windows | WindowsOptions | - | Windows-specific options | | linux | LinuxOptions | - | Linux-specific options | | macos | MacOSOptions | - | macOS-specific options |

SingleInstanceLockMode

| Value | Behavior | |-------|----------| | 'auto' | The library calls app.requestSingleInstanceLock() and releases it on dispose() | | 'external' | You already hold the lock. The library listens for 'second-instance' but never acquires or releases | | 'disabled' | No lock and no 'second-instance' listener. Deep links still work |

Returns: InstanceManager

| Property/Method | Type | Description | |-----------------|------|-------------| | shouldQuit | boolean | Whether this instance should quit | | configure(handlers) | (handlers: InstanceHandlers) => void | Supply or replace onDeepLink / onSecondInstance. Only the keys present are updated | | processPendingDeepLinks() | () => Promise<void> | Process queued deep links and mark handler as ready. Resolves once dispatched handlers settle | | getPendingDeepLinks() | () => string[] | Get pending deep links without processing | | clearPendingDeepLinks() | () => void | Clear pending deep links without processing | | queueDeepLink(url) | (url: string, intent?: DeepLinkIntent) => void | Queue a deep link for later processing | | deferDeepLink(url) | (url: string, intent?: DeepLinkIntent) => void | Hold a deep link until processDeferredDeepLinks() | | processDeferredDeepLinks() | () => Promise<void> | Process deep links held by deferDeepLink(). Resolves once dispatched handlers settle, or once scheduled when called re-entrantly | | getDeferredDeepLinks() | () => string[] | Get deferred deep links without processing | | clearDeferredDeepLinks() | () => void | Clear deferred deep links without processing | | unregisterProtocols() | () => void | Unregister protocol handlers (typically only needed for testing) | | dispose() | () => void | Remove installed listeners and unregister protocols |

createInstance(options)

Same as setupInstance() minus onDeepLink and onSecondInstance, which you supply later via configure(). Returns the same InstanceManager. See Staged Startup.

extractDeepLinkFromArgs(argv, protocols)

Find the deep link in a list of command-line arguments, using the current platform's rules. Useful if you buffer launch events yourself or need to inspect a relaunch's argv.

import { extractDeepLinkFromArgs } from 'electron-launch-handler'

extractDeepLinkFromArgs(['MyApp', '--flag', 'myapp://open'], ['myapp'])
// 'myapp://open'

Arguments are scanned in reverse on every platform, because the OS appends the URL — the last match is the one that triggered the launch.

DeepLinkContext

| Property | Type | Description | |----------|------|-------------| | url | string | Original URL string | | parsed | URL | Parsed URL object | | protocol | string | Protocol without :// | | host | string | URL host | | path | string | URL path | | params | URLSearchParams | Query parameters | | hash | string | URL hash/fragment | | intent | 'launch' \| 'open-url' | How the deep link was delivered |

DeepLinkDeferral

Return { action: 'defer' } from onDeepLink to redeliver the same URL on the next processDeferredDeepLinks() call.

SecondInstanceContext

| Property | Type | Description | |----------|------|-------------| | argv | string[] | Command-line arguments from the second instance | | workingDirectory | string | Working directory from the second instance | | deepLinkUrl | string \| undefined | Deep link found in argv, if present |

parseDeepLink(url)

Parse a deep link URL manually.

import { parseDeepLink } from 'electron-launch-handler'

const result = parseDeepLink('myapp://open/document?id=123')
// {
//   url: 'myapp://open/document?id=123',
//   parsed: URL { ... },
//   protocol: 'myapp',
//   host: 'open',
//   path: '/document',
//   params: URLSearchParams { 'id' => '123' },
//   hash: ''
// }

Platform Notes

macOS

  • Deep links arrive via the open-url app event
  • Protocol registration uses app.setAsDefaultProtocolClient()
  • Works with both Intel and Apple Silicon

Windows

  • Deep links arrive via command-line arguments
  • Protocol registration uses app.setAsDefaultProtocolClient()
  • Squirrel.Windows Support: Installer events are handled automatically
  • This library handles URL protocols, not OS-level file associations

Linux

  • Deep links arrive via command-line arguments
  • Protocol registration may require a .desktop file
  • Behavior varies by desktop environment

Security

When handling deep links, validate and sanitize URL inputs before acting on them.

  • Never execute arbitrary code from deep link URLs
  • Validate URL schemes before processing
  • Sanitize user input from URL parameters
  • Use HTTPS for OAuth callbacks when possible

License

MIT