@@ -38,6 +38,9 @@ P2P: [Browser] ◄──────► [Browser]
|
||||
- **Protomux** - Protocol multiplexing over connections
|
||||
- **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
|
||||
|
||||
### Easy install (recommended)
|
||||
@@ -297,6 +300,7 @@ For comprehensive documentation, see:
|
||||
- **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** - System architecture and message flow
|
||||
- **[docs/API-REFERENCE.md](docs/API-REFERENCE.md)** - Full API reference
|
||||
- **[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/HRPC.md](docs/HRPC.md)** - RPC with streaming
|
||||
|
||||
@@ -346,6 +350,7 @@ BridgeSwarm/
|
||||
│ ├── ARCHITECTURE.md
|
||||
│ ├── API-REFERENCE.md
|
||||
│ ├── DATA-API.md
|
||||
│ ├── CAPABILITIES.md
|
||||
│ ├── PROTOMUX.md
|
||||
│ └── HRPC.md
|
||||
├── examples/ # Example applications
|
||||
|
||||
@@ -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 extension’s **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
|
||||
@@ -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
|
||||
|
||||
- [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.
|
||||
- [PROTOMUX.md](PROTOMUX.md) — Protomux in the browser and connection attachment.
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — Message flow and components.
|
||||
|
||||
@@ -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" }`.
|
||||
@@ -145,6 +145,11 @@
|
||||
<p>Hypercore, Hyperbee, Hyperdrive, Autobase, and Hyperdb from the page.</p>
|
||||
<span class="path">/data-demo/</span>
|
||||
</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>
|
||||
|
||||
<footer>
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
@@ -468,6 +468,141 @@
|
||||
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.
|
||||
* Use this if your script runs at load time and the extension may inject api.js after your script.
|
||||
|
||||
@@ -164,7 +164,18 @@ function connect() {
|
||||
}
|
||||
if (msg.type === 'event') {
|
||||
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);
|
||||
if (isCapEvent || eventSwarmId == null) {
|
||||
// Capability / global events: all subscribed tabs
|
||||
for (const tabId of subscribedTabs) {
|
||||
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)) {
|
||||
@@ -172,6 +183,7 @@ function connect() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
port.onDisconnect.addListener((p) => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -17,6 +17,8 @@ const { Duplex } = require('bare-stream');
|
||||
const c = require('compact-encoding');
|
||||
const minimalDefinition = require('./hyperdb-minimal-definition.js');
|
||||
const b4a = require('b4a');
|
||||
const capabilities = require('./capabilities/registry.js');
|
||||
const { getStorageRoot } = require('./capabilities/paths.js');
|
||||
|
||||
// File-based logging
|
||||
const LOG_FILE = path.join(__dirname, 'bridge-swarm.log');
|
||||
@@ -1181,9 +1183,58 @@ async function handleMessageAsync(send, msg) {
|
||||
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}` });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
reply({ ok: false, error: err.message });
|
||||
if (process.stderr) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
* available as a global before any other module runs (required for bare-pack
|
||||
@@ -15,106 +15,23 @@
|
||||
|
||||
import 'bare-process/global';
|
||||
import './set-tmpdir.mjs';
|
||||
import {
|
||||
installConsoleToStderr,
|
||||
logErr,
|
||||
DEFAULT_ADDON_PACKAGES,
|
||||
extractAddons,
|
||||
startHost,
|
||||
} from './boot.mjs';
|
||||
import _messenger from './messenger.js';
|
||||
import _host from './host.js';
|
||||
|
||||
function logErr(msg) {
|
||||
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;
|
||||
installConsoleToStderr();
|
||||
|
||||
if (process.argv.includes('--extract-addons')) {
|
||||
(async () => {
|
||||
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);
|
||||
})();
|
||||
extractAddons(DEFAULT_ADDON_PACKAGES);
|
||||
} else {
|
||||
try {
|
||||
const { createMessenger } = _messenger;
|
||||
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');
|
||||
startHost(_messenger, _host);
|
||||
} catch (err) {
|
||||
logErr(`startup error: ${err.message}`);
|
||||
if (err.stack) logErr(err.stack);
|
||||
|
||||
+3
-1
@@ -19,7 +19,9 @@
|
||||
"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: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": {
|
||||
"archiver": "^7.0.1",
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
* node scripts/build-distributable.js --all # all platforms
|
||||
* 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 --media --package # media capability host (ffmpeg)
|
||||
*
|
||||
* Output under releases/:
|
||||
* bridge-swarm-host-darwin-arm64.zip, …-darwin-x64.zip,
|
||||
* …-linux-arm64.zip, …-linux-x64.zip, …-win32-x64.zip
|
||||
* bridge-swarm-host-media-<platform>-<arch>.zip (with --media)
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
@@ -25,8 +27,8 @@ const { pathToFileURL } = require('url');
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const NATIVE_HOST_DIR = path.join(ROOT, 'native-host');
|
||||
const RELEASES_DIR = path.join(ROOT, 'releases');
|
||||
const ENTRY = path.join(NATIVE_HOST_DIR, 'index.mjs');
|
||||
const HOST_NAME = 'bridge-swarm-host';
|
||||
let ENTRY = path.join(NATIVE_HOST_DIR, 'index.mjs');
|
||||
let HOST_NAME = 'bridge-swarm-host';
|
||||
|
||||
const ALL_HOSTS = [
|
||||
'darwin-arm64',
|
||||
@@ -49,16 +51,31 @@ function parseArgs() {
|
||||
const hosts = [];
|
||||
let all = false;
|
||||
let doPackage = false;
|
||||
let media = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--all') all = 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]);
|
||||
}
|
||||
|
||||
if (all) return { hosts: ALL_HOSTS, doPackage };
|
||||
if (hosts.length > 0) return { hosts, doPackage };
|
||||
return { hosts: [getCurrentHost()], doPackage };
|
||||
if (all) return { hosts: ALL_HOSTS, doPackage, media };
|
||||
if (hosts.length > 0) return { hosts, doPackage, media };
|
||||
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) {
|
||||
@@ -303,7 +320,7 @@ async function createZipArchives(builtEntries) {
|
||||
}
|
||||
}
|
||||
|
||||
async function build(hosts, doPackage) {
|
||||
async function build(hosts, doPackage, media) {
|
||||
patchBareBuildSignForLinux();
|
||||
|
||||
// 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' });
|
||||
}
|
||||
|
||||
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'))) {
|
||||
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'));
|
||||
|
||||
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(`Entry: ${ENTRY}`);
|
||||
console.log(`Output: ${RELEASES_DIR}\n`);
|
||||
@@ -417,9 +440,9 @@ async function build(hosts, doPackage) {
|
||||
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);
|
||||
if (err.cause) console.error('Cause:', err.cause);
|
||||
process.exitCode = 1;
|
||||
|
||||
Executable
+123
@@ -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."
|
||||
Reference in New Issue
Block a user