summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/platform.js
blob: a8e17bf6a7a3274ebfa51a41ff80192cd13745bf (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
/**
 * What differs between running in a browser and running as an installed app.
 *
 * The interface is the same code either way — that is the whole reason Electron
 * was chosen over a shell that replaces the engine (docs/desktop-client-v1.md
 * §2). What genuinely differs is small and lives here:
 *
 *   · **where the hub is.** Served from the hub, it is the current origin. Ship
 *     the interface in a package and it becomes a configured URL, because the
 *     page is loaded from disk and has no hub origin of its own.
 *   · **where a downloaded file goes**, and whether a native dialog picks it.
 *   · **what the app can do at all** — managing a local node, choosing folders
 *     on this machine. Features gated on these render nowhere in a browser
 *     rather than failing when clicked.
 *
 * The browser implementation below is exactly today's behaviour, so nothing
 * changes for anyone until an app is installed. That is the acceptance
 * criterion for this split: **the browser SPA behaves identically.**
 *
 * The native side arrives through `window.meshbay`, which the Electron preload
 * exposes over a context bridge. Absent, everything falls back to the browser
 * path — so this file is safe to load anywhere and there is no build flag.
 */

const bridge = (typeof window !== 'undefined' && window.meshbay) || null;

export const isNative = Boolean(bridge);

/**
 * The hub's base URL, prefixed to every API path.
 *
 * Empty string in a browser: the hub served this page, so a relative path goes
 * to the right place and no configuration can be wrong. In the app it is
 * whatever the user signed in against, and it is deliberately *not* guessed —
 * a client that picks its own hub is a client that can be pointed at one.
 */
export function hubBase() {
  return bridge ? (bridge.hubBase() || '') : '';
}

/** Native-only capabilities. A browser renders none of what these gate. */
export const capabilities = {
  // Install, configure and drive a node running on this machine.
  nodeAdmin: Boolean(bridge && bridge.capabilities && bridge.capabilities.nodeAdmin),
  // Choose directories on this machine to share.
  localFolders: Boolean(bridge && bridge.capabilities && bridge.capabilities.localFolders),
  // A real save dialog and a write that does not pass through the page.
  nativeSave: Boolean(bridge && bridge.capabilities && bridge.capabilities.nativeSave),
  // Cast decrypted video to a device on the same LAN via a local HTTP relay.
  lanCast: Boolean(bridge && bridge.capabilities && bridge.capabilities.lanCast),
};

/**
 * Where the identity keys live.
 *
 * In a browser: exactly where they live today — IndexedDB and sessionStorage,
 * with the keypair bundle on the node as the way a second browser recovers
 * them, which is finding C4 and is the reason the app exists.
 *
 * In the app: the OS keychain, and no bundle is stored anywhere. That is what
 * closes C4 for a native device — unconditionally for that device, and for the
 * account only once it stops signing in from a browser too.
 */
export const secrets = {
  available: Boolean(bridge && bridge.secrets),
  async get(name) {
    if (!bridge || !bridge.secrets) return null;
    return bridge.secrets.get(name);
  },
  async set(name, value) {
    if (!bridge || !bridge.secrets) return false;
    return bridge.secrets.set(name, value);
  },
  async clear(name) {
    if (!bridge || !bridge.secrets) return false;
    return bridge.secrets.clear(name);
  },
  /**
   * Whether the OS is really protecting them.
   *
   * Electron's safeStorage falls back to a fixed key when no keyring is
   * running — a headless session, a minimal desktop — and silently. A user who
   * believes their keys are protected by the OS deserves to be told when they
   * are not, so this is surfaced rather than swallowed.
   */
  async backend() {
    if (!bridge || !bridge.secrets) return 'browser';
    return bridge.secrets.backend();
  },
};

/**
 * This device's key for signing in to the hub.
 *
 * Ed25519, generated and held by the main process — the interface asks for a
 * signature and never sees a key. The passphrase is still the account's
 * credential and its only recovery path; this is what saves deriving a key from
 * it on every launch.
 *
 * Not a per-node identity key. Those are generated per node, pinned there, and
 * never leave that relationship: nothing here correlates a person across
 * operators, and nothing wraps a group key for it.
 *
 * Absent in a browser, where a passphrase is entered every time and the
 * keypair bundle on each node is what a second browser recovers — which is
 * finding C4, and the reason the application exists.
 */
/**
 * The sentence out of a bridge error.
 *
 * Electron wraps anything thrown in the main process as
 * "Error invoking remote method 'hub:set': Error: …", which puts plumbing in
 * front of a message written for a person. The message is the part that was
 * written for them.
 */
export function bridgeMessage(err) {
  const raw = (err && err.message) || String(err);
  const m = raw.match(/^Error invoking remote method '[^']*':\s*(?:\w*Error:\s*)?(.*)$/s);
  return m ? m[1] : raw;
}

export const device = {
  available: Boolean(bridge && bridge.device),
  async ensure() {
    return bridge && bridge.device ? bridge.device.ensure() : null;
  },
  async publicKey() {
    return bridge && bridge.device ? bridge.device.publicKey() : null;
  },
  /** `{timestamp, signature}` over `meshbay:user_auth:<username>:<ts>`. */
  async sign(username) {
    return bridge && bridge.device ? bridge.device.sign(username) : null;
  },
  async forget() {
    return bridge && bridge.device ? bridge.device.forget() : false;
  },
};

/**
 * Call the hub.
 *
 * In a browser this is `fetch`, unchanged — the page came from the hub, so the
 * request is same-origin and nothing is in the way.
 *
 * In the application the page's origin is `app://meshbay`, and a browser fetch
 * from it is refused by CORS. The hub has **no CORS middleware at all**, and
 * that is worth keeping: its API is reachable from no web origin whatever.
 * Widening it for `app://meshbay` would be worse than it appears, because that
 * origin is not a credential — any Electron application can claim the same
 * scheme and host name.
 *
 * So the main process makes the call. It returns a small object rather than a
 * Response, and this shapes it back into something with `.ok`, `.status` and
 * `.json()`, so callers do not have to know which one they got.
 */
export async function apiFetch(url, init) {
  if (!bridge || !bridge.fetch) return fetch(url, init);
  const raw = await bridge.fetch(String(url), init && {
    method: init.method,
    headers: init.headers,
    body: init.body,
  });
  return {
    ok: raw.ok,
    status: raw.status,
    statusText: String(raw.status),
    headers: new Headers(raw.headers || {}),
    text: async () => raw.body,
    json: async () => JSON.parse(raw.body),
  };
}

/**
 * Save a decrypted file to disk.
 *
 * Returns null when there is no native path, so the caller keeps today's
 * behaviour — File System Access, a service worker stream, or a blob, decided
 * in `downloads.js`. Adding a native writer must not remove the three that
 * already work.
 */
export async function nativeSave(suggestedName, { auto = true } = {}) {
  if (!bridge || !bridge.saveFile) return null;
  const sink = await bridge.saveFile(suggestedName, { auto });
  if (!sink) return null;
  // The shape every caller already expects from a download target: a `writable`
  // with write/close/abort, and the name it was actually given on disk.
  return {
    name: sink.name,
    writable: {
      write: (bytes) => sink.write(bytes),
      close: () => sink.close(),
      abort: () => sink.abort(),
    },
    open: sink.open ? () => sink.open() : null,
  };
}

/** Where downloads go on a desktop build. Null in a browser. */
export const folder = {
  available: Boolean(bridge && bridge.folder),
  async choose() { return bridge && bridge.folder ? bridge.folder.choose() : null; },
  async get() { return bridge && bridge.folder ? bridge.folder.get() : null; },
  async forget() { return bridge && bridge.folder ? bridge.folder.forget() : false; },
};

/** Pick a directory to add as a group root. Returns { path, name } or null. */
export const rootPicker = {
  available: Boolean(bridge && bridge.rootPicker),
  async choose() { return bridge && bridge.rootPicker ? bridge.rootPicker.choose() : null; },
};

/**
 * The local node, if one is running on this machine.
 *
 * Detection probes `127.0.0.1:{ui_port}` with the session token read from the
 * daemon's data directory. The renderer never sees the token — it names an
 * operation and the main process executes it, the same trust model as hub:fetch.
 *
 * In a browser all calls resolve to a "not available" result, so the interface
 * can gate features on `node.available` without a build flag.
 */
export const node = {
  available: Boolean(bridge && bridge.node),
  async detect() {
    return bridge && bridge.node ? bridge.node.detect() : { detected: false };
  },
  async installed() {
    return bridge && bridge.node ? bridge.node.installed() : { installed: false };
  },
  async start(opts) {
    if (!bridge || !bridge.node) throw new Error('Node bridge not available');
    return bridge.node.start(opts);
  },
  async call(method, path, body) {
    if (!bridge || !bridge.node) throw new Error('Node bridge not available');
    return bridge.node.call(method, path, body);
  },
  async pairingCode() {
    return bridge && bridge.node ? bridge.node.pairingCode() : null;
  },
  async setPairingCode(code) {
    return bridge && bridge.node ? bridge.node.setPairingCode(code) : false;
  },
};

/**
 * LAN cast relay — re-serve decrypted video segments over HTTP so a
 * Chromecast or Smart TV on the same Wi-Fi can play the stream.
 *
 * Absent in a browser, where the relay cannot run: there is no main process
 * to bind a server socket in, and a page cannot open one.
 */
export const cast = {
  available: Boolean(bridge && bridge.cast),
  async start(opts) {
    if (!bridge || !bridge.cast) return null;
    return bridge.cast.start(opts);
  },
  async push(data) {
    if (!bridge || !bridge.cast) return false;
    return bridge.cast.push(data);
  },
  async stop() {
    if (!bridge || !bridge.cast) return false;
    return bridge.cast.stop();
  },
  async finish() {
    if (!bridge || !bridge.cast) return false;
    return bridge.cast.finish();
  },
  async status() {
    if (!bridge || !bridge.cast) return null;
    return bridge.cast.status();
  },
  async discover() {
    if (!bridge || !bridge.cast) return [];
    return bridge.cast.discover();
  },
  async chromecastConnect(opts) {
    if (!bridge || !bridge.cast) return null;
    return bridge.cast.chromecastConnect(opts);
  },
  async chromecastReload(opts) {
    if (!bridge || !bridge.cast) return null;
    return bridge.cast.chromecastReload(opts);
  },
  async chromecastDisconnect() {
    if (!bridge || !bridge.cast) return false;
    return bridge.cast.chromecastDisconnect();
  },
};

export default { isNative, hubBase, capabilities, secrets, nativeSave,
                 apiFetch, device, bridgeMessage, folder, rootPicker, node,
                 cast };

// Also a global, because `transport.js` is loaded as a classic script — it
// predates the module graph and exposes `MeshBayTransport` the same way. The
// alternative was a second fetch path there, which is how two callers of one
// hub end up disagreeing about how to reach it.
if (typeof window !== 'undefined') {
  window.MeshBayPlatform = { isNative, hubBase, capabilities, secrets,
                             nativeSave, apiFetch, device,
                             bridgeMessage, folder, rootPicker, node,
                             cast };
}