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
|
/**
* 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),
};
/**
* 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(),
},
};
}
/** 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; },
};
export default { isNative, hubBase, capabilities, secrets, nativeSave,
apiFetch, device, bridgeMessage, folder };
// 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 };
}
|