const { app, BrowserWindow, ipcMain, shell, clipboard } = require('electron') const Path = require('node:path') const URL = require('node:url') const p = require('node:util').promisify const awaitable = require('pull-awaitable') const { createPeer } = require('pzp-sdk') // WARNING monkey patch! ------------------------------------------------------- const na = require('sodium-native') na.sodium_malloc = function sodium_malloc_monkey_patched(n) { return Buffer.alloc(n) } na.sodium_free = function sodium_free_monkey_patched() {} // Electron > 20.3.8 breaks a napi method that `sodium_malloc` depends on to // create external buffers. (see v8 memory cage) // // This crashes electron when called by various libraries, so we monkey-patch // this particular function. // ----------------------------------------------------------------------------- process.env.ZOOBOARD_DATA ??= Path.join(app.getPath('appData'), 'zooboard') app.setPath('userData', process.env.ZOOBOARD_DATA) const path = Path.resolve(app.getPath('userData'), 'pzp') console.log("Appdata path:", process.env.ZOOBOARD_DATA) let mainWindow let globalAccountName = null createPeer({ path }).then(({ peer, account: globalAccountID }) => { function createWindow() { mainWindow = new BrowserWindow({ width: 1200, height: 1200, title: 'Zooboard', webPreferences: { preload: Path.join(__dirname, 'preload.js'), }, }) const startUrl = process.env.ELECTRON_START_URL ?? URL.format({ pathname: Path.join(__dirname, '/../build/index.html'), protocol: 'file:', slashes: true, }) mainWindow.loadURL(startUrl) mainWindow.webContents.openDevTools({ mode: 'bottom', activate: true }) // open Web URLs in the default system browser mainWindow.webContents.on('new-window', (ev, url) => { ev.preventDefault() shell.openExternal(url) }) } async function loadAccount() { if (globalAccountName !== null) { return { id: globalAccountID, name: globalAccountName } } // Read profile const profile = await p(peer.dict.read)(globalAccountID, 'profile') const name = profile?.name ?? '' globalAccountName = name return { id: globalAccountID, name } } async function setProfileName(ev, name) { await p(peer.dict.update)('profile', { name }) return name } async function writeElements(ev, elements) { if (globalAccountID === null) throw new Error('account not loaded') for (const element of elements) { await p(peer.db.feed.publish)({ account: globalAccountID, domain: 'zooboardElements', data: element, }) } } async function createInvite() { if (globalAccountID === null) throw new Error('account not loaded') let { url } = await p(peer.invite.createForFriend)({ hubs: 1, id: globalAccountID, }) // if the hub is on localhost, it's probably on the default port of 3000, so let's make things a bit easier for the user if (url.indexOf('0.0.0.0') !== -1) url = url.replace("0.0.0.0", "0.0.0.0:3000") return url } function copyToClipboard(ev, text) { clipboard.writeText(text) } let hasSubscribedToReadElements = false async function subscribeToReadElements() { if (hasSubscribedToReadElements) return hasSubscribedToReadElements = true // Load initial elements and inform renderer const elementsByID = new Map() const msgIDToElemID = new Map() for await (const { id: msgID, msg } of peer.db.records()) { if (msg.data && msg.metadata.domain === 'zooboardElements') { const { id: elemID, isDeleted } = msg.data if (isDeleted) { elementsByID.delete(elemID) } else { msgIDToElemID.set(msgID, elemID) elementsByID.set(elemID, msg.data) } } } const initialElements = [...elementsByID.values()] mainWindow.webContents.send('readElements', initialElements) // Subscribe to new elements and inform renderer peer.db.onRecordAdded(({ id: msgID, msg }) => { if (msg.data && msg.metadata.domain === 'zooboardElements') { const { id: elemID, isDeleted } = msg.data if (isDeleted) { elementsByID.delete(elemID) } else { msgIDToElemID.set(msgID, elemID) elementsByID.set(elemID, msg.data) } mainWindow.webContents.send('readElements', [msg.data]) } }) // Subscribe to deleted elements and inform renderer peer.db.onRecordDeletedOrErased((msgID) => { const elemID = msgIDToElemID.get(msgID) if (!elemID) return msgIDToElemID.delete(msgID) // Is there some other msgID that supports this elemID? If so, bail out for (const [, remainingElemID] of msgIDToElemID) { if (remainingElemID === elemID) { return } } // If not, delete the element elementsByID.delete(elemID) mainWindow.webContents.send('readElements', [ { id: elemID, isDeleted: true }, ]) }) // Finally safe to kickstart replication and garbage collection setTimeout(() => { peer.conductor.start( globalAccountID, [ ['profile@dict', 'zooboardElements@newest-100', 'hubs@set'], ['profile@dict', 'zooboardElements@newest-100'], ], 64_000, (err) => { if (err) console.error('Starting conductor failed:', err) } ) }, 32) } let hasSubscribedToConnections = false async function subscribeToConnections() { if (hasSubscribedToConnections) return hasSubscribedToConnections = true for await (const connections of awaitable(peer.net.peers())) { mainWindow.webContents.send('connections', connections) } } async function handlePZPUri(ev, uri) { if (!globalAccountID) { setTimeout(handlePZPUri, 100, null, uri) return } if (uri.startsWith("http:") || uri.startsWith("https://")) { uri = decodeURIComponent(uri.split('/invite#')[1]) } if (!uri.startsWith('pzp://')) return console.log('Not a pzp invite URI', uri) const commands = peer.invite.parse(uri) for (const command of commands) { console.log('Executing command', JSON.stringify(command)) switch (command.type) { case 'join': { try { await p(peer.hubClient.addHub)(command.multiaddr) } catch (err) { console.error('Failed to properly join hub', err) } break } case 'follow': { await p(peer.set.add)('follows', command.id) break } case 'promise.follow': { const [issuerType, issuerPubkey] = command.issuer if (issuerType !== 'pubkey') { throw new Error(`Dont know how to claim a ${issuerType} promise`) } // eslint-disable-next-line no-loop-func peer.addListener('rpc:connect', function onConnect(rpc) { if (rpc.shse.pubkey === issuerPubkey) { peer.removeListener('rpc:connect', onConnect) rpc.promise.follow(command.token, globalAccountID, (err) => { if (err) return console.error('Failed to use follow promise', err) }) } }) break } default: console.log('Unknown command type', command.type) } } } if (process.defaultApp) { if (process.argv.length >= 2) { app.setAsDefaultProtocolClient('pzp', process.execPath, [ Path.resolve(process.argv[1]), ]) } } else { app.setAsDefaultProtocolClient('pzp') } app.whenReady().then(() => { ipcMain.handle('loadAccount', loadAccount) ipcMain.handle('setProfileName', setProfileName) ipcMain.handle('createInvite', createInvite) ipcMain.handle('copyToClipboard', copyToClipboard) ipcMain.handle('writeElements', writeElements) ipcMain.handle('consumeInvite', handlePZPUri) ipcMain.handle('subscribeToReadElements', subscribeToReadElements) ipcMain.handle('subscribeToConnections', subscribeToConnections) createWindow() if (process.argv.length > 1) { handlePZPUri(null, process.argv[process.argv.length - 1]) } app.on('activate', function () { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) app.on('window-all-closed', function () { if (process.platform !== 'darwin') app.quit() }) }) }).catch(err => console.error("Couldn't create peer:", err))