LogoPear Docs

Start from the hello-pear-electron template

An alternative getting started path: clone the hello-pear-electron boilerplate and learn where the frontend lives, where the app logic lives, and how the preload bridge and worker IPC connect them — then add a feature end to end.

This is an alternative to the four-part getting started path. Instead of building a chat from five files and reshaping it into the production template, you start from the finished template and learn your way around it.

holepunchto/hello-pear-electron is Holepunch's official Electron template — the same shape Keet and PearPass ship. This is a clone-first tour: where to put your UI, where to put your peer-to-peer logic, and how the two halves talk. For the conceptual "why" behind the split, read Pear desktop application architecture.

Take this path when you want a production-shaped Electron app from the first commit. If you would rather learn the moving parts by building up from scratch, follow the four-part path instead; if you want a scaffold from a pear:// link, see pear init.

Clone and run

1. Clone the repository

Clone the repository and install dependencies with the following commands:

git clone https://github.com/holepunchto/hello-pear-electron
cd hello-pear-electron
npm install

Before the app will boot, set a valid upgrade link. To do this, run the following command:

pear touch

This will output a link, for example: pear://qxenz5wmspmryjc13m9yzsqj1conqotn8fb4ocbufwtz9mtbqq5o.

Set the upgrade link in package.json to the link you just created:

"upgrade": "pear://qxenz5wmspmryjc13m9yzsqj1conqotn8fb4ocbufwtz9mtbqq5o"

4. Run the app

npm start runs electron-forge start -- --no-updates: the app launches in development mode with over-the-air updates disabled, so a live release never replaces your working tree while you hack.

npm start

Map the template

PathWhat it isDo you edit it?
renderer/The frontend — index.html plus app.js, plain DOM with no bundler.Yes — this is your UI.
workers/main.jsThe app logic — a Bare worker that owns the swarm, storage, and updater.Yes — this is your backend.
electron/main.jsThe Electron main process. Spawns the worker and proxies messages; you rarely change it.Occasionally.
electron/preload.jsExposes the safe window.bridge API to the renderer.Only to add typed methods.
package.jsonApp metadata, scripts, and the upgrade link.Yes — branding and release link.
forge.config.jsElectron Forge packagers, makers, and signing hooks.For packaging and signing.
build/Icons and per-OS manifests (AppxManifest.xml, entitlements, Flatpak/Snap metadata).Yes — brand assets.
pear.jsonMultisig config placeholder for production releases.At production time.

The three pieces you care about day to day are:

  • renderer/ (view)
  • workers/main.js (logic)
  • the bridge that connects them

How the three processes connect

window.bridge pear:worker:* IPC FramedStream pipe renderer/app.js electron/preload.js electron/main.js workers/main.js (Bare) Hyperswarm + Corestore + updater

The renderer never touches ipcRenderer directly — it only calls window.bridge. The main process is a thin proxy: it relays bridge calls to a FramedStream byte stream connected to the Bare worker, and fans the worker's output back out to the renderer on per-worker channels named after the worker specifier (/workers/main.js).

The boilerplate ships a "hello" round-trip you can trace on boot:

  1. The renderer calls bridge.startWorker('/workers/main.js') (renderer/app.js).
  2. The main process handles pear:startWorker and launches the worker with PearRuntime.run() (see below for more details).
  3. The worker runs pipe.write('Hello from worker') (see below for more details).
  4. The main process forwards it on pear:worker:ipc:/workers/main.js.
  5. The renderer's bridge.onWorkerIPC callback receives it and replies bridge.writeWorkerIPC('/workers/main.js', 'Hello from renderer') (see below for more details).

Where the frontend goes

Your UI lives in renderer/. index.html is a static shell loaded by the main process; renderer/app.js is loaded as an ES module and drives the DOM:

const bridge = window.bridge
document.getElementById('v').innerText += bridge.pkg().version

There is no framework or build step — bring your own (React, Vue, plain DOM) by editing these files. The only rule is: the renderer talks to the rest of the app only through window.bridge. It has no Node or Bare access of its own, which is what keeps the renderer sandboxed.

If you prefer to develop the UI against a dev server (hot reload, a framework toolchain), set PEAR_DEV_SERVER_URL and the main process loads that URL instead of renderer/index.html.

The full production bridge — including how window.bridge is assembled — is walked through in Reshape your app for production.

Where the app logic goes

Everything peer-to-peer lives in workers/main.js, which runs in Bare (not Node). The main process passes the configuration as positional arguments to PearRuntime.run():

const pipe = new FramedStream(Bare.IPC)

const updaterConfig = {
  dir: Bare.argv[2],       // application storage directory
  app: Bare.argv[3],       // packaged app path
  updates: Bare.argv[4] !== 'false',
  version: Bare.argv[5],
  upgrade: Bare.argv[6],   // pear:// upgrade link
  name: Bare.argv[7]
}

const store = new Corestore(path.join(updaterConfig.dir, 'pear-runtime/corestore'))
const swarm = new Hyperswarm()
const pear = new PearRuntime({ ...updaterConfig, swarm, store })

This is where you add your Corestore cores, join Hyperswarm topics, and run your protocols. Pass pear.storage (in this case Bare.argv[2], the storage path) as the storage root so your data lands in the same per-app directory Pear manages — see Storage and distribution. For why the logic belongs here rather than in the renderer, see Workers.

How to connect them

The bridge is the contract between the two halves. electron/preload.js exposes these methods on window.bridge:

window.bridge methodPurpose
pkg()Read package.json synchronously (used for the version label).
startWorker(specifier)Spawn a worker by path, e.g. /workers/main.js.
writeWorkerIPC(specifier, data)Send a message to that worker.
onWorkerIPC(specifier, listener)Receive messages from that worker. Returns an unsubscribe function.
onWorkerStdout / onWorkerStderr / onWorkerExitObserve worker output and lifecycle.
applyUpdate() / appAfterUpdate()Apply a downloaded OTA update and relaunch.

Messages over the worker pipe are raw bytes — the boilerplate sends plain UTF-8 strings ('Hello from worker', 'updating', 'pear:applyUpdate'). You have two ways to add functionality:

  1. Stay on the generic channel. Send and receive your own messages with writeWorkerIPC / onWorkerIPC. Define a small message protocol (a type field is enough). This is the lightest option and is shown below.
  2. Add a typed bridge method. For something first-class (not tied to a worker), add a method to electron/preload.js and a matching ipcMain.handle(...) in electron/main.js, the way applyUpdate is wired.

Example: add a "ping" feature

You can add a button that asks the worker for the current time and shows the reply. It exercises the full round-trip: renderer to worker and back. You can switch the pipe from bare strings to small JSON messages so several message types can coexist. This example uses the generic channel.

1. Add a button to the renderer UI

In renderer/index.html, add a button and an output line inside .container:

     <div class="container">
       <h1 id="v">v</h1>
       <button id="update-btn">Apply update</button>
+      <button id="ping-btn">Ping worker</button>
+      <p id="pong"></p>
     </div>

2. Send and receive in the renderer

In renderer/app.js, wire the button to writeWorkerIPC and handle the reply in the existing onWorkerIPC callback. The boilerplate currently treats every worker message as an updater event string; route messages through JSON.parse and fall back to the old strings so the updater keeps working:

 const offWorkerIpc = bridge.onWorkerIPC(workers.main, (data) => {
   const message = decoder.decode(data)
   console.log('worker ipc', '[', workers.main, ']:', message)
-  onWorkerUpdaterEvent(message)
+
+  let parsed
+  try {
+    parsed = JSON.parse(message)
+  } catch {
+    onWorkerUpdaterEvent(message) // 'updating' / 'updated'
+    parsed = null
+  }
+
+  if (parsed?.type === 'pong') {
+    document.getElementById('pong').innerText = 'Worker time: ' + parsed.time
+  }

   if (!sentHello) {
     sentHello = true
     bridge.writeWorkerIPC(workers.main, 'Hello from renderer')
   }
 })
+
+document.getElementById('ping-btn').onclick = () => {
+  bridge.writeWorkerIPC(workers.main, JSON.stringify({ type: 'ping' }))
+}

3. Handle it in the worker

In workers/main.js, parse incoming messages and reply to ping. Keep the existing pear:applyUpdate handling:

 pipe.on('data', async (data) => {
   const message = data.toString()
   if (message === 'pear:applyUpdate') {
     await pear.updater.applyUpdate()
     pipe.write('pear:updateApplied')
-  } else console.log(message)
+    return
+  }
+
+  try {
+    const msg = JSON.parse(message)
+    if (msg.type === 'ping') {
+      pipe.write(JSON.stringify({ type: 'pong', time: new Date().toISOString() }))
+    }
+  } catch {
+    console.log(message)
+  }
 })

Run npm start, click Ping worker, and the worker's timestamp appears in the renderer. You now have a request/response path you can grow into real features — swap the pong handler for a Corestore read, a Hyperswarm lookup, or any protocol your app needs.

Customize for your brand

Before you ship, deploy your application:

  • package.json — set name, productName, description, author, license, and the upgrade pear:// link (see Set the upgrade link).
  • build/ — replace icon.icns / icon.ico / icon.png and the sized icons, and edit build/AppxManifest.xml (DisplayName, Publisher) for Windows.
  • pear.json — fill the multisig public-key placeholders when you set up production signing.

Then build and release with Build desktop distributables, or automate it with Build and sign desktop apps in CI.

Where to go next

On this page