aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src/main.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-client/src/main.js')
-rw-r--r--packages/meshbay-client/src/main.js115
1 files changed, 95 insertions, 20 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index 82dfec9..4bc6392 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -256,7 +256,7 @@ function createWindow() {
width: 1200, height: 800,
};
- mainWindow = new BrowserWindow({
+ const win = new BrowserWindow({
...bounds,
minWidth: 320,
show: false,
@@ -282,7 +282,7 @@ function createWindow() {
},
});
- mainWindow.once('ready-to-show', () => mainWindow.show());
+ win.once('ready-to-show', () => win.show());
// The hub must never become the document origin. Anything that would navigate
// away from the packaged interface is refused, and an external link opens in
@@ -290,26 +290,35 @@ function createWindow() {
const isOurs = (target) => {
try { return new URL(target).protocol === `${SCHEME}:`; } catch { return false; }
};
- mainWindow.webContents.on('will-navigate', (event, target) => {
+ win.webContents.on('will-navigate', (event, target) => {
if (!isOurs(target)) event.preventDefault();
});
- mainWindow.webContents.setWindowOpenHandler(({ url }) => {
+ win.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:$/.test(new URL(url).protocol)) shell.openExternal(url);
return { action: 'deny' };
});
// Nothing in this application needs a camera, a microphone or a location.
- mainWindow.webContents.session.setPermissionRequestHandler(
+ win.webContents.session.setPermissionRequestHandler(
(_wc, _permission, callback) => callback(false));
- mainWindow.on('close', () => {
- if (!mainWindow.isMinimized() && !mainWindow.isFullScreen()) {
- config = { ...config, window: mainWindow.getBounds() };
+ win.on('close', () => {
+ if (!win.isMinimized() && !win.isFullScreen()) {
+ config = { ...config, window: win.getBounds() };
writeConfig(config);
}
});
- mainWindow.on('closed', () => { mainWindow = null; });
- mainWindow.loadURL(`${SCHEME}://meshbay/index.html`);
+ // `win`, not `mainWindow`. Changing the hub closes one window and opens
+ // another, and `closed` arrives *after* the replacement has been assigned —
+ // so a handler reading the module variable nulls out the new window, and the
+ // new window's `ready-to-show` then crashes on it. Every handler here belongs
+ // to the window it was created with, and only the current one may clear the
+ // reference.
+ win.on('closed', () => { if (mainWindow === win) mainWindow = null; });
+
+ mainWindow = win;
+ win.loadURL(`${SCHEME}://meshbay/index.html`);
+ return win;
}
// ── Bridge ──────────────────────────────────────────────────────────────────
@@ -320,7 +329,7 @@ function createWindow() {
// input, so it is treated as hostile even though it is our own code.
function registerBridge() {
- ipcMain.handle('hub:set', (_e, base) => {
+ ipcMain.handle('hub:set', async (_e, base) => {
const url = String(base || '').trim().replace(/\/+$/, '');
if (url && !/^https:\/\//.test(url) && !/^http:\/\/(localhost|127\.)/.test(url)) {
// http is allowed only to a loopback address, for someone running a hub
@@ -328,15 +337,35 @@ function registerBridge() {
// the wire in clear.
throw new Error('The hub address must be https');
}
+ // Ask the hub whether it is one, before writing the address down.
+ //
+ // Without this the first-run screen accepts anything shaped like a URL and
+ // the application is then broken with no way back — there was no way to
+ // change the hub once it was set, so a typo meant editing a JSON file by
+ // hand. `https` typed at an `http` hub is the obvious case and it fails
+ // with a TLS error that says nothing to anyone.
+ let version;
+ try {
+ const probe = await fetch(`${url}/v1/hub/version`,
+ { signal: AbortSignal.timeout(10000) });
+ if (!probe.ok) throw new Error(`answered ${probe.status}`);
+ version = await probe.json();
+ if (!version || !version.hub) throw new Error('did not answer as a hub');
+ } catch (e) {
+ throw new Error(describeUnreachable(url, e));
+ }
+
config = { ...config, hubBase: url };
writeConfig(config);
// The renderer reads the address from a process argument, so the window has
// to be rebuilt for a change to take. Reloading in place would leave the
// interface talking to the old hub with no sign of it.
- if (mainWindow) {
- mainWindow.close();
- createWindow();
- }
+ // Build the replacement first, then close the old one: the new window is
+ // what `mainWindow` points at, so the outgoing window's `closed` handler
+ // finds a reference that is no longer its own and leaves it alone.
+ const outgoing = mainWindow;
+ createWindow();
+ if (outgoing && !outgoing.isDestroyed()) outgoing.close();
return url;
});
@@ -360,11 +389,18 @@ function registerBridge() {
if (!base || target.origin !== base.origin) {
throw new Error('Refused: not this hub');
}
- const response = await fetch(target, {
- method: (init && init.method) || 'GET',
- headers: (init && init.headers) || {},
- body: (init && init.body) || undefined,
- });
+ let response;
+ try {
+ response = await fetch(target, {
+ method: (init && init.method) || 'GET',
+ headers: (init && init.headers) || {},
+ body: (init && init.body) || undefined,
+ });
+ } catch (e) {
+ // Node's fetch says "fetch failed" for everything from a refused
+ // connection to a TLS mismatch, which tells a person nothing at all.
+ throw new Error(describeUnreachable(target.origin, e));
+ }
return {
status: response.status,
ok: response.ok,
@@ -373,6 +409,16 @@ function registerBridge() {
};
});
+ ipcMain.handle('hub:probe', async (_e, url) => {
+ const target = String(url || config.hubBase || '').replace(/\/+$/, '');
+ if (!target) return null;
+ try {
+ const r = await fetch(`${target}/v1/hub/version`,
+ { signal: AbortSignal.timeout(10000) });
+ return r.ok ? await r.json() : null;
+ } catch { return null; }
+ });
+
ipcMain.handle('device:ensure', () => ensureDeviceKey());
ipcMain.handle('device:public', () => {
const key = deviceKey();
@@ -446,6 +492,35 @@ function registerBridge() {
});
}
+/**
+ * Why the hub could not be reached, in words somebody can act on.
+ *
+ * `TypeError: fetch failed` is what Node says for a refused connection, a DNS
+ * failure and a TLS mismatch alike. The most common mistake by far is `https`
+ * typed at a hub speaking plain `http`, so that one is named outright.
+ */
+function describeUnreachable(url, error) {
+ const cause = (error && error.cause) || {};
+ const code = cause.code || '';
+ const detail = cause.message || error.message || String(error);
+
+ if (/^https:/.test(url) &&
+ (code === 'ECONNRESET' || /wrong version|SSL|TLS|EPROTO/i.test(detail))) {
+ return `${url} does not speak https. If this hub is on your own machine, ` +
+ `it is probably http — try http:// instead.`;
+ }
+ if (code === 'ECONNREFUSED') {
+ return `Nothing is listening at ${url}. Is the hub running?`;
+ }
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
+ return `${url} could not be found. Check the address.`;
+ }
+ if (error && error.name === 'TimeoutError') {
+ return `${url} did not answer in time.`;
+ }
+ return `Could not reach ${url}: ${detail}`;
+}
+
// ── Lifecycle ───────────────────────────────────────────────────────────────
// One instance. Two would fight over the config file and the secrets blob, and