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 install2. Create a valid upgrade link
Before the app will boot, set a valid upgrade link. To do this, run the following command:
pear touchThis will output a link, for example: pear://qxenz5wmspmryjc13m9yzsqj1conqotn8fb4ocbufwtz9mtbqq5o.
3. Set the upgrade link in package.json
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 startMap the template
| Path | What it is | Do you edit it? |
|---|---|---|
renderer/ | The frontend — index.html plus app.js, plain DOM with no bundler. | Yes — this is your UI. |
workers/main.js | The app logic — a Bare worker that owns the swarm, storage, and updater. | Yes — this is your backend. |
electron/main.js | The Electron main process. Spawns the worker and proxies messages; you rarely change it. | Occasionally. |
electron/preload.js | Exposes the safe window.bridge API to the renderer. | Only to add typed methods. |
package.json | App metadata, scripts, and the upgrade link. | Yes — branding and release link. |
forge.config.js | Electron 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.json | Multisig 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
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:
- The renderer calls
bridge.startWorker('/workers/main.js')(renderer/app.js). - The main process handles
pear:startWorkerand launches the worker withPearRuntime.run()(see below for more details). - The worker runs
pipe.write('Hello from worker')(see below for more details). - The main process forwards it on
pear:worker:ipc:/workers/main.js. - The renderer's
bridge.onWorkerIPCcallback receives it and repliesbridge.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().versionThere 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 method | Purpose |
|---|---|
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 / onWorkerExit | Observe 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:
- Stay on the generic channel. Send and receive your own messages with
writeWorkerIPC/onWorkerIPC. Define a small message protocol (atypefield is enough). This is the lightest option and is shown below. - Add a typed bridge method. For something first-class (not tied to a worker), add a method to
electron/preload.jsand a matchingipcMain.handle(...)inelectron/main.js, the wayapplyUpdateis 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— setname,productName,description,author,license, and theupgradepear://link (see Set the upgrade link).build/— replaceicon.icns/icon.ico/icon.pngand the sized icons, and editbuild/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
- Pear desktop application architecture — the conceptual model behind the renderer/main/worker split.
- Workers — why peer-to-peer logic lives in a Bare worker and where the boundary should sit.
- Reshape into a production app — the hello-pear-electron-shaped scaffold with an Autobase-backed room, blind-pairing invites, and a vanilla HTML renderer.
- Deploy your application — stage, seed, and release your build.