OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / pear-plugins.cjs

246 lines · javascript ref:
'use strict'

/**
 * Pear Plugins foundation (Phase 3 of docs/BROWSER_PARITY_PLAN.md).
 *
 * Plugins are Hyperdrive-style apps with extra capabilities declared in
 * manifest.json. They are NOT Chrome MV2/MV3 extensions — there is no
 * webRequest API surface. Contributions feed the same ContentShield engine
 * and HTML inject path as browser-owned rules.
 *
 * Capability vocabulary:
 *   pear.net.filter       — contribute network filter rules (namespaced)
 *   pear.content.styles   — contribute cosmetic/content CSS for matched hosts
 *   pear.content.scripts  — contribute content scripts (hash-authorized inject)
 *   pear.panel            — reserved (chrome side-panel; not enforced here)
 *   pear.ai.infer         — already exists for AI; not plugin-specific
 *
 * Fail-closed: missing pear.plugin metadata, missing capability, or kill
 * switch → contribution has no effect.
 */

const PLUGIN_CAPABILITIES = Object.freeze([
  'pear.net.filter',
  'pear.content.styles',
  'pear.content.scripts',
  'pear.panel',
  'pear.ai.infer'
])

class PearPluginRegistry {
  /**
   * @param {object} opts
   * @param {import('./content-shield.cjs').ContentShield} [opts.shield]
   */
  constructor (opts = {}) {
    this._shield = opts.shield || null
    /** @type {Map<string, PluginRecord>} */
    this._plugins = new Map()
  }

  setContentShield (shield) {
    this._shield = shield || null
  }

  /**
   * Register (or replace) a plugin from a parsed manifest + optional
   * contribution payload. Returns a structured result; never throws for
   * policy failures (fail-closed).
   *
   * @param {object} input
   * @param {string} input.id — stable plugin id (usually drive key hex)
   * @param {object} input.manifest — drive /manifest.json contents
   * @param {object} [input.contribution] — { filters, styles, scripts }
   * @param {boolean} [input.enabled]
   */
  register (input = {}) {
    const id = normalizePluginId(input.id)
    if (!id) return { ok: false, reason: 'missing-plugin-id' }

    const parsed = parsePluginManifest(input.manifest)
    if (!parsed.ok) return parsed

    const enabled = input.enabled !== false
    const contribution = input.contribution || extractContributionFromManifest(input.manifest) || {}

    const record = {
      id,
      name: parsed.name,
      version: parsed.version,
      capabilities: parsed.capabilities,
      contribution,
      enabled,
      registeredAt: Date.now()
    }
    this._plugins.set(id, record)

    if (this._shield) {
      this._shield.setPluginEnabled(id, enabled)
      if (enabled) {
        this._shield.applyPluginContribution(id, contribution, parsed.capabilities)
      } else {
        // Ensure namespaced rules are stripped while disabled.
        this._shield.applyPluginContribution(id, contribution, parsed.capabilities)
        this._shield.setPluginEnabled(id, false)
      }
    }

    return {
      ok: true,
      id,
      name: record.name,
      capabilities: record.capabilities,
      enabled,
      applied: summarizeApplied(parsed.capabilities, contribution, enabled)
    }
  }

  /** One-click kill switch without uninstalling. */
  setEnabled (pluginId, enabled) {
    const id = normalizePluginId(pluginId)
    const record = this._plugins.get(id)
    if (!record) return { ok: false, reason: 'unknown-plugin' }
    record.enabled = !!enabled
    if (this._shield) {
      this._shield.setPluginEnabled(id, record.enabled)
      if (record.enabled) {
        this._shield.applyPluginContribution(id, record.contribution, record.capabilities)
      } else {
        // Strip filter list; style/script inject paths honor isPluginEnabled.
        this._shield.applyPluginContribution(id, record.contribution, record.capabilities)
        this._shield.setPluginEnabled(id, false)
      }
    }
    return { ok: true, id, enabled: record.enabled }
  }

  unregister (pluginId) {
    const id = normalizePluginId(pluginId)
    const record = this._plugins.get(id)
    if (!record) return false
    this._plugins.delete(id)
    if (this._shield) {
      this._shield.setPluginEnabled(id, false)
      this._shield.applyPluginContribution(id, {}, []) // clears styles/scripts maps
      // removeList via empty filters with cap would not clear; force list drop
      const { PLUGIN_LIST_PREFIX } = require('./content-shield.cjs')
      this._shield.removeList(PLUGIN_LIST_PREFIX + id)
    }
    return true
  }

  get (pluginId) {
    const record = this._plugins.get(normalizePluginId(pluginId))
    return record ? publicPluginView(record) : null
  }

  list () {
    return [...this._plugins.values()].map(publicPluginView)
  }

  /**
   * Fail-closed capability check used by callers before exposing surfaces.
   */
  hasCapability (pluginId, capability) {
    const record = this._plugins.get(normalizePluginId(pluginId))
    if (!record || !record.enabled) return false
    return record.capabilities.includes(String(capability))
  }
}

/**
 * Parse pear.plugin metadata from a drive manifest. Fail-closed when
 * pear.plugin is missing or not truthy.
 */
function parsePluginManifest (manifest) {
  if (!manifest || typeof manifest !== 'object') {
    return { ok: false, reason: 'missing-manifest' }
  }
  const pear = manifest.pear && typeof manifest.pear === 'object' ? manifest.pear : null
  const pluginFlag = pear
    ? (pear.plugin === true || pear.plugin === 1 || (pear.plugin && typeof pear.plugin === 'object'))
    : (manifest.plugin === true)
  if (!pluginFlag) {
    return { ok: false, reason: 'not-a-plugin' }
  }

  const rawCaps = []
  if (pear && Array.isArray(pear.capabilities)) rawCaps.push(...pear.capabilities)
  if (Array.isArray(manifest.capabilities)) rawCaps.push(...manifest.capabilities)
  if (pear && pear.plugin && typeof pear.plugin === 'object' && Array.isArray(pear.plugin.capabilities)) {
    rawCaps.push(...pear.plugin.capabilities)
  }

  const capabilities = [...new Set(rawCaps.map(String).filter((c) => PLUGIN_CAPABILITIES.includes(c)))]
  const name = String(
    (pear && pear.plugin && pear.plugin.name) ||
    manifest.name ||
    manifest.title ||
    'plugin'
  )
  const version = String(manifest.version || (pear && pear.plugin && pear.plugin.version) || '0.0.0')

  return { ok: true, name, version, capabilities }
}

function extractContributionFromManifest (manifest) {
  if (!manifest || typeof manifest !== 'object') return null
  const pear = manifest.pear && typeof manifest.pear === 'object' ? manifest.pear : {}
  const plugin = pear.plugin && typeof pear.plugin === 'object' ? pear.plugin : {}
  const content = plugin.content || pear.content || manifest.content || null
  if (!content || typeof content !== 'object') {
    // Allow top-level contribution fields for fixtures.
    if (manifest.filters || manifest.styles || manifest.scripts) {
      return {
        filters: manifest.filters || '',
        styles: manifest.styles || null,
        scripts: manifest.scripts || null
      }
    }
    return null
  }
  return {
    filters: content.filters || content.filterList || '',
    styles: content.styles || content.css || null,
    scripts: content.scripts || content.js || null
  }
}

function normalizePluginId (value) {
  if (typeof value !== 'string') return ''
  const text = value.trim().toLowerCase()
  if (!text || text.length > 128) return ''
  // Accept full drive keys or short fixture ids.
  if (/^[0-9a-f]{64}$/.test(text)) return text
  if (/^[a-z0-9][a-z0-9._-]{0,63}$/.test(text)) return text
  return ''
}

function publicPluginView (record) {
  return {
    id: record.id,
    name: record.name,
    version: record.version,
    capabilities: [...record.capabilities],
    enabled: record.enabled,
    registeredAt: record.registeredAt
  }
}

function summarizeApplied (capabilities, contribution, enabled) {
  if (!enabled) return { filters: false, styles: false, scripts: false, reason: 'disabled' }
  const caps = new Set(capabilities)
  return {
    filters: !!(caps.has('pear.net.filter') && contribution && contribution.filters),
    styles: !!(caps.has('pear.content.styles') && contribution && contribution.styles),
    scripts: !!(caps.has('pear.content.scripts') && contribution && contribution.scripts)
  }
}

module.exports = {
  PearPluginRegistry,
  parsePluginManifest,
  extractContributionFromManifest,
  PLUGIN_CAPABILITIES
}