OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / ui / boot.js

434 lines · javascript ref:
/**
 * Renderer-side backend connection.
 *
 * Wraps a browser WebSocket into a pipe-like object (on('data')
 * / write()) so the existing length-prefixed JSON framing from
 * backend/rpc.js works unchanged. The backend runs in the Bare
 * main process (where native addons load) and exposes this WS
 * on localhost:9876.
 */

import { RpcClient } from './rpc-client.js'

// Backend tries this range in index.js main. Renderer scans it in
// order until one accepts. Handles zombie pear-runtime processes
// holding 9876.
const RPC_PORT_BASE = 9876
const RPC_PORT_COUNT = 5
const RPC_PROBE_ID = 900000001
const RPC_SESSION_TOKEN = String(globalThis.pearbrowserRuntime?.sessionToken || '')

// Must match backend/constants.js exactly (numeric wire codes). This is a
// COMPLETE mirror — every command/event the renderer can send or receive.
// A missing entry resolves to `undefined` at the call site, which the
// backend silently never matches, so partial mirrors break whole features
// (Settings, Profile, Identity, Login, Swarm) without an obvious error.
// Keep this in lockstep with backend/constants.js.
const C = {
  CMD_NAVIGATE: 1,
  CMD_GET_STATUS: 2,
  CMD_GET_DRIVE_INFO: 3,
  CMD_RELEASE_ORIGIN: 4,
  CMD_LOAD_CATALOG: 10,
  CMD_INSTALL_APP: 11,
  CMD_UNINSTALL_APP: 12,
  CMD_LAUNCH_APP: 13,
  CMD_LIST_INSTALLED: 14,
  CMD_CHECK_UPDATES: 15,
  CMD_LOAD_CATALOG_BEE: 16,
  CMD_GET_CATALOG_APPS: 17,
  CMD_UNLOAD_CATALOG: 18,
  CMD_LOAD_CATALOG_AUTOBEE: 19,
  CMD_SHEETS_LOAD: 170,
  CMD_SHEETS_LIST: 171,
  CMD_SHEETS_LIST_SCHEMAS: 175,
  CMD_LOAD_CATALOG_INDEX: 176,
  CMD_SEARCH: 177,
  CMD_SEARCH_INDEX: 178,
  CMD_CREATE_SITE: 20,
  CMD_UPDATE_SITE: 21,
  CMD_PUBLISH_SITE: 22,
  CMD_UNPUBLISH_SITE: 23,
  CMD_LIST_SITES: 24,
  CMD_DELETE_SITE: 25,
  CMD_LOAD_TEMPLATE: 26,
  CMD_GET_SITE_BLOCKS: 27,
  CMD_LEGACY_APP_MIGRATION: 28,
  CMD_RUN_APP_IN_TAB: 201,
  CMD_RESET_APP: 29,
  CMD_CLEAR_CACHE: 30,
  CMD_GET_IDENTITY: 31,
  CMD_GET_APP_ICON: 32,
  CMD_SET_SITE_ICON: 33,
  CMD_GET_RELAYS: 40,
  CMD_SET_RELAYS: 41,
  CMD_SET_RELAY_ENABLED: 42,
  CMD_CHECK_RELAY_CAPABILITY: 43,
  CMD_USERDATA_LIST_BOOKMARKS: 50,
  CMD_USERDATA_ADD_BOOKMARK: 51,
  CMD_USERDATA_REMOVE_BOOKMARK: 52,
  CMD_USERDATA_LIST_HISTORY: 53,
  CMD_USERDATA_ADD_HISTORY: 54,
  CMD_USERDATA_CLEAR_HISTORY: 55,
  CMD_USERDATA_GET_SETTINGS: 56,
  CMD_USERDATA_SET_SETTINGS: 57,
  CMD_USERDATA_GET_SESSION: 58,
  CMD_USERDATA_SAVE_SESSION: 59,
  CMD_USERDATA_IMPORT: 60,
  CMD_IDENTITY_EXPORT_PHRASE: 70,
  CMD_IDENTITY_IMPORT_PHRASE: 71,
  CMD_IDENTITY_ROTATE: 72,
  CMD_IDENTITY_VALIDATE_PHRASE: 73,
  CMD_IDENTITY_SIGN: 74,
  CMD_IDENTITY_VERIFY: 75,
  CMD_PROFILE_GET: 80,
  CMD_PROFILE_UPDATE: 81,
  CMD_PROFILE_CLEAR: 82,
  CMD_LOGIN_LIST_GRANTS: 83,
  CMD_LOGIN_REVOKE_GRANT: 84,
  CMD_LOGIN_REVOKE_ALL: 85,
  CMD_LOGIN_RESOLVE: 86,
  CMD_CONTACTS_LIST: 90,
  CMD_CONTACTS_LOOKUP: 91,
  CMD_CONTACTS_ADD: 92,
  CMD_CONTACTS_UPDATE: 93,
  CMD_CONTACTS_REMOVE: 94,
  CMD_CONTACTS_MY_INVITE: 95,
  CMD_CONTACTS_ADD_INVITE: 96,
  CMD_STOP: 99,
  CMD_SWARM_RESOLVE: 120,
  CMD_SWARM_LIST_GRANTS: 121,
  CMD_SWARM_REVOKE_GRANT: 122,
  CMD_SWARM_REVOKE_ALL_FOR_APP: 123,
  CMD_MYCATALOG_GET: 150,
  CMD_MYCATALOG_CREATE: 151,
  CMD_MYCATALOG_ADD_APP: 152,
  CMD_MYCATALOG_REMOVE_APP: 153,
  CMD_MYCATALOG_RENAME: 154,
  CMD_MYCATALOG_UPDATE_APP: 155,
  CMD_AUTOBEE_CREATE: 160,
  CMD_AUTOBEE_GET: 161,
  CMD_AUTOBEE_ADD_APP: 162,
  CMD_AUTOBEE_REMOVE_APP: 163,
  CMD_AUTOBEE_RENAME: 164,
  CMD_AUTOBEE_ADD_WRITER: 165,
  CMD_SYNC_STATUS: 180,
  CMD_SYNC_CREATE: 181,
  CMD_SYNC_JOIN: 182,
  CMD_SYNC_ADD_WRITER: 183,
  CMD_SYNC_GET_BOOKMARKS: 184,
  CMD_SYNC_ADD_BOOKMARK: 185,
  CMD_SYNC_REMOVE_BOOKMARK: 186,
  CMD_SYNC_PUSH_LOCAL: 187,
  CMD_NAME_RESOLVE: 250,
  CMD_NAME_PETNAME_LIST: 251,
  CMD_NAME_PETNAME_SET: 252,
  CMD_NAME_PETNAME_REMOVE: 253,
  CMD_NAMEREG_CLAIM: 264,
  CMD_NAMEREG_ROTATE: 265,
  CMD_NAMEREG_RELEASE: 266,
  CMD_NAMEREG_REVOKE: 267,
  CMD_NAMEREG_LIST: 268,
  CMD_NAMEREG_RESOLVE: 269,
  CMD_NAMEREG_STATUS: 270,
  CMD_IDENTITY_BINDING_PUBLISH: 260,
  CMD_IDENTITY_BINDING_RESOLVE: 261,
  CMD_SEARCH_FEDERATED: 262,
  CMD_NOSTR_GET_IDENTITY: 188,
  CMD_NOSTR_BIND: 189,
  CMD_NOSTR_REVOKE: 190,
  CMD_NOSTR_PUBLISH: 191,
  CMD_NOSTR_QUERY: 192,
  CMD_SUBMIT_APP: 210,
  CMD_MOD_PENDING: 211,
  CMD_MOD_APPROVE: 212,
  CMD_MOD_REJECT: 213,
  CMD_MOD_REVIEW: 214,
  CMD_ASK_BROWSER_CAPABILITIES: 220,
  CMD_ASK_BROWSER_START: 221,
  CMD_ASK_BROWSER_CANCEL: 222,
  CMD_SHIELD_STATUS: 230,
  CMD_SHIELD_LOAD_LIST: 231,
  CMD_SHIELD_REMOVE_LIST: 232,
  CMD_SHIELD_SET_ALLOW: 233,
  CMD_SHIELD_SET_STRICT: 234,
  CMD_PLUGIN_LIST: 235,
  CMD_PLUGIN_SET_ENABLED: 236,
  CMD_PLUGIN_REGISTER: 237,
  CMD_SHIELD_SUBSCRIBE_LIST: 239,
  CMD_SHIELD_UNSUBSCRIBE_LIST: 240,
  CMD_SHIELD_REFRESH_LISTS: 241,
  CMD_PLUGIN_INSTALL_DRIVE: 242,
  CMD_PLUGIN_UPDATE_DRIVE: 243,
  CMD_PLUGIN_UNINSTALL: 244,
  CMD_PLUGIN_CATALOG: 245,
  CMD_PLUGIN_CATALOG_LOAD_DRIVE: 246,
  CMD_PLUGIN_CATALOG_REMOVE_SOURCE: 247,
  CMD_PRIVACY_STATUS: 238,
  CMD_BRIDGE: 200,
  EVT_READY: 100,
  EVT_PEER_COUNT: 101,
  EVT_ERROR: 102,
  EVT_INSTALL_PROGRESS: 103,
  EVT_SITE_PUBLISHED: 104,
  EVT_BOOT_PROGRESS: 105,
  EVT_LOGIN_REQUEST: 106,
  EVT_SWARM_REQUEST: 107,
  EVT_SEARCH_FEDERATED: 108,
  EVT_IDENTITY_BINDING_PUBLISHED: 109,
  EVT_LAUNCH_PROGRESS: 110,
  EVT_ASK_BROWSER_STREAM: 111
}

export class WsPipe {
  constructor (url, opts = {}) {
    this._listeners = { data: [], close: [], error: [], open: [], reconnecting: [], 'reconnect-failed': [] }
    this._url = url
    this._connected = false
    this._connecting = false
    this._destroyed = false
    this._reconnectEnabled = false
    this._reconnectTimer = null
    this._reconnectAttempt = 0
    this._maxReconnectAttempts = Number.isInteger(opts.maxReconnectAttempts) ? opts.maxReconnectAttempts : 8
    this._reconnectBaseMs = Number.isFinite(opts.reconnectBaseMs) ? opts.reconnectBaseMs : 100
    this._reconnectMaxMs = Number.isFinite(opts.reconnectMaxMs) ? opts.reconnectMaxMs : 1000
    this._failedSocket = null
    this._ws = null
    this._connect()
  }

  get connected () {
    return this._connected
  }

  enableReconnect () {
    if (this._destroyed) return
    this._reconnectEnabled = true
    if (!this._connected && !this._connecting) this._scheduleReconnect()
  }

  destroy () {
    this._destroyed = true
    this._reconnectEnabled = false
    this._connected = false
    this._connecting = false
    if (this._reconnectTimer) clearTimeout(this._reconnectTimer)
    this._reconnectTimer = null
    const socket = this._ws
    this._ws = null
    try { socket?.close() } catch {}
  }

  _emit (event, detail) {
    for (const fn of this._listeners[event] || []) fn(detail)
  }

  _connect () {
    if (this._destroyed || this._connecting || this._connected) return
    this._connecting = true
    console.log('[ws] connecting to', this._url)
    const socket = new WebSocket(this._url)
    this._ws = socket
    this._failedSocket = null
    socket.binaryType = 'arraybuffer'

    socket.addEventListener('open', () => {
      if (this._destroyed || socket !== this._ws) {
        try { socket.close() } catch {}
        return
      }
      console.log('[ws] open')
      this._connecting = false
      this._connected = true
      this._reconnectAttempt = 0
      this._emit('open')
    })

    socket.addEventListener('message', (e) => {
      if (socket !== this._ws || !this._connected) return
      const text = typeof e.data === 'string'
        ? e.data
        : new TextDecoder().decode(e.data)
      this._emit('data', text)
    })

    socket.addEventListener('close', (e) => {
      console.log('[ws] close', e.code, e.reason)
      this._handleDisconnect(socket)
    })

    socket.addEventListener('error', (e) => {
      console.error('[ws] error', e)
      this._handleDisconnect(socket, e)
      try { socket.close() } catch {}
    })
  }

  _handleDisconnect (socket, error = null) {
    if (this._destroyed || socket !== this._ws || this._failedSocket === socket) return
    this._failedSocket = socket
    this._connected = false
    this._connecting = false
    if (error) this._emit('error', error)
    this._emit('close')
    this._scheduleReconnect()
  }

  _scheduleReconnect () {
    if (!this._reconnectEnabled || this._destroyed || this._connected || this._connecting || this._reconnectTimer) return
    if (this._reconnectAttempt >= this._maxReconnectAttempts) {
      this._emit('reconnect-failed', { attempts: this._reconnectAttempt })
      return
    }
    const attempt = ++this._reconnectAttempt
    const delay = Math.min(this._reconnectBaseMs * (2 ** (attempt - 1)), this._reconnectMaxMs)
    this._emit('reconnecting', { attempt, delay })
    this._reconnectTimer = setTimeout(() => {
      this._reconnectTimer = null
      this._connect()
    }, delay)
  }

  on (event, fn) {
    if (this._listeners[event]) this._listeners[event].push(fn)
    return this
  }

  write (frame) {
    if (!this._connected || !this._ws) throw new Error('WebSocket RPC connection is not open')
    this._ws.send(frame)
    return true
  }
}

function frameRpc (msg) {
  const json = JSON.stringify(msg)
  return json.length.toString(16).padStart(8, '0') + json
}

function diagnosticUrlFor (url) {
  const u = new URL(url)
  u.pathname = '/status-smoke'
  u.search = `?session=${encodeURIComponent(RPC_SESSION_TOKEN)}`
  u.hash = ''
  return u.toString()
}

function rendererUrlFor (port) {
  if (!RPC_SESSION_TOKEN) throw new Error('PearBrowser v3 runtime session token is unavailable')
  return `ws://127.0.0.1:${port}/?session=${encodeURIComponent(RPC_SESSION_TOKEN)}`
}

function parseRpcFrames (state, data) {
  state.buffer += typeof data === 'string'
    ? data
    : new TextDecoder().decode(data)
  const out = []
  while (state.buffer.length >= 8) {
    const len = parseInt(state.buffer.slice(0, 8), 16)
    if (isNaN(len) || len <= 0 || len > 10_000_000) {
      throw new Error('invalid rpc frame')
    }
    if (state.buffer.length < 8 + len) break
    const json = state.buffer.slice(8, 8 + len)
    state.buffer = state.buffer.slice(8 + len)
    out.push(JSON.parse(json))
  }
  return out
}

function probeBackend (url, timeoutMs) {
  return new Promise((resolve, reject) => {
    const probeUrl = diagnosticUrlFor(url)
    const ws = new WebSocket(probeUrl)
    ws.binaryType = 'arraybuffer'
    const state = { buffer: '' }
    let settled = false
    const t = setTimeout(() => finish(new Error('probe timeout')), timeoutMs)

    function finish (err) {
      if (settled) return
      settled = true
      clearTimeout(t)
      try { ws.close() } catch {}
      err ? reject(err) : resolve()
    }

    ws.addEventListener('open', () => {
      ws.send(frameRpc({ id: RPC_PROBE_ID, cmd: C.CMD_GET_STATUS, data: {} }))
    })
    ws.addEventListener('message', (e) => {
      let messages
      try { messages = parseRpcFrames(state, e.data) } catch (err) {
        finish(err)
        return
      }
      for (const msg of messages) {
        if (msg?.event === 'backend-boot-failed') return finish(null)
        if (msg?.id === RPC_PROBE_ID) return msg.error ? finish(new Error(msg.error)) : finish(null)
      }
    })
    ws.addEventListener('error', () => finish(new Error('probe error')))
    ws.addEventListener('close', () => finish(new Error('probe closed')))
  })
}

function tryConnect (url, timeoutMs) {
  return probeBackend(url, timeoutMs).then(() => new Promise((resolve, reject) => {
    const pipe = new WsPipe(url)
    let settled = false
    const finish = (err = null) => {
      if (settled) return
      settled = true
      clearTimeout(t)
      if (err) {
        pipe.destroy()
        reject(err)
      } else {
        pipe.enableReconnect()
        resolve(pipe)
      }
    }
    const t = setTimeout(() => finish(new Error('timeout')), timeoutMs)
    pipe.on('open', () => finish())
    pipe.on('error', () => finish(new Error('ws error')))
    pipe.on('close', () => finish(new Error('ws closed')))
  }))
}

export async function startBackend () {
  let pipe = null
  let connectedPort = null
  const errors = []
  for (let p = RPC_PORT_BASE; p < RPC_PORT_BASE + RPC_PORT_COUNT; p++) {
    try {
      pipe = await tryConnect(rendererUrlFor(p), 1500)
      connectedPort = p
      console.log('[rpc] connected on :' + p)
      break
    } catch (err) {
      errors.push(`:${p} ${err.message}`)
    }
  }
  if (!pipe) {
    // None of the ports accepted. The Bare main process is either not
    // running or crashed before binding the WS server. Since v0.4.4
    // the main process catches synchronous boot failures and still
    // binds the WS — emitting a `backend-boot-failed` event — so a
    // pure port-scan failure means the native host itself did not start, not
    // that the backend reported a structured boot failure.
    //
    // Most common cause: an interrupted local installation. Do not revive a
    // remote app reference; reinstall the verified signed native package.
    throw new Error(
      `Could not reach backend on any port ${RPC_PORT_BASE}-${RPC_PORT_BASE + RPC_PORT_COUNT - 1} ` +
      `(${errors.join('; ')}). The Bare main process appears not to be running. ` +
      `Most likely cause: an interrupted installation. Reinstall the verified ` +
      `signed native package and relaunch it.`
    )
  }
  const rpc = new RpcClient(pipe)
  return { rpc, C, pipe, storagePath: `(backend in main Bare process, WS :${connectedPort})` }
}