zooboard/main.js

305 lines
9.9 KiB
JavaScript

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 iterable = require( 'pull-iterable' )
const combineLatest = require('pull-combine-latest')
const pull = require('pull-stream')
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)
}
} else {
console.log('other msg on init', msg)
}
}
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])
} else if(msg?.domain === 'dict_v1__profile') {
// TODO: load on init as well
console.log('other msg on stream', msg)
}
})
// 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
const recordsAdded = pull(
combineLatest([
peer.net.peers(),
pull(
pull.count(),
pull.asyncMap((i, cb) => {
// ideally we'd detect peer.db.onRecordAdded here instead of polling but i couldn't figure out how to convert the obz into a pull stream
setTimeout(() => cb(null, i), 2000)
}),
),
])
)
for await (const [connections] of awaitable(recordsAdded)) {
const connInfo = await Promise.all(connections.map(async (conn) => {
const [multiaddr] = conn
const parts = [...multiaddr.matchAll(/\w{44}/g)].map(r => r[0])
const key = parts[parts.length - 1]
if (!key) return conn
let accountId
try {
accountId = await p(peer.db.account.find)({keypair: { public: key }, subdomain: 'person'})
} catch {
return conn
}
// TODO: this seems to always return {}. Do the messages manage to replicate?
const profile = await p(peer.dict.read)(accountId, 'profile')
const name = profile?.name
console.log({key, accountId, profile})
return [
conn[0],
{
...conn[1],
name,
},
]
}))
mainWindow.webContents.send('connections', connInfo)
}
}
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))