// 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 } }
}
})()