Bare Caps
CI / Build & Test (push) Successful in 3m7s

This commit is contained in:
Raven Scott
2026-07-26 23:09:00 -04:00
parent f4569b765e
commit 631f9c4023
19 changed files with 1412 additions and 108 deletions
+5
View File
@@ -38,6 +38,9 @@ P2P: [Browser] ◄──────► [Browser]
- **Protomux** - Protocol multiplexing over connections - **Protomux** - Protocol multiplexing over connections
- **HRPC** - Remote procedure calls with streaming support - **HRPC** - Remote procedure calls with streaming support
### Optional capabilities
- **Media pack** - Host-side image/video via `bare-media` + `bare-ffmpeg` (`BridgeSwarm.media.*`); separate install — see [docs/CAPABILITIES.md](docs/CAPABILITIES.md)
## Quick Start ## Quick Start
### Easy install (recommended) ### Easy install (recommended)
@@ -297,6 +300,7 @@ For comprehensive documentation, see:
- **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** - System architecture and message flow - **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** - System architecture and message flow
- **[docs/API-REFERENCE.md](docs/API-REFERENCE.md)** - Full API reference - **[docs/API-REFERENCE.md](docs/API-REFERENCE.md)** - Full API reference
- **[docs/DATA-API.md](docs/DATA-API.md)** - Data storage API - **[docs/DATA-API.md](docs/DATA-API.md)** - Data storage API
- **[docs/CAPABILITIES.md](docs/CAPABILITIES.md)** - Optional Bare capability packs (media)
- **[docs/PROTOMUX.md](docs/PROTOMUX.md)** - Protocol multiplexing - **[docs/PROTOMUX.md](docs/PROTOMUX.md)** - Protocol multiplexing
- **[docs/HRPC.md](docs/HRPC.md)** - RPC with streaming - **[docs/HRPC.md](docs/HRPC.md)** - RPC with streaming
@@ -346,6 +350,7 @@ BridgeSwarm/
│ ├── ARCHITECTURE.md │ ├── ARCHITECTURE.md
│ ├── API-REFERENCE.md │ ├── API-REFERENCE.md
│ ├── DATA-API.md │ ├── DATA-API.md
│ ├── CAPABILITIES.md
│ ├── PROTOMUX.md │ ├── PROTOMUX.md
│ └── HRPC.md │ └── HRPC.md
├── examples/ # Example applications ├── examples/ # Example applications
+26
View File
@@ -26,6 +26,10 @@ These are the main APIs your page uses. For host request types and event payload
- **`BridgeSwarm.request(type, payload, options)`** — Sends a request to the host. Optional third argument: `{ timeoutMs: number }` aborts after that many milliseconds and rejects with `"Request timed out after N ms"`. If the page does not pass `timeoutMs`, the extensions **default request timeout** (from the options page) is used when it is > 0. - **`BridgeSwarm.request(type, payload, options)`** — Sends a request to the host. Optional third argument: `{ timeoutMs: number }` aborts after that many milliseconds and rejects with `"Request timed out after N ms"`. If the page does not pass `timeoutMs`, the extensions **default request timeout** (from the options page) is used when it is > 0.
- **`BridgeSwarm.capabilities`** — Optional Bare capability packs on the host. `list()`, `has(pack)`, `call(pack, cmd, payload)`, `on(event, fn)`. See [CAPABILITIES.md](CAPABILITIES.md).
- **`BridgeSwarm.media.*`** — Media pack wrappers (`info`, `imageTransform`, `extractFrame`, `transcode`, `cancel`, `writeInput`, `readOutput`). Requires the media host artifact.
--- ---
## Host request types and events ## Host request types and events
@@ -134,9 +138,31 @@ Full details and examples: [DATA-API.md](DATA-API.md).
--- ---
## Capabilities (optional packs)
| Type | Payload | Response |
|------|---------|----------|
| `capabilities.list` | `{}` | `{ ok, packs }` |
| `capabilities.has` | `{ pack }` | `{ ok, pack, has }` |
| `capability` | `{ pack, cmd, payload }` | pack-specific |
| `media.info` / `media.imageTransform` / `media.extractFrame` / `media.transcode` / `media.cancel` / … | command payload | see [CAPABILITIES.md](CAPABILITIES.md) |
### Capability events
| Event | Payload |
|-------|---------|
| `cap-chunk` | `{ pack, jobId, index?, data?, progress?, bytes? }` |
| `cap-end` | `{ pack, jobId, … }` |
| `cap-error` | `{ pack, jobId, message }` |
These events are broadcast to all subscribed tabs (no `swarmId` filter).
---
## See also ## See also
- [DATA-API.md](DATA-API.md) — Data API details and examples. - [DATA-API.md](DATA-API.md) — Data API details and examples.
- [CAPABILITIES.md](CAPABILITIES.md) — Optional Bare capability packs (media, install, security).
- [HRPC.md](HRPC.md) — HRPC (attachHrpc) and commands. - [HRPC.md](HRPC.md) — HRPC (attachHrpc) and commands.
- [PROTOMUX.md](PROTOMUX.md) — Protomux in the browser and connection attachment. - [PROTOMUX.md](PROTOMUX.md) — Protomux in the browser and connection attachment.
- [ARCHITECTURE.md](ARCHITECTURE.md) — Message flow and components. - [ARCHITECTURE.md](ARCHITECTURE.md) — Message flow and components.
+114
View File
@@ -0,0 +1,114 @@
# Capabilities
BridgeSwarm exposes selected [Bare](https://github.com/holepunchto/bare) native APIs to the page as **optional capability packs**. The default host stays swarm/data-focused and does **not** ship heavy addons like `bare-ffmpeg` (~400+ MB unpacked).
Curated packs — not “every `bare-*` package” — is an intentional design choice.
## Page API
```js
await BridgeSwarm.capabilities.list() // e.g. ['media'] or []
await BridgeSwarm.capabilities.has('media')
// Generic dispatch
await BridgeSwarm.capabilities.call('media', 'info', { path: '…' })
// Thin wrappers (Pack 1)
await BridgeSwarm.media.info({ dataBase64, filename })
await BridgeSwarm.media.imageTransform({ dataBase64, maxWidth: 640, mimetype: 'image/webp' })
await BridgeSwarm.media.extractFrame({ dataBase64, frameIndex: 0 })
await BridgeSwarm.media.transcode({ dataBase64, format: 'webm' }, { onProgress })
await BridgeSwarm.media.cancel(jobId)
```
Streaming results use host events (under the ~1 MB native-messaging limit):
| Event | Payload |
|-------|---------|
| `cap-chunk` | `{ pack, jobId, index?, data? (base64), progress?, bytes? }` |
| `cap-end` | `{ pack, jobId, chunks?, path?, mimetype?, … }` |
| `cap-error` | `{ pack, jobId, message }` |
`BridgeSwarm.media.*` helpers assign a `jobId`, listen for these events, and resolve with assembled `dataBase64` (or a host-relative `path` for large transcodes).
You can also subscribe manually:
```js
const off = BridgeSwarm.capabilities.on('cap-chunk', (p) => console.log(p))
```
## Security
Every pack that touches the filesystem is **allowlisted** under:
```text
$BRIDGE_SWARM_STORAGE/cap-jobs/
```
(default: `~/.bridgeswarm/bridge-swarm-storage/cap-jobs/`). Absolute paths outside that tree are rejected. Large outputs are written there and returned as relative paths; the page can stream them back with `media.readOutput`.
## Pack 1 — Media
| Command | Behavior |
|---------|----------|
| `media.info` | Probe image/video metadata |
| `media.imageTransform` | Decode → optional resize/crop → encode (webp/jpeg/png); stream base64 |
| `media.extractFrame` | Extract one video frame → encode; stream base64 |
| `media.transcode` | Async job to webm/mp4/mkv (VP9+Opus where supported); progress events |
| `media.cancel` | Cancel in-flight job |
| `media.writeInput` / `media.readOutput` | Chunked upload / download under `cap-jobs/` |
### Install media host
Default `latest-main` artifacts are **lean** (no ffmpeg). Build and install the media variant:
```bash
# From a clone (heavy; downloads native prebuilds)
npm run build:dist:media
# Install into ~/.bridgeswarm (backs up lean host to bridge-swarm-host.lean.bak)
npm run install:capability:media
```
Or download `bridge-swarm-host-media-<platform>-<arch>.zip` from releases and point `BRIDGE_SWARM_MEDIA_URL` at it.
On macOS the install script extracts addons into `~/.bridgeswarm/tmp` and codesigns `*.bare` / `*.dylib` (same Gatekeeper fix as the lean host). Then **fully quit** the browser and reload the extension.
Verify:
```js
await BridgeSwarm.capabilities.has('media') // true
```
Demo: [`examples/media-demo/`](../examples/media-demo/) (`npm run examples``/media-demo/`).
### Local Bare (dev)
```bash
cd native-host
npm install bare-media
# run entry that registers the pack:
node ./node_modules/bare/bin/bare index-media.mjs
```
Point the native messaging manifest at that process the same way as the lean host.
## Planned packs (not in this release)
| Pack | Notes |
|------|--------|
| **fs / sqlite** | RPC over `bare-fs` / `bare-sqlite` under allowlisted roots |
| **net** | Host `fetch`, optional tcp/dgram — CORS-free from the page |
UI/mobile/build Bare packages (`bare-gtk`, `bare-ios`, `bare-build`, …) are out of scope for BridgeSwarm.
## Host protocol
| Type | Payload | Response |
|------|---------|----------|
| `capabilities.list` | `{}` | `{ ok, packs: string[] }` |
| `capabilities.has` | `{ pack }` | `{ ok, pack, has }` |
| `capability` | `{ pack, cmd, payload }` | pack-specific |
| `media.<cmd>` | same as `payload` | shortcut when pack installed |
Unknown pack → `{ ok: false, error: "capability 'media' not installed" }`.
+5
View File
@@ -145,6 +145,11 @@
<p>Hypercore, Hyperbee, Hyperdrive, Autobase, and Hyperdb from the page.</p> <p>Hypercore, Hyperbee, Hyperdrive, Autobase, and Hyperdb from the page.</p>
<span class="path">/data-demo/</span> <span class="path">/data-demo/</span>
</a> </a>
<a class="card" href="./media-demo/">
<h2>Media Capability</h2>
<p>Optional ffmpeg pack: probe, WebP transform, extract frame, transcode.</p>
<span class="path">/media-demo/</span>
</a>
</div> </div>
<footer> <footer>
+173
View File
@@ -0,0 +1,173 @@
(function () {
const logEl = document.getElementById('log');
function log(msg, type) {
const line = document.createElement('div');
line.className = type || 'sys';
line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg;
logEl.appendChild(line);
logEl.scrollTop = logEl.scrollHeight;
}
function fileToBase64(file) {
return new Promise(function (resolve, reject) {
const reader = new FileReader();
reader.onload = function () {
const dataUrl = reader.result;
const i = String(dataUrl).indexOf(',');
resolve(i >= 0 ? dataUrl.slice(i + 1) : dataUrl);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function showDataUrl(imgEl, mimetype, dataBase64) {
if (!dataBase64) return;
imgEl.src = 'data:' + (mimetype || 'image/webp') + ';base64,' + dataBase64;
imgEl.hidden = false;
}
function setButtons(enabled) {
['probe', 'transform', 'extract', 'transcode'].forEach(function (id) {
document.getElementById(id).disabled = !enabled;
});
}
function initWhenReady(attempts) {
if (attempts >= 50) {
log('BridgeSwarm not available. Install extension and reload.', 'err');
return;
}
if (typeof window.BridgeSwarm === 'undefined') {
setTimeout(function () { initWhenReady(attempts + 1); }, 100);
return;
}
window.BridgeSwarm.ready().then(async function () {
const hasMedia = await window.BridgeSwarm.capabilities.has('media');
const packs = await window.BridgeSwarm.capabilities.list();
log('Capability packs: ' + (packs.length ? packs.join(', ') : '(none)'));
if (!hasMedia) {
document.getElementById('missing').hidden = false;
setButtons(false);
log('Install media capability to enable this demo.', 'err');
return;
}
document.getElementById('ready').hidden = false;
setButtons(true);
log('Media pack ready.', 'val');
async function currentUpload() {
const file = document.getElementById('file').files[0];
if (!file) throw new Error('Choose a file first');
if (file.size > 12 * 1024 * 1024) {
throw new Error('Demo limit: keep uploads under ~12 MB (NMH / base64)');
}
const dataBase64 = await fileToBase64(file);
return { file: file, dataBase64: dataBase64 };
}
document.getElementById('probe').onclick = async function () {
try {
const up = await currentUpload();
const r = await window.BridgeSwarm.media.info({
dataBase64: up.dataBase64,
filename: up.file.name,
});
log('info: ' + JSON.stringify(r, null, 0).slice(0, 400), 'val');
} catch (e) {
log(e.message, 'err');
}
};
document.getElementById('transform').onclick = async function () {
try {
const up = await currentUpload();
if (!up.file.type.startsWith('image/')) {
log('Pick an image for transform (or use extract frame for video).', 'err');
return;
}
log('Transforming…');
const r = await window.BridgeSwarm.media.imageTransform({
dataBase64: up.dataBase64,
filename: up.file.name,
maxWidth: 640,
maxHeight: 640,
mimetype: 'image/webp',
});
showDataUrl(document.getElementById('preview'), r.mimetype, r.dataBase64);
log('imageTransform ok (' + (r.dataBase64 ? r.dataBase64.length : 0) + ' b64 chars)', 'val');
} catch (e) {
log(e.message, 'err');
}
};
document.getElementById('extract').onclick = async function () {
try {
const up = await currentUpload();
if (!up.file.type.startsWith('video/')) {
log('Pick a video for frame extract.', 'err');
return;
}
const frameIndex = Number(document.getElementById('frameIndex').value) || 0;
log('Extracting frame ' + frameIndex + '…');
const r = await window.BridgeSwarm.media.extractFrame({
dataBase64: up.dataBase64,
filename: up.file.name,
frameIndex: frameIndex,
mimetype: 'image/webp',
});
showDataUrl(document.getElementById('frame'), r.mimetype, r.dataBase64);
log('extractFrame ok', 'val');
} catch (e) {
log(e.message, 'err');
}
};
document.getElementById('transcode').onclick = async function () {
const progressEl = document.getElementById('progress');
try {
const up = await currentUpload();
if (!up.file.type.startsWith('video/')) {
log('Pick a video to transcode.', 'err');
return;
}
log('Transcoding to webm (may take a while)…');
progressEl.textContent = 'starting…';
const r = await window.BridgeSwarm.media.transcode(
{
dataBase64: up.dataBase64,
filename: up.file.name,
format: 'webm',
width: 640,
height: 360,
},
{
timeoutMs: 300000,
onProgress: function (p) {
progressEl.textContent = p.bytes ? (Math.round(p.bytes / 1024) + ' KB written') : '…';
},
}
);
progressEl.textContent = 'done (' + (r.path || '') + ')';
log('transcode finished: ' + (r.path || JSON.stringify(r)), 'val');
if (r.path) {
const read = await window.BridgeSwarm.media.readOutput({
path: r.path,
mimetype: 'video/webm',
});
const video = document.getElementById('outVideo');
video.src = 'data:video/webm;base64,' + read.dataBase64;
video.hidden = false;
}
} catch (e) {
progressEl.textContent = '';
log(e.message, 'err');
}
};
}).catch(function (e) {
log(e.message, 'err');
});
}
initWhenReady(0);
})();
+75
View File
@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BridgeSwarm Media Capability Demo</title>
<link rel="stylesheet" href="style.css">
<script>
if (location.protocol === 'file:') {
document.documentElement.innerHTML =
'<body style="margin:0;font:15px/1.5 Segoe UI,system-ui,sans-serif;background:#0f1419;color:#e7ecf3">' +
'<div style="max-width:36rem;margin:10vh auto;padding:24px;background:#1a2332;border:1px solid #2a3548;border-radius:14px">' +
'<h1 style="margin:0 0 10px;font-size:1.25rem">Open this demo over HTTP</h1>' +
'<p style="color:#8b9bb4">From the BridgeSwarm repo root run:</p>' +
'<pre style="padding:12px;background:#0f1419;border-radius:10px;color:#2dd4bf">npm run examples</pre>' +
'<p style="color:#8b9bb4">Then open <code>http://127.0.0.1:4173/media-demo/</code></p>' +
'</div></body>';
}
</script>
</head>
<body>
<h1>BridgeSwarm Media Capability</h1>
<p>Uses optional host pack <code>media</code> (<code>bare-media</code> + <code>bare-ffmpeg</code>) via <code>BridgeSwarm.media.*</code>.</p>
<div id="missing" class="banner warn" hidden>
<strong>Media pack not installed.</strong>
Build with <code>npm run build:dist:media</code>, then
<code>npm run install:capability:media</code>, fully quit the browser, and reload.
See <code>docs/CAPABILITIES.md</code>.
</div>
<div id="ready" class="banner ok" hidden>
Media capability available. Pick an image or short video below.
</div>
<div class="section">
<h2>Input</h2>
<div class="row">
<input type="file" id="file" accept="image/*,video/*">
<button class="primary" id="probe" disabled>Probe info</button>
</div>
</div>
<div class="section">
<h2>Image → WebP</h2>
<div class="row">
<button class="secondary" id="transform" disabled>Transform (max 640px)</button>
</div>
<img id="preview" alt="Transformed preview" hidden>
</div>
<div class="section">
<h2>Video frame</h2>
<div class="row">
<input type="number" id="frameIndex" value="0" min="0" step="1" title="Frame index">
<button class="secondary" id="extract" disabled>Extract frame</button>
</div>
<img id="frame" alt="Extracted frame" hidden>
</div>
<div class="section">
<h2>Transcode (optional)</h2>
<div class="row">
<button class="secondary" id="transcode" disabled>Transcode → WebM</button>
<span id="progress" class="muted"></span>
</div>
<video id="outVideo" controls hidden></video>
</div>
<div class="section">
<h2>Log</h2>
<div id="log" class="log"></div>
</div>
<script src="app.js"></script>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 720px;
margin: 0 auto;
padding: 1rem;
background: #0f1419;
color: #e7ecf3;
min-height: 100vh;
}
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; color: #3d8bfd; }
h2 { font-size: 0.95rem; margin: 1rem 0 0.5rem; color: #2dd4bf; font-weight: 600; }
p { margin: 0 0 0.75rem; font-size: 0.9rem; color: #8b9bb4; }
.row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; align-items: center; flex-wrap: wrap; }
input[type="file"], input[type="number"] {
padding: 0.45rem 0.6rem;
border: 1px solid #2a3548;
border-radius: 6px;
background: #1a2332;
color: #e7ecf3;
font-size: 0.9rem;
}
button {
padding: 0.5rem 0.75rem;
border: none;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
font-weight: 500;
}
button:disabled { opacity: 0.45; cursor: not-allowed; }
button.primary { background: #3d8bfd; color: #0f1419; }
button.secondary { background: #2a3548; color: #e7ecf3; }
.banner {
padding: 12px 14px;
border-radius: 10px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.banner.warn {
border: 1px solid rgba(251, 191, 36, 0.35);
background: rgba(251, 191, 36, 0.08);
color: #fde68a;
}
.banner.ok {
border: 1px solid rgba(45, 212, 191, 0.35);
background: rgba(45, 212, 191, 0.08);
color: #99f6e4;
}
.banner code { color: #fff7c2; }
.banner.ok code { color: #ccfbf1; }
.muted { color: #8b9bb4; font-size: 0.85rem; }
img, video {
display: block;
max-width: 100%;
margin-top: 0.75rem;
border-radius: 8px;
border: 1px solid #2a3548;
background: #1a2332;
}
.log {
background: #1a2332;
border: 1px solid #2a3548;
border-radius: 6px;
padding: 0.6rem;
height: 200px;
overflow-y: auto;
font-family: ui-monospace, monospace;
font-size: 0.75rem;
line-height: 1.35;
}
.log .sys { color: #3d8bfd; }
.log .err { color: #f7768e; }
.log .val { color: #9ece6a; }
.section { margin-bottom: 1rem; }
+135
View File
@@ -468,6 +468,141 @@
return p; return p;
}; };
/** Collect cap-chunk / cap-end / cap-error for a jobId into one Promise. */
function awaitCapJob(jobId, options) {
const timeoutMs = (options && options.timeoutMs) || 120000;
let settled = false;
let cleanup = function () {};
const promise = new Promise(function (resolve, reject) {
const parts = [];
const timer = setTimeout(function () {
cleanup();
if (!settled) {
settled = true;
reject(new Error('Capability job timed out after ' + timeoutMs + ' ms'));
}
}, timeoutMs);
function onEvent(e) {
const msg = e.detail;
if (!msg || msg.type !== 'event') return;
const payload = msg.payload || {};
if (payload.jobId !== jobId) return;
if (msg.event === 'cap-chunk') {
if (payload.progress) {
if (options && options.onProgress) options.onProgress(payload);
return;
}
if (payload.data) parts[payload.index != null ? payload.index : parts.length] = payload.data;
if (options && options.onChunk) options.onChunk(payload);
return;
}
if (msg.event === 'cap-end') {
cleanup();
if (settled) return;
settled = true;
resolve({
...payload,
dataBase64: parts.length ? parts.join('') : undefined,
});
return;
}
if (msg.event === 'cap-error') {
cleanup();
if (settled) return;
settled = true;
reject(new Error(payload.message || 'Capability job failed'));
}
}
cleanup = function () {
clearTimeout(timer);
window.removeEventListener('bridge-swarm-event', onEvent);
};
window.addEventListener('bridge-swarm-event', onEvent);
});
promise.cancel = function () {
cleanup();
settled = true;
};
return promise;
}
function capabilityCall(pack, cmd, payload, options) {
payload = Object.assign({}, payload || {});
const streamCmds = { imageTransform: 1, extractFrame: 1, transcode: 1, readOutput: 1 };
const expectsStream = !!streamCmds[cmd];
if (expectsStream && !payload.jobId) {
payload.jobId = 'job_' + Date.now() + '_' + Math.random().toString(36).slice(2);
}
// Listen before the request so early cap-chunk events are not missed.
const jobWait = expectsStream ? awaitCapJob(payload.jobId, options) : null;
return BridgeSwarm.request('capability', { pack: pack, cmd: cmd, payload: payload }, options).then(function (r) {
if (!r || !r.ok) {
if (jobWait) jobWait.cancel();
return Promise.reject(new Error((r && r.error) || 'Capability request failed'));
}
if (jobWait && (r.streaming || r.status === 'started')) {
return jobWait.then(function (end) {
return Object.assign({}, r, end);
});
}
if (jobWait) jobWait.cancel();
return r;
}, function (err) {
if (jobWait) jobWait.cancel();
return Promise.reject(err);
});
}
BridgeSwarm.capabilities = {
list: function (options) {
return BridgeSwarm.request('capabilities.list', {}, options).then(function (r) {
return (r && r.packs) || [];
});
},
has: function (pack, options) {
return BridgeSwarm.request('capabilities.has', { pack: pack }, options).then(function (r) {
return !!(r && r.has);
});
},
call: capabilityCall,
on: function (eventName, fn) {
function handler(e) {
const msg = e.detail;
if (!msg || msg.type !== 'event') return;
if (msg.event !== eventName) return;
fn(msg.payload || {});
}
window.addEventListener('bridge-swarm-event', handler);
return function unsubscribe() {
window.removeEventListener('bridge-swarm-event', handler);
};
},
};
BridgeSwarm.media = {
info: function (payload, options) {
return capabilityCall('media', 'info', payload, options);
},
imageTransform: function (payload, options) {
return capabilityCall('media', 'imageTransform', payload, options);
},
extractFrame: function (payload, options) {
return capabilityCall('media', 'extractFrame', payload, options);
},
transcode: function (payload, options) {
return capabilityCall('media', 'transcode', payload, options);
},
cancel: function (jobId, options) {
return capabilityCall('media', 'cancel', { jobId: jobId }, options);
},
writeInput: function (payload, options) {
return capabilityCall('media', 'writeInput', payload, options);
},
readOutput: function (payload, options) {
return capabilityCall('media', 'readOutput', payload, options);
},
};
/** /**
* Returns a Promise that resolves with the BridgeSwarm constructor when the API is available. * Returns a Promise that resolves with the BridgeSwarm constructor when the API is available.
* Use this if your script runs at load time and the extension may inject api.js after your script. * Use this if your script runs at load time and the extension may inject api.js after your script.
+15 -3
View File
@@ -164,12 +164,24 @@ function connect() {
} }
if (msg.type === 'event') { if (msg.type === 'event') {
const eventSwarmId = msg.payload?.swarmId; const eventSwarmId = msg.payload?.swarmId;
const isCapEvent =
msg.event === 'cap-chunk' ||
msg.event === 'cap-end' ||
msg.event === 'cap-error' ||
!!msg.payload?.pack;
log('Broadcasting event to tabs:', msg.event, 'connId:', msg.payload?.connId, 'swarmId:', eventSwarmId); log('Broadcasting event to tabs:', msg.event, 'connId:', msg.payload?.connId, 'swarmId:', eventSwarmId);
// Only send to tabs that have registered this swarmId if (isCapEvent || eventSwarmId == null) {
for (const [tabId, swarmIds] of tabSwarms) { // Capability / global events: all subscribed tabs
if (swarmIds.has(eventSwarmId)) { for (const tabId of subscribedTabs) {
browser.tabs.sendMessage(tabId, { type: 'bridge-swarm-event', payload: msg }).catch(() => {}); browser.tabs.sendMessage(tabId, { type: 'bridge-swarm-event', payload: msg }).catch(() => {});
} }
} else {
// Only send to tabs that have registered this swarmId
for (const [tabId, swarmIds] of tabSwarms) {
if (swarmIds.has(eventSwarmId)) {
browser.tabs.sendMessage(tabId, { type: 'bridge-swarm-event', payload: msg }).catch(() => {});
}
}
} }
} }
}); });
+111
View File
@@ -0,0 +1,111 @@
/**
* Shared host bootstrap used by index.mjs (lean) and index-media.mjs (media pack).
*/
export function installConsoleToStderr() {
const _stderrWrite = (...args) => {
try {
const line = args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ') + '\n';
process.stderr.write(line);
} catch (_) {}
};
console.log = _stderrWrite;
console.info = _stderrWrite;
console.warn = _stderrWrite;
console.error = _stderrWrite;
console.debug = _stderrWrite;
}
export function logErr(msg) {
try {
process.stderr.write(`[bridge-swarm-host] ${msg}\n`);
} catch (_) {}
}
export const DEFAULT_ADDON_PACKAGES = [
'bare-fs',
'bare-pipe',
'bare-module',
'udx-native',
'sodium-native',
'rocksdb-native',
];
export const MEDIA_ADDON_PACKAGES = [
'bare-ffmpeg',
'bare-jpeg',
'bare-png',
'bare-webp',
'bare-gif',
'bare-heif',
'bare-bmp',
'bare-ico',
'bare-tiff',
'bare-svg',
'bare-image-resample',
'bare-exif',
];
export async function extractAddons(addonPackages) {
for (const name of addonPackages) {
try {
await import(name);
} catch (_) {}
}
process.exit(0);
}
export function startHost(messengerMod, hostMod) {
const { createMessenger } = messengerMod;
const { handleMessage, cleanup } = hostMod;
logErr('loading messenger...');
logErr('loading host (hyperswarm)...');
const input = process.stdin;
const output = process.stdout;
const messenger = createMessenger({
input,
output,
onMessage(msg) {
handleMessage((response) => messenger.send(response), msg).catch((err) => {
logErr(err.stack || err.message);
const id = msg && msg.id;
if (id) {
messenger.send({ id, type: 'response', payload: { ok: false, error: err.message } });
}
});
},
onError(err) {
logErr(err.message);
},
});
function shutdown() {
try {
cleanup();
} catch (_) {}
try {
messenger.destroy();
} catch (_) {}
process.exit(0);
}
process.on('exit', () => {
try {
cleanup();
} catch (_) {}
});
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
process.on('unhandledRejection', (reason) => {
logErr('unhandledRejection: ' + (reason && (reason.stack || reason.message || reason)));
});
process.on('uncaughtException', (err) => {
logErr('uncaughtException: ' + (err && (err.stack || err.message)));
});
logErr('ready');
}
+291
View File
@@ -0,0 +1,291 @@
/**
* Media capability pack (bare-media + bare-ffmpeg).
* Only loaded by index-media.mjs so the default host stays lean.
*/
const path = require('bare-path');
const fs = require('bare-fs');
const b4a = require('b4a');
const { resolveAllowedPath, ensureJobDir, sanitizeId, getCapJobsRoot } = require('./paths.js');
/** Soft limit so JSON + base64 stays under Chrome NMH ~1 MB */
const MAX_CHUNK_CHARS = 700000;
/** @type {Map<string, { cancelled: boolean }>} */
const jobs = new Map();
let nextJobSeq = 0;
function makeJobId(prefix) {
return `${prefix}_${Date.now()}_${nextJobSeq++}`;
}
function bufferToBase64(buf) {
return b4a.toString(buf, 'base64');
}
function base64ToBuffer(b64) {
return b4a.from(b64, 'base64');
}
/** Use the returned bytes object itself — never Buffer.buffer (shared pool). */
function asBytes(value) {
if (value == null) return value;
if (typeof value.byteLength === 'number') return value;
if (value.buffer && typeof value.buffer.byteLength === 'number') return value.buffer;
return value;
}
/**
* Emit base64 payload in NMH-safe chunks.
* @returns {number} number of chunks
*/
function emitBase64Chunks(emit, pack, jobId, buffer, extra) {
const b64 = typeof buffer === 'string' ? buffer : bufferToBase64(buffer);
let index = 0;
for (let offset = 0; offset < b64.length; offset += MAX_CHUNK_CHARS) {
emit('cap-chunk', {
pack,
jobId,
index,
data: b64.slice(offset, offset + MAX_CHUNK_CHARS),
...(extra || {}),
});
index++;
}
return index;
}
function createMediaPack(bareMedia) {
const { image, video } = bareMedia;
async function writeInputIfNeeded(payload, jobId) {
if (payload.dataBase64) {
const dir = ensureJobDir(jobId);
const name = payload.filename || 'input.bin';
const dest = resolveAllowedPath(name, jobId);
fs.writeFileSync(dest, base64ToBuffer(payload.dataBase64));
return dest;
}
if (payload.path) {
return resolveAllowedPath(payload.path, payload.jobId || jobId);
}
throw new Error('path or dataBase64 required');
}
const commands = {
async info(ctx) {
const { payload, reply } = ctx;
const jobId = payload.jobId || makeJobId('info');
const inputPath = await writeInputIfNeeded(payload, jobId);
try {
const meta = await video(inputPath).metadata();
reply({
ok: true,
jobId,
kind: 'video',
width: meta.width,
height: meta.height,
duration: meta.duration,
codec: meta.codec,
avgFramerate: meta.avgFramerate,
rotation: meta.rotation,
displayRotation: meta.displayRotation,
});
} catch (videoErr) {
try {
const meta = await image(inputPath).metadata();
reply({ ok: true, jobId, kind: 'image', metadata: meta });
} catch (_) {
reply({ ok: false, error: videoErr.message || 'Unable to probe media' });
}
}
},
async imageTransform(ctx) {
const { payload, reply, emit } = ctx;
const jobId = payload.jobId || makeJobId('img');
jobs.set(jobId, { cancelled: false });
try {
const inputPath = await writeInputIfNeeded(payload, jobId);
const mimetype = payload.mimetype || payload.format || 'image/webp';
let pipe = image(inputPath).decode({ maxFrames: payload.maxFrames });
if (payload.orientate) pipe = pipe.orientate();
if (payload.maxWidth || payload.maxHeight) {
pipe = pipe.resize({ maxWidth: payload.maxWidth, maxHeight: payload.maxHeight });
}
if (payload.crop) {
pipe = pipe.crop(payload.crop);
}
const encoded = await pipe.encode({
mimetype,
maxBytes: payload.maxBytes,
});
if (jobs.get(jobId)?.cancelled) {
reply({ ok: false, error: 'cancelled', jobId });
return;
}
const buf = asBytes(encoded);
if (payload.returnPath) {
const outName = payload.outName || `out.${mimetype.split('/')[1] || 'webp'}`;
const outPath = resolveAllowedPath(outName, jobId);
fs.writeFileSync(outPath, buf);
reply({ ok: true, jobId, path: path.relative(getCapJobsRoot(), outPath), mimetype });
return;
}
reply({ ok: true, jobId, mimetype, streaming: true });
const chunks = emitBase64Chunks(emit, 'media', jobId, buf, { mimetype });
emit('cap-end', { pack: 'media', jobId, chunks, mimetype });
} catch (err) {
reply({ ok: false, error: err.message, jobId });
emit('cap-error', { pack: 'media', jobId, message: err.message });
} finally {
jobs.delete(jobId);
}
},
async extractFrame(ctx) {
const { payload, reply, emit } = ctx;
const jobId = payload.jobId || makeJobId('frame');
jobs.set(jobId, { cancelled: false });
try {
const inputPath = await writeInputIfNeeded(payload, jobId);
const frameIndex = payload.frameIndex != null ? payload.frameIndex : 0;
const mimetype = payload.mimetype || 'image/webp';
const rgba = await video(inputPath).extractFrames({ frameIndex });
if (!rgba) throw new Error('No frame extracted');
if (jobs.get(jobId)?.cancelled) {
reply({ ok: false, error: 'cancelled', jobId });
return;
}
const encoded = await image.encode(rgba, { mimetype, maxBytes: payload.maxBytes });
const buf = asBytes(encoded);
if (payload.returnPath) {
const outName = payload.outName || `frame-${frameIndex}.webp`;
const outPath = resolveAllowedPath(outName, jobId);
fs.writeFileSync(outPath, buf);
reply({ ok: true, jobId, path: path.relative(getCapJobsRoot(), outPath), mimetype, frameIndex });
return;
}
reply({ ok: true, jobId, mimetype, frameIndex, streaming: true });
const chunks = emitBase64Chunks(emit, 'media', jobId, buf, { mimetype, frameIndex });
emit('cap-end', { pack: 'media', jobId, chunks, mimetype, frameIndex });
} catch (err) {
reply({ ok: false, error: err.message, jobId });
emit('cap-error', { pack: 'media', jobId, message: err.message });
} finally {
jobs.delete(jobId);
}
},
async transcode(ctx) {
const { payload, reply, emit } = ctx;
const jobId = payload.jobId || makeJobId('xcode');
jobs.set(jobId, { cancelled: false });
try {
const inputPath = await writeInputIfNeeded(payload, jobId);
ensureJobDir(jobId);
const format = payload.format || 'webm';
const outName = payload.outName || `out.${format === 'matroska' ? 'mkv' : format}`;
const outPath = resolveAllowedPath(outName, jobId);
reply({ ok: true, jobId, status: 'started', format });
const opts = { format };
if (payload.width) opts.width = payload.width;
if (payload.height) opts.height = payload.height;
const out = fs.createWriteStream(outPath);
let bytes = 0;
try {
for await (const chunk of video(inputPath).transcode(opts)) {
if (jobs.get(jobId)?.cancelled) {
out.destroy();
emit('cap-error', { pack: 'media', jobId, message: 'cancelled' });
return;
}
const buf = asBytes(chunk.buffer != null && chunk.byteLength == null ? chunk.buffer : chunk);
out.write(buf);
bytes += buf.length || 0;
emit('cap-chunk', {
pack: 'media',
jobId,
progress: true,
bytes,
});
}
await new Promise((resolve, reject) => {
out.end((err) => (err ? reject(err) : resolve()));
});
emit('cap-end', {
pack: 'media',
jobId,
path: path.relative(getCapJobsRoot(), outPath),
format,
bytes,
});
} catch (err) {
try { out.destroy(); } catch (_) {}
emit('cap-error', { pack: 'media', jobId, message: err.message });
}
} catch (err) {
reply({ ok: false, error: err.message, jobId });
emit('cap-error', { pack: 'media', jobId, message: err.message });
} finally {
jobs.delete(jobId);
}
},
async cancel(ctx) {
const { payload, reply } = ctx;
const jobId = payload.jobId;
if (!jobId) {
reply({ ok: false, error: 'jobId required' });
return;
}
const job = jobs.get(jobId);
if (job) job.cancelled = true;
reply({ ok: true, jobId, cancelled: !!job });
},
async writeInput(ctx) {
const { payload, reply } = ctx;
const jobId = sanitizeId(payload.jobId || makeJobId('up'));
ensureJobDir(jobId);
const name = payload.filename || 'input.bin';
const dest = resolveAllowedPath(name, jobId);
const buf = base64ToBuffer(payload.dataBase64 || '');
if (payload.append) {
fs.appendFileSync(dest, buf);
} else {
fs.writeFileSync(dest, buf);
}
reply({
ok: true,
jobId,
path: path.relative(getCapJobsRoot(), dest),
bytes: buf.length,
});
},
async readOutput(ctx) {
const { payload, reply, emit } = ctx;
const jobId = payload.jobId || makeJobId('read');
const filePath = resolveAllowedPath(payload.path, payload.underJobId || null);
const buf = fs.readFileSync(filePath);
const mimetype = payload.mimetype || 'application/octet-stream';
reply({ ok: true, jobId, mimetype, streaming: true, size: buf.length });
const chunks = emitBase64Chunks(emit, 'media', jobId, buf, { mimetype });
emit('cap-end', { pack: 'media', jobId, chunks, mimetype, path: payload.path });
},
};
return {
id: 'media',
commands,
onLoad() {
ensureJobDir('_ready');
},
};
}
module.exports = { createMediaPack, jobs };
+68
View File
@@ -0,0 +1,68 @@
/**
* Path allowlisting for capability packs. All media I/O is confined under
* BRIDGE_SWARM_STORAGE/cap-jobs/ (no arbitrary filesystem escape).
*/
const path = require('bare-path');
const fs = require('bare-fs');
function getStorageRoot() {
return process.env.BRIDGE_SWARM_STORAGE || path.join(process.cwd(), 'bridge-swarm-storage');
}
function getCapJobsRoot() {
return path.join(getStorageRoot(), 'cap-jobs');
}
function ensureCapJobsRoot() {
const root = getCapJobsRoot();
try {
fs.mkdirSync(root, { recursive: true });
} catch (_) {}
return root;
}
/**
* Resolve a user-supplied path to an absolute path under cap-jobs.
* Relative paths are joined under cap-jobs (or under jobId subdir if given).
* Absolute paths must resolve inside cap-jobs.
*/
function resolveAllowedPath(userPath, jobId) {
if (!userPath || typeof userPath !== 'string') {
throw new Error('path is required');
}
const root = path.resolve(ensureCapJobsRoot());
const base = jobId ? path.join(root, sanitizeId(jobId)) : root;
let resolved;
if (path.isAbsolute(userPath)) {
resolved = path.resolve(userPath);
} else {
resolved = path.resolve(base, userPath);
}
const rel = path.relative(root, resolved);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error('path escapes cap-jobs allowlist');
}
return resolved;
}
function sanitizeId(id) {
return String(id).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128);
}
function ensureJobDir(jobId) {
const dir = path.join(ensureCapJobsRoot(), sanitizeId(jobId));
try {
fs.mkdirSync(dir, { recursive: true });
} catch (_) {}
return dir;
}
module.exports = {
getStorageRoot,
getCapJobsRoot,
ensureCapJobsRoot,
resolveAllowedPath,
sanitizeId,
ensureJobDir,
};
+53
View File
@@ -0,0 +1,53 @@
/**
* Capability pack registry. Packs register at host startup (default: none;
* media host registers via index-media.mjs).
*/
/** @type {Map<string, { id: string, commands: Record<string, Function>, onLoad?: Function }>} */
const packs = new Map();
function registerPack(pack) {
if (!pack || !pack.id || !pack.commands) {
throw new Error('Invalid capability pack');
}
packs.set(pack.id, pack);
return pack;
}
function getPack(id) {
return packs.get(id) || null;
}
function listPackIds() {
return Array.from(packs.keys());
}
function hasPack(id) {
return packs.has(id);
}
/**
* Dispatch a capability command.
* @param {string} packId
* @param {string} cmd
* @param {object} ctx - { payload, reply, emit, storageRoot }
*/
async function dispatch(packId, cmd, ctx) {
const pack = packs.get(packId);
if (!pack) {
throw new Error(`capability '${packId}' not installed`);
}
const handler = pack.commands[cmd];
if (typeof handler !== 'function') {
throw new Error(`Unknown capability command: ${packId}.${cmd}`);
}
return handler(ctx);
}
module.exports = {
registerPack,
getPack,
listPackIds,
hasPack,
dispatch,
};
+52 -1
View File
@@ -17,6 +17,8 @@ const { Duplex } = require('bare-stream');
const c = require('compact-encoding'); const c = require('compact-encoding');
const minimalDefinition = require('./hyperdb-minimal-definition.js'); const minimalDefinition = require('./hyperdb-minimal-definition.js');
const b4a = require('b4a'); const b4a = require('b4a');
const capabilities = require('./capabilities/registry.js');
const { getStorageRoot } = require('./capabilities/paths.js');
// File-based logging // File-based logging
const LOG_FILE = path.join(__dirname, 'bridge-swarm.log'); const LOG_FILE = path.join(__dirname, 'bridge-swarm.log');
@@ -1181,8 +1183,57 @@ async function handleMessageAsync(send, msg) {
break; break;
} }
default: case 'capabilities.list': {
reply({ ok: true, packs: capabilities.listPackIds() });
break;
}
case 'capabilities.has': {
const packId = payload.pack || payload.id;
reply({ ok: true, pack: packId, has: capabilities.hasPack(packId) });
break;
}
case 'capability': {
const packId = payload.pack;
const cmd = payload.cmd;
if (!packId || !cmd) {
reply({ ok: false, error: 'pack and cmd required' });
break;
}
try {
await capabilities.dispatch(packId, cmd, {
payload: payload.payload || payload.args || {},
reply,
emit,
storageRoot: getStorageRoot(),
});
} catch (err) {
reply({ ok: false, error: err.message });
}
break;
}
default: {
// Shortcut: media.info → capability { pack: 'media', cmd: 'info' }
const capMatch = typeof type === 'string' && type.match(/^([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)$/);
if (capMatch && capabilities.hasPack(capMatch[1])) {
try {
await capabilities.dispatch(capMatch[1], capMatch[2], {
payload,
reply,
emit,
storageRoot: getStorageRoot(),
});
} catch (err) {
reply({ ok: false, error: err.message });
}
break;
}
if (capMatch) {
reply({ ok: false, error: `capability '${capMatch[1]}' not installed` });
break;
}
reply({ ok: false, error: `Unknown command: ${type}` }); reply({ ok: false, error: `Unknown command: ${type}` });
}
} }
} catch (err) { } catch (err) {
reply({ ok: false, error: err.message }); reply({ ok: false, error: err.message });
+45
View File
@@ -0,0 +1,45 @@
/**
* BridgeSwarm native messaging host with the media capability pack
* (bare-media + bare-ffmpeg). Built separately via `npm run build:dist:media`
* so the default host stays lean.
*/
import 'bare-process/global';
import './set-tmpdir.mjs';
import * as bareMedia from 'bare-media';
import registry from './capabilities/registry.js';
import mediaMod from './capabilities/media.js';
import {
installConsoleToStderr,
logErr,
DEFAULT_ADDON_PACKAGES,
MEDIA_ADDON_PACKAGES,
extractAddons,
startHost,
} from './boot.mjs';
import _messenger from './messenger.js';
import _host from './host.js';
installConsoleToStderr();
try {
const pack = mediaMod.createMediaPack(bareMedia);
registry.registerPack(pack);
if (typeof pack.onLoad === 'function') pack.onLoad();
logErr('capability pack registered: media');
} catch (err) {
logErr('failed to register media pack: ' + (err && err.message));
throw err;
}
if (process.argv.includes('--extract-addons')) {
extractAddons([...DEFAULT_ADDON_PACKAGES, ...MEDIA_ADDON_PACKAGES]);
} else {
try {
startHost(_messenger, _host);
} catch (err) {
logErr(`startup error: ${err.message}`);
if (err.stack) logErr(err.stack);
process.exitCode = 1;
}
}
+11 -94
View File
@@ -1,5 +1,5 @@
/** /**
* BridgeSwarm native messaging host entrypoint. * BridgeSwarm native messaging host entrypoint (default / lean).
* *
* bare-process/global must be the very first import so that `process` is * bare-process/global must be the very first import so that `process` is
* available as a global before any other module runs (required for bare-pack * available as a global before any other module runs (required for bare-pack
@@ -15,106 +15,23 @@
import 'bare-process/global'; import 'bare-process/global';
import './set-tmpdir.mjs'; import './set-tmpdir.mjs';
import {
installConsoleToStderr,
logErr,
DEFAULT_ADDON_PACKAGES,
extractAddons,
startHost,
} from './boot.mjs';
import _messenger from './messenger.js'; import _messenger from './messenger.js';
import _host from './host.js'; import _host from './host.js';
function logErr(msg) { installConsoleToStderr();
try {
process.stderr.write(`[bridge-swarm-host] ${msg}\n`);
} catch (_) {}
}
// Keep stdout clean for the native messaging framing protocol.
const _stderrWrite = (...args) => {
try {
const line = args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ') + '\n';
process.stderr.write(line);
} catch (_) {}
};
console.log = _stderrWrite;
console.info = _stderrWrite;
console.warn = _stderrWrite;
console.error = _stderrWrite;
console.debug = _stderrWrite;
if (process.argv.includes('--extract-addons')) { if (process.argv.includes('--extract-addons')) {
(async () => { extractAddons(DEFAULT_ADDON_PACKAGES);
const addonPackages = [
'bare-fs',
'bare-pipe',
'bare-module',
'udx-native',
'sodium-native',
'rocksdb-native',
];
for (const name of addonPackages) {
try {
await import(name);
} catch (_) {}
}
process.exit(0);
})();
} else { } else {
try { try {
const { createMessenger } = _messenger; startHost(_messenger, _host);
const { handleMessage, cleanup } = _host;
logErr('loading messenger...');
logErr('loading host (hyperswarm)...');
const input = process.stdin;
const output = process.stdout;
// Do NOT setEncoding('binary') — that makes 'data' emit strings and breaks
// length-prefixed Buffer framing with Chrome's native messaging pipes.
const messenger = createMessenger({
input,
output,
onMessage(msg) {
handleMessage((response) => messenger.send(response), msg).catch((err) => {
logErr(err.stack || err.message);
const id = msg && msg.id;
if (id) {
messenger.send({ id, type: 'response', payload: { ok: false, error: err.message } });
}
});
},
onError(err) {
logErr(err.message);
},
});
function shutdown() {
try {
cleanup();
} catch (_) {}
try {
messenger.destroy();
} catch (_) {}
process.exit(0);
}
process.on('exit', () => {
try {
cleanup();
} catch (_) {}
});
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
// Do not exit on stdin 'end'/'close' — Bare can emit those spuriously under
// Chrome's native-messaging pipes (holesail-browser does not either). Chrome
// kills the host when the port disconnects.
process.on('unhandledRejection', (reason) => {
logErr('unhandledRejection: ' + (reason && (reason.stack || reason.message || reason)));
});
process.on('uncaughtException', (err) => {
logErr('uncaughtException: ' + (err && (err.stack || err.message)));
});
logErr('ready');
} catch (err) { } catch (err) {
logErr(`startup error: ${err.message}`); logErr(`startup error: ${err.message}`);
if (err.stack) logErr(err.stack); if (err.stack) logErr(err.stack);
+3 -1
View File
@@ -19,7 +19,9 @@
"build:dist:mac": "node scripts/build-distributable.js --host darwin-arm64 --host darwin-x64", "build:dist:mac": "node scripts/build-distributable.js --host darwin-arm64 --host darwin-x64",
"build:dist:linux": "node scripts/build-distributable.js --host linux-arm64 --host linux-x64", "build:dist:linux": "node scripts/build-distributable.js --host linux-arm64 --host linux-x64",
"build:dist:win": "node scripts/build-distributable.js --host win32-x64", "build:dist:win": "node scripts/build-distributable.js --host win32-x64",
"build:dist:package": "node scripts/build-distributable.js --all --package" "build:dist:package": "node scripts/build-distributable.js --all --package",
"build:dist:media": "node scripts/build-distributable.js --media --package",
"install:capability:media": "bash scripts/install-capability-media.sh"
}, },
"devDependencies": { "devDependencies": {
"archiver": "^7.0.1", "archiver": "^7.0.1",
+32 -9
View File
@@ -10,10 +10,12 @@
* node scripts/build-distributable.js --all # all platforms * node scripts/build-distributable.js --all # all platforms
* node scripts/build-distributable.js --host darwin-arm64 --host linux-x64 * node scripts/build-distributable.js --host darwin-arm64 --host linux-x64
* node scripts/build-distributable.js --package # also create .zip archives * node scripts/build-distributable.js --package # also create .zip archives
* node scripts/build-distributable.js --media --package # media capability host (ffmpeg)
* *
* Output under releases/: * Output under releases/:
* bridge-swarm-host-darwin-arm64.zip, …-darwin-x64.zip, * bridge-swarm-host-darwin-arm64.zip, …-darwin-x64.zip,
* …-linux-arm64.zip, …-linux-x64.zip, …-win32-x64.zip * …-linux-arm64.zip, …-linux-x64.zip, …-win32-x64.zip
* bridge-swarm-host-media-<platform>-<arch>.zip (with --media)
*/ */
const path = require('path'); const path = require('path');
@@ -25,8 +27,8 @@ const { pathToFileURL } = require('url');
const ROOT = path.join(__dirname, '..'); const ROOT = path.join(__dirname, '..');
const NATIVE_HOST_DIR = path.join(ROOT, 'native-host'); const NATIVE_HOST_DIR = path.join(ROOT, 'native-host');
const RELEASES_DIR = path.join(ROOT, 'releases'); const RELEASES_DIR = path.join(ROOT, 'releases');
const ENTRY = path.join(NATIVE_HOST_DIR, 'index.mjs'); let ENTRY = path.join(NATIVE_HOST_DIR, 'index.mjs');
const HOST_NAME = 'bridge-swarm-host'; let HOST_NAME = 'bridge-swarm-host';
const ALL_HOSTS = [ const ALL_HOSTS = [
'darwin-arm64', 'darwin-arm64',
@@ -49,16 +51,31 @@ function parseArgs() {
const hosts = []; const hosts = [];
let all = false; let all = false;
let doPackage = false; let doPackage = false;
let media = false;
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
if (args[i] === '--all') all = true; if (args[i] === '--all') all = true;
else if (args[i] === '--package') doPackage = true; else if (args[i] === '--package') doPackage = true;
else if (args[i] === '--media') media = true;
else if (args[i] === '--host' && args[i + 1]) hosts.push(args[++i]); else if (args[i] === '--host' && args[i + 1]) hosts.push(args[++i]);
} }
if (all) return { hosts: ALL_HOSTS, doPackage }; if (all) return { hosts: ALL_HOSTS, doPackage, media };
if (hosts.length > 0) return { hosts, doPackage }; if (hosts.length > 0) return { hosts, doPackage, media };
return { hosts: [getCurrentHost()], doPackage }; return { hosts: [getCurrentHost()], doPackage, media };
}
function ensureMediaDeps() {
const mediaPkg = path.join(NATIVE_HOST_DIR, 'node_modules', 'bare-media', 'package.json');
if (fs.existsSync(mediaPkg)) {
console.log('Media deps already present (bare-media).');
return;
}
console.log('Installing media capability deps (bare-media + bare-ffmpeg)...');
execSync('npm install --no-save bare-media@^2.10.1', {
cwd: NATIVE_HOST_DIR,
stdio: 'inherit',
});
} }
function getPlatformModule(host) { function getPlatformModule(host) {
@@ -303,7 +320,7 @@ async function createZipArchives(builtEntries) {
} }
} }
async function build(hosts, doPackage) { async function build(hosts, doPackage, media) {
patchBareBuildSignForLinux(); patchBareBuildSignForLinux();
// Ensure HRPC spec is mirrored into native-host before packing // Ensure HRPC spec is mirrored into native-host before packing
@@ -320,6 +337,12 @@ async function build(hosts, doPackage) {
execSync('npm install', { cwd: NATIVE_HOST_DIR, stdio: 'inherit' }); execSync('npm install', { cwd: NATIVE_HOST_DIR, stdio: 'inherit' });
} }
if (media) {
ensureMediaDeps();
ENTRY = path.join(NATIVE_HOST_DIR, 'index-media.mjs');
HOST_NAME = 'bridge-swarm-host-media';
}
if (!fs.existsSync(path.join(NATIVE_HOST_DIR, 'spec', 'hrpc', 'index.js'))) { if (!fs.existsSync(path.join(NATIVE_HOST_DIR, 'spec', 'hrpc', 'index.js'))) {
throw new Error('native-host/spec/hrpc missing after build:hrpc'); throw new Error('native-host/spec/hrpc missing after build:hrpc');
} }
@@ -333,7 +356,7 @@ async function build(hosts, doPackage) {
const pkg = require(path.join(NATIVE_HOST_DIR, 'package.json')); const pkg = require(path.join(NATIVE_HOST_DIR, 'package.json'));
console.log(`\nBuilding ${HOST_NAME} v${pkg.version}`); console.log(`\nBuilding ${HOST_NAME} v${pkg.version}${media ? ' (media capability)' : ''}`);
console.log(`Targets: ${hosts.join(', ')}`); console.log(`Targets: ${hosts.join(', ')}`);
console.log(`Entry: ${ENTRY}`); console.log(`Entry: ${ENTRY}`);
console.log(`Output: ${RELEASES_DIR}\n`); console.log(`Output: ${RELEASES_DIR}\n`);
@@ -417,9 +440,9 @@ async function build(hosts, doPackage) {
return built; return built;
} }
const { hosts, doPackage } = parseArgs(); const { hosts, doPackage, media } = parseArgs();
build(hosts, doPackage).catch((err) => { build(hosts, doPackage, media).catch((err) => {
console.error('\nBuild failed:', err.message || err); console.error('\nBuild failed:', err.message || err);
if (err.cause) console.error('Cause:', err.cause); if (err.cause) console.error('Cause:', err.cause);
process.exitCode = 1; process.exitCode = 1;
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# Install the optional media capability host (bare-media + bare-ffmpeg) into
# ~/.bridgeswarm, replacing the lean host binary while keeping the same
# launcher / native messaging registration.
#
# Sources (first match wins):
# 1. Local releases/bridge-swarm-host-media-<platform>-<arch>.zip
# 2. BRIDGE_SWARM_MEDIA_URL env override
# 3. Gitea latest-main release asset
#
# After install on macOS: extracts + codesigns new .bare addons (ffmpeg, codecs).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
INSTALL_DIR="${BRIDGE_SWARM_HOME:-$HOME/.bridgeswarm}"
HOST_BIN="$INSTALL_DIR/bridge-swarm-host"
MEDIA_BIN_NAME="bridge-swarm-host-media"
LAUNCHER="$INSTALL_DIR/run-bridge-swarm-host.sh"
uname_s="$(uname -s | tr '[:upper:]' '[:lower:]')"
uname_m="$(uname -m)"
case "$uname_s" in
darwin) platform=darwin ;;
linux) platform=linux ;;
mingw*|msys*|cygwin*) platform=win32 ;;
*) echo "Unsupported OS: $uname_s" >&2; exit 1 ;;
esac
case "$uname_m" in
arm64|aarch64) arch=arm64 ;;
x86_64|amd64) arch=x64 ;;
*) echo "Unsupported arch: $uname_m" >&2; exit 1 ;;
esac
ZIP_NAME="bridge-swarm-host-media-${platform}-${arch}.zip"
LOCAL_ZIP="$REPO_ROOT/releases/$ZIP_NAME"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/bridgeswarm-media.XXXXXX")"
cleanup() { rm -rf "$TMP_DIR"; }
trap cleanup EXIT
echo "Installing BridgeSwarm media capability → $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
if [[ -f "$LOCAL_ZIP" ]]; then
echo "Using local artifact: $LOCAL_ZIP"
cp "$LOCAL_ZIP" "$TMP_DIR/$ZIP_NAME"
elif [[ -n "${BRIDGE_SWARM_MEDIA_URL:-}" ]]; then
echo "Downloading: $BRIDGE_SWARM_MEDIA_URL"
curl -fsSL "$BRIDGE_SWARM_MEDIA_URL" -o "$TMP_DIR/$ZIP_NAME"
else
BASE="${BRIDGE_SWARM_RELEASE_BASE:-https://git.ssh.surf/snxraven/BridgeSwarm/releases/download/latest-main}"
URL="$BASE/$ZIP_NAME"
echo "Downloading: $URL"
if ! curl -fsSL "$URL" -o "$TMP_DIR/$ZIP_NAME"; then
echo "Failed to download $ZIP_NAME." >&2
echo "Build locally with: npm run build:dist:media" >&2
echo "Or set BRIDGE_SWARM_MEDIA_URL to a zip URL." >&2
exit 1
fi
fi
echo "Extracting..."
unzip -qo "$TMP_DIR/$ZIP_NAME" -d "$TMP_DIR/out"
# Find the media host binary inside the zip (layout may be flat or nested)
FOUND=""
if [[ -f "$TMP_DIR/out/$MEDIA_BIN_NAME" ]]; then
FOUND="$TMP_DIR/out/$MEDIA_BIN_NAME"
elif [[ -f "$TMP_DIR/out/${MEDIA_BIN_NAME}-${platform}-${arch}/$MEDIA_BIN_NAME" ]]; then
FOUND="$TMP_DIR/out/${MEDIA_BIN_NAME}-${platform}-${arch}/$MEDIA_BIN_NAME"
else
FOUND="$(find "$TMP_DIR/out" -type f -name "$MEDIA_BIN_NAME" | head -n 1 || true)"
fi
if [[ -z "$FOUND" || ! -f "$FOUND" ]]; then
# bare-build may emit bridge-swarm-host-media as the file name already;
# also accept a single executable in the archive.
FOUND="$(find "$TMP_DIR/out" -type f \( -name 'bridge-swarm-host-media*' -o -name 'bridge-swarm-host' \) ! -name '*.zip' | head -n 1 || true)"
fi
if [[ -z "$FOUND" || ! -f "$FOUND" ]]; then
echo "Could not find media host binary in archive." >&2
ls -laR "$TMP_DIR/out" >&2 || true
exit 1
fi
# Keep a backup of the lean host if present and not already media-backed up
if [[ -x "$HOST_BIN" && ! -f "$INSTALL_DIR/bridge-swarm-host.lean.bak" ]]; then
cp "$HOST_BIN" "$INSTALL_DIR/bridge-swarm-host.lean.bak"
echo "Backed up lean host → bridge-swarm-host.lean.bak"
fi
cp "$FOUND" "$HOST_BIN"
chmod +x "$HOST_BIN"
echo "Installed media host as $HOST_BIN"
# Ensure launcher exports TMPDIR (required on macOS for .bare extract)
if [[ ! -x "$LAUNCHER" ]]; then
printf '%s\n' '#!/bin/bash' 'DIR="$(cd "$(dirname "$0")" && pwd)"' 'export TMPDIR="${DIR}/tmp"' 'export BRIDGE_SWARM_STORAGE="${BRIDGE_SWARM_STORAGE:-${DIR}/bridge-swarm-storage}"' 'exec "${DIR}/bridge-swarm-host" "$@"' > "$LAUNCHER"
chmod +x "$LAUNCHER"
fi
mkdir -p "$INSTALL_DIR/tmp"
if [[ "$platform" == "darwin" ]]; then
echo "macOS: extracting and codesigning media addons..."
/usr/bin/xattr -rd com.apple.quarantine "$INSTALL_DIR" 2>/dev/null || true
ENTITLEMENTS_PLIST="$INSTALL_DIR/entitlements.plist"
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' '<plist version="1.0"><dict><key>com.apple.security.cs.disable-library-validation</key><true/></dict></plist>' > "$ENTITLEMENTS_PLIST"
codesign --force --sign - --entitlements "$ENTITLEMENTS_PLIST" "$HOST_BIN" || true
"$LAUNCHER" --extract-addons 2>/dev/null || true
sleep 2
/usr/bin/xattr -rd com.apple.quarantine "$INSTALL_DIR/tmp" 2>/dev/null || true
SIGNED=0
while IFS= read -r -d '' f; do
codesign --force --sign - "$f" 2>/dev/null && SIGNED=$((SIGNED + 1)) || true
done < <(find "$INSTALL_DIR/tmp" \( -name '*.bare' -o -name '*.dylib' \) -print0 2>/dev/null)
echo "Signed $SIGNED native addons (including media codecs / ffmpeg)"
else
"$LAUNCHER" --extract-addons 2>/dev/null || true
fi
# Marker so capabilities.list can also be inferred offline (host still authoritative)
echo "media" > "$INSTALL_DIR/capabilities.txt"
echo "Done. Restart Chrome fully (Cmd+Q / quit), then BridgeSwarm.capabilities.has('media') should be true."