OverviewFilesCommitsIssuesDocsReleasesPeers

mirall-cli / web / bridge-shim.js

143 lines · javascript ref:
// bridge-shim.js — defines window.bridge for a BROWSER talking to a local headless
// mirall-cli daemon. Replaces Electron's preload. The whole renderer↔worker data path
// (every request/response + event) is raw NDJSON over onWorkerIPC/writeWorkerIPC; we
// route those to the daemon via SSE (worker→browser) + POST (browser→worker). Everything
// else is desktop chrome, stubbed for the browser. LOCAL-ONLY: the daemon is your machine.
(function () {
  var ipcSubs = new Set()
  var noop = function () {}

  // worker → browser: SSE stream of base64-encoded NDJSON chunks (exact bytes, framing-safe)
  var es = new EventSource('/worker/events')
  es.onmessage = function (e) {
    var bin = atob(e.data)
    var bytes = new Uint8Array(bin.length)
    for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
    ipcSubs.forEach(function (cb) { try { cb(bytes) } catch (err) { console.error(err) } })
  }
  es.onerror = function () { /* EventSource auto-reconnects */ }

  // getConfig() must return the FULL config shape the renderer expects (it reads
  // config.appearance.theme/.locale, config.ui.*, config.notifications synchronously at boot) —
  // mirrors src/main/config-store.js defaults() (CONFIG_VERSION = 1).
  var DEFAULT_CONFIG = {
    version: 1,
    window: { bounds: null, zoom: 1 },
    appearance: { theme: 'system', locale: null },
    general: { minimizeToTray: true, openAtLogin: false, firstHideNoticeShown: false, appMenuAutoHide: false },
    downloads: { folder: null },
    storage: { cacheBudgetBytes: 0 },
    notifications: null,
    ui: { lastSeenVersion: null, feedbackEmail: '' }
  }
  function isObj (v) { return v && typeof v === 'object' && !Array.isArray(v) }
  function deepMerge (base, over) {
    var out = {}; for (var k in base) out[k] = base[k]
    for (var j in (over || {})) { out[j] = isObj(over[j]) && isObj(base[j]) ? deepMerge(base[j], over[j]) : over[j] }
    return out
  }
  var cfg = function () { var s; try { s = JSON.parse(localStorage.getItem('mirall:config') || '{}') } catch (e) { s = {} } return deepMerge(DEFAULT_CONFIG, s) }
  var setCfg = function (patch) { var m = deepMerge(cfg(), patch || {}); localStorage.setItem('mirall:config', JSON.stringify(m)); return m }

  // Folder picker: replaces Electron's native dialog. Browses the DAEMON's filesystem via
  // /fs/list — and since the daemon is LOCAL, that's your own machine. Resolves the chosen
  // absolute path (string), or null on cancel. Matches browseShareFolder(): Promise<string|null>.
  function pickFolder () {
    return new Promise(function (resolve) {
      var cur = null
      var ov = document.createElement('div')
      ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2147483647;display:flex;align-items:center;justify-content:center;font-family:system-ui,sans-serif'
      var box = document.createElement('div')
      box.style.cssText = 'background:#fff;color:#111;width:min(560px,92vw);max-height:78vh;border-radius:12px;box-shadow:0 12px 48px rgba(0,0,0,.4);display:flex;flex-direction:column;overflow:hidden'
      if (document.documentElement.classList.contains('dark')) { box.style.background = '#1b1b1f'; box.style.color = '#eee' }
      var title = document.createElement('div'); title.textContent = 'Choose a folder'; title.style.cssText = 'padding:14px 16px;font-weight:600;border-bottom:1px solid rgba(128,128,128,.25)'
      var pathEl = document.createElement('div'); pathEl.style.cssText = 'padding:8px 16px;font-size:13px;opacity:.75;word-break:break-all'
      var list = document.createElement('div'); list.style.cssText = 'overflow:auto;flex:1;min-height:140px;border-top:1px solid rgba(128,128,128,.15)'
      var footer = document.createElement('div'); footer.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;padding:12px 16px;border-top:1px solid rgba(128,128,128,.25)'
      var cancel = document.createElement('button'); cancel.textContent = 'Cancel'; cancel.style.cssText = 'padding:8px 14px;border-radius:8px;border:1px solid rgba(128,128,128,.4);background:transparent;color:inherit;cursor:pointer'
      var sel = document.createElement('button'); sel.textContent = 'Select this folder'; sel.style.cssText = 'padding:8px 14px;border-radius:8px;border:0;background:#2563eb;color:#fff;cursor:pointer'
      footer.appendChild(cancel); footer.appendChild(sel)
      box.appendChild(title); box.appendChild(pathEl); box.appendChild(list); box.appendChild(footer); ov.appendChild(box); document.body.appendChild(ov)
      function done (v) { try { document.body.removeChild(ov) } catch (e) {} resolve(v) }
      cancel.onclick = function () { done(null) }
      sel.onclick = function () { done(cur) }
      ov.onclick = function (e) { if (e.target === ov) done(null) }
      function row (label, isDir, onClick) {
        var r = document.createElement('div')
        r.textContent = (isDir ? '📁  ' : '📄  ') + label
        r.style.cssText = 'padding:9px 16px;font-size:14px;' + (isDir ? 'cursor:pointer;' : 'opacity:.4;')
        if (onClick) { r.onmouseenter = function () { r.style.background = 'rgba(128,128,128,.15)' }; r.onmouseleave = function () { r.style.background = '' }; r.onclick = onClick }
        list.appendChild(r)
      }
      function load (p) {
        fetch('/fs/list' + (p ? '?path=' + encodeURIComponent(p) : '')).then(function (r) { return r.json() }).then(function (d) {
          cur = d.path; pathEl.textContent = d.path; list.innerHTML = ''
          if (d.parent) row('.. (up)', true, function () { load(d.parent) })
          d.entries.forEach(function (e) { row(e.name, e.dir, e.dir ? function () { load(d.path + '/' + e.name) } : null) })
        }).catch(function () { pathEl.textContent = '(cannot read this folder)' })
      }
      load(null)
    })
  }

  window.bridge = {
    // ── LOAD-BEARING: the raw NDJSON worker pipe ──
    startWorker: function () { return Promise.resolve() },              // daemon already runs the worker
    writeWorkerIPC: function (_spec, data) {                            // renderer → worker
      var buf = data instanceof Uint8Array ? data : new Uint8Array(data)
      return fetch('/worker/write', { method: 'POST', body: buf })
    },
    onWorkerIPC: function (_spec, cb) { ipcSubs.add(cb); return function () { ipcSubs.delete(cb) } },  // worker → renderer
    onWorkerStdout: function () { return noop }, onWorkerStderr: function () { return noop }, onWorkerExit: function () { return noop },

    // ── app info (SYNC in Electron) ──
    pkg: function () { return { name: 'mirall', version: '0.1.0' } },
    isDev: function () { return false },
    getLocale: function () { return navigator.language || 'en' },
    getPlatform: function () { return 'web' },
    appVersion: function () { return Promise.resolve('0.1.0') },
    getChangelog: function () { return Promise.resolve('') },
    getIdentityProtection: function () { return Promise.resolve('strong') },
    setVerbose: function () { return Promise.resolve() },
    checkForUpdate: function () { return Promise.resolve({ available: false }) },
    applyUpdate: function () { return Promise.resolve() },

    // ── config / prefs → localStorage (getConfig is SYNC) ──
    getConfig: cfg,
    setConfig: function (patch) { return Promise.resolve(setCfg(patch)) },
    getPrefs: function () { try { return Promise.resolve(JSON.parse(localStorage.getItem('mirall:prefs') || '{}')) } catch (e) { return Promise.resolve({}) } },
    setPrefs: function (p) { var c = Object.assign({}, p); localStorage.setItem('mirall:prefs', JSON.stringify(c)); return Promise.resolve(c) },

    // ── window chrome → no-ops ──
    getWindowBounds: function () { return Promise.resolve(null) }, setWindowBounds: function () { return Promise.resolve() },
    getZoom: function () { return Promise.resolve(1) }, setZoom: function () { return Promise.resolve() },
    setTheme: function () { return Promise.resolve() },
    setTrayLabels: function () { return Promise.resolve() }, menuContextChanged: function () { return Promise.resolve() },

    // ── downloads / folders (LOCAL machine — fs-browse picker replaces these, see notes) ──
    // Read/write config.downloads.folder — the real config shape (DEFAULT_CONFIG above). The
    // earlier top-level `downloadFolder` key never matched, so the folder always read back empty.
    getDownloadFolder: function () { return Promise.resolve((cfg().downloads || {}).folder || '') },
    setDownloadFolder: function (f) { return Promise.resolve(setCfg({ downloads: { folder: f } })) },
    browseDownloadFolder: function () { return pickFolder() },
    browseShareFolder: function () { return pickFolder() },
    startOwnedFolderWatcher: function () { return Promise.resolve() }, stopOwnedFolderWatcher: function () { return Promise.resolve() },

    // ── notifications → browser Notification API ──
    notify: function (s) { try { new Notification(s.title, { body: s.body }) } catch (e) {} return Promise.resolve() },
    notifyIsSupported: function () { return Promise.resolve('Notification' in window) },
    isWindowFocused: function () { return Promise.resolve(document.hasFocus()) },
    focusWindow: function () { window.focus(); return Promise.resolve() },
    showInFolder: function () { return Promise.resolve() },

    // ── drag-drop file path — not available in a browser (fs-browse picker instead) ──
    getPathForFile: function () { return '' },

    // ── event subscriptions → harmless no-op unsubscribers ──
    onMainLog: function () { return noop }, onPearEvent: function () { return noop }, onZoomChanged: function () { return noop },
    onNotificationClick: function () { return noop }, onFirstHideNotice: function () { return noop }, onHiddenToTray: function () { return noop },
    onKeyboardCommand: function () { return noop },
    deepLink: { subscribe: function () { return noop } }
  }
})()