aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 16:34:12 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 16:34:12 +0200
commiteedbca3f0d47af39b4dd8812683e5a14ae4e48e6 (patch)
treec99304c1f935e63a40e0546ef63ff30879d5aa9b /packages/meshbay-hub
parentc384878aa0d6ef7a33bda23887dc264b94386725 (diff)
downloadmeshbay-eedbca3f0d47af39b4dd8812683e5a14ae4e48e6.tar.gz
fix: two waits with no deadline, resume positions per account, group settings tab
**Joining a group could hang.** Reported after a first attempt that never finished and a later one that worked — the shape of a network wait with no deadline, and there were two. Signaling here is non-trickle: the offer is not sent until ICE gathering says it is done. A STUN server that is slow, filtered, or resolved through a DNS that is not answering means `icegatheringstatechange` never reaches `complete`, and `connect()` never returns. Same shape as the fullscreen denial fixed yesterday: a promise that never settles leaves no error to find. Gathering now has four seconds, after which the offer goes out with what it has — host candidates are already there, which is enough on a LAN, and giving up instead would turn a slow STUN server into a refusal to connect. The second: `hub:fetch` in the desktop client had no timeout, so a host that accepts a connection and then says nothing holds the request for as long as the OS allows. `hub:probe` had one; the handler that carries signaling did not. Now thirty seconds — longer than the hub's own fifteen-second signaling wait, so it cannot abort a call that was about to succeed — and it says the hub did not answer rather than "fetch failed". **Resume positions belonged to the machine, not the account.** Stored as `mb:pos:<file>`, so a second account signing in on the same computer was offered "resume where you left off" in a film it had never opened. Wrong on its own terms, and a small disclosure of what the other person watches, since the offer only appears for files someone has actually been through. The account is in the key now. Positions written before this are deleted rather than re-keyed: there is no record of whose they were, and guessing hands them to whoever signs in next, which is the bug. **The staggered rules in the members table.** `display: flex` on the actions `<td>` — a flex table cell stops being a table cell, so it no longer stretches to its row and its bottom border is drawn wherever its own content ends. Measured: in a row whose other cells were `top 76, height 40`, that cell was `top 77, height 30`, its rule nine pixels above the rest. It is a table cell again, held open by a zero-width strut so the owner's row — which has no remove button — stays as tall as the others. Every cell now shares its row's top and bottom exactly, at 420px and 900px. **Members became Settings.** It was a list with three unrelated forms stacked above it, laid out with inline styles on whichever element needed them, and the group's own controls somewhere else entirely — leaving or deleting a group sat in the page header beside the title. Now one tab in sections: invitations, operator pairing, your devices on this node, leaving or deleting, and the roster last, since it is the only part with no upper bound. One consequence worth stating: the tab bar no longer waits for the node. Membership is hub-side, and gating it on a live connection would have made "leave this group" unreachable exactly when a node is down — which is when someone most wants it. Files and chat still need the node and say so. **A download button in the viewer**, beside the close button and in the same style, for both the video player and the file preview. 844 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js378
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css107
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js28
-rw-r--r--packages/meshbay-hub/tests/test_connect_never_hangs.py91
-rw-r--r--packages/meshbay-hub/tests/test_resume_position.py158
-rw-r--r--packages/meshbay-hub/tests/test_spa_ordering.py59
-rw-r--r--packages/meshbay-hub/tests/test_table_rows_measured.py116
17 files changed, 824 insertions, 153 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 07fc781..c460f23 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -1925,30 +1925,6 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
&& html`<span class="spinner"></span>${' '}`}
${statusLabel}
</span>
- ${group && group.is_admin && html`
- <button class="admin-btn danger" style="margin-left:auto"
- onClick=${async () => {
- if (!confirm(t('group.delete_group_confirm', { name: group.name }))) return;
- try {
- await hubFetch('/v1/groups/' + groupId, { method: 'DELETE', token });
- navigate('/');
- window.location.reload();
- } catch (err) { setError(err.message); }
- }}>${t('group.delete_group')}</button>
- `}
- ${group && !group.is_admin && html`
- <button class="admin-btn danger" style="margin-left:auto"
- onClick=${async () => {
- if (!confirm(t('group.leave_confirm', { name: group.name }))) return;
- try {
- await hubFetch('/v1/groups/' + groupId + '/leave',
- { method: 'POST', token });
- // Dropped from the list here rather than reloading: a reload
- // would tear down the WebRTC connections other groups hold.
- if (onLeft) onLeft(groupId);
- } catch (err) { setError(err.message); }
- }}>${t('group.leave')}</button>
- `}
</div>
${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
${needsDevice && html`
@@ -1983,17 +1959,25 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
</div>
</form>
`}
- ${status === 'connected' && html`
+ ${/* Not gated on the connection any more. Leaving a group, deleting it
+ and seeing who is in it are hub-side, and moving them into this tab
+ would otherwise have made them unreachable exactly when a node is
+ down — which is when someone is most likely to want them. Files and
+ chat still need the node and say so. */ group && html`
<div class="group-tabs">
<button class="group-tab ${tab === 'chat' ? 'active' : ''}"
onClick=${() => setTab('chat')}>${t('group.tab_chat')}</button>
<button class="group-tab ${tab === 'files' ? 'active' : ''}"
onClick=${() => setTab('files')}>${t('group.tab_files')}</button>
- <button class="group-tab ${tab === 'members' ? 'active' : ''}"
- onClick=${() => setTab('members')}>${t('group.tab_members')}</button>
+ <button class="group-tab ${tab === 'settings' ? 'active' : ''}"
+ onClick=${() => setTab('settings')}>${t('group.tab_settings')}</button>
</div>
- ${tab === 'files' && html`
+ ${tab === 'files' && status !== 'connected' && html`
+ <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting')}</p>
+ `}
+
+ ${tab === 'files' && status === 'connected' && html`
<div class="file-toolbar">
<div class="toolbar-group">
<label class="tb-btn primary">
@@ -2127,11 +2111,12 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
<p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting')}</p>
`}
- ${tab === 'members' && html`
- <${MembersPanel} groupId=${groupId} group=${group} token=${token}
+ ${tab === 'settings' && html`
+ <${GroupSettingsPanel} groupId=${groupId} group=${group} token=${token}
transportRef=${transportRef} gekRef=${gekRef}
isNodeAdmin=${isNodeAdmin} userId=${userId}
- operatorPaired=${operatorPaired}
+ operatorPaired=${operatorPaired} connected=${status === 'connected'}
+ onLeft=${onLeft}
onPaired=${() => setOperatorPaired(true)} />
`}
`}
@@ -2149,14 +2134,16 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
entry=${previewEntry}
transportRef=${transportRef}
gekRef=${gekRef}
- onClose=${() => setPreviewEntry(null)} />
+ onClose=${() => setPreviewEntry(null)}
+ onDownload=${() => downloadFile(previewEntry)} />
`}
${videoEntry && html`
<${VideoPlayer}
entry=${videoEntry}
transportRef=${transportRef}
gekRef=${gekRef}
- onClose=${() => setVideoEntry(null)} />
+ onClose=${() => setVideoEntry(null)}
+ onDownload=${() => downloadFile(videoEntry)} />
`}
</div>
`;
@@ -2167,7 +2154,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const TEXT_EXTS = /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i;
const IMAGE_EXTS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i;
-function FilePreview({ entry, transportRef, gekRef, onClose }) {
+function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) {
const [phase, setPhase] = useState('loading');
const [progress, setProgress] = useState(0);
const [content, setContent] = useState(null);
@@ -2243,6 +2230,11 @@ function FilePreview({ entry, transportRef, gekRef, onClose }) {
}}>
<div class="video-top-bar">
<span class="video-title">${entry.name} (${formatSize(entry.size)})</span>
+ ${onDownload && html`
+ <button class="video-close" onClick=${onDownload}
+ title="${t('group.download')}">
+ <${Icon} name="download" /></button>
+ `}
<button class="video-close" onClick=${onClose} title="${t('video.close')}">
<${Icon} name="close" /></button>
</div>
@@ -2286,8 +2278,18 @@ function _b64ToU8(b64) {
// ── Members Panel ────────────────────────────────────────────────────────
-function MembersPanel({ groupId, group, token, transportRef, gekRef,
- isNodeAdmin, userId, operatorPaired, onPaired }) {
+/**
+ * Everything about the group that is not its files or its chat.
+ *
+ * Was "Members", which was a list with three unrelated forms stacked on top of
+ * it and the group's own controls somewhere else entirely — leaving or deleting
+ * a group lived in the header, beside its title. One tab now, in sections, with
+ * the roster last: it is the part that grows without limit, and burying the
+ * controls under two hundred names is how a tab stops being usable.
+ */
+function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
+ isNodeAdmin, userId, operatorPaired, connected,
+ onPaired, onLeft }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
const [loading, setLoading] = useState(true);
@@ -2460,119 +2462,181 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef,
if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`;
+ const isOwner = Boolean(isAdmin);
+
return html`
<div class="members-panel">
- ${isAdmin && !operatorPaired && html`
- <p class="settings-hint">
- ${isNodeAdmin ? t('members.invite_needs_pairing')
- : t('members.invite_ask_operator')}
- </p>
- `}
- ${isAdmin && operatorPaired && html`
- <form class="invite-form" onSubmit=${doInvite}>
- <h4>${t('members.invite_title')}</h4>
- ${error && html`<p class="error-msg">${error}</p>`}
- ${inviteCode && html`
- <div class="success-msg" style="margin-bottom:8px">
- <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p>
- <p style="font-family:monospace;font-size:1.4em;letter-spacing:2px;margin:6px 0">
- ${inviteCode.code}
- </p>
- <p>${t('members.invite_code_hint')}</p>
- </div>
+ ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
+
+ ${/* Inviting needs the node: it is the node that wraps the group key and
+ issues the code, not the hub. */ isAdmin && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('members.invite_title')}</h3>
+ ${!connected && html`
+ <p class="settings-hint">${t('group.offline_title')}</p>
`}
- <div style="display:flex;gap:8px">
- <input type="text" placeholder="${t('members.username_placeholder')}"
- value=${inviteUser} onInput=${e => setInviteUser(e.target.value)} required />
- <button class="admin-btn" type="submit" disabled=${inviting}>
- ${inviting ? '...' : t('members.invite_btn')}
- </button>
- </div>
- </form>
- `}
- <table class="admin-table">
- <thead>
- <tr>
- <th>${t('admin.col_username')}</th>
- <th>${t('members.group_role')}</th>
- <th></th>
- </tr>
- </thead>
- <tbody>
- ${members.map(m => html`
- <tr key=${m.user_id}>
- <td>${m.username}</td>
- <td>
- ${m.user_id === adminId
- ? html`<span class="badge" style="background:var(--accent);color:var(--accent-text)">${t('members.owner')}</span>`
- : html`<span class="badge">${t('members.member')}</span>`
- }
- </td>
- <td class="admin-actions">
- ${isAdmin && m.user_id !== adminId && html`
- <button class="admin-btn danger" disabled=${removing === m.user_id}
- onClick=${() => {
- if (!confirm(t('members.remove_confirm', { user: m.username }))) return;
- removeMember(m);
- }}>
- ${removing === m.user_id ? '...' : t('members.remove')}
- </button>
- `}
- </td>
- </tr>
- `)}
- </tbody>
- </table>
- ${isAdmin && members.length > 1 && html`
- <p class="settings-hint">${t('members.remove_hint')}</p>
+ ${connected && !operatorPaired && html`
+ <p class="settings-hint">
+ ${isNodeAdmin ? t('members.invite_needs_pairing')
+ : t('members.invite_ask_operator')}
+ </p>
+ `}
+ ${connected && operatorPaired && html`
+ <form onSubmit=${doInvite}>
+ ${inviteCode && html`
+ <div class="success-msg" style="margin-bottom:8px">
+ <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p>
+ <p class="code-display">${inviteCode.code}</p>
+ <p>${t('members.invite_code_hint')}</p>
+ </div>
+ `}
+ <div class="form-row">
+ <input type="text" placeholder="${t('members.username_placeholder')}"
+ value=${inviteUser} onInput=${e => setInviteUser(e.target.value)} required />
+ <button class="admin-btn" type="submit" disabled=${inviting}>
+ ${inviting ? '...' : t('members.invite_btn')}
+ </button>
+ </div>
+ </form>
+ `}
+ </div>
`}
- ${isNodeAdmin && !operatorPaired && html`
- <form class="invite-form" onSubmit=${doPair}>
- <h4>${t('members.pair_title')}</h4>
+
+ ${isNodeAdmin && !operatorPaired && connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('members.pair_title')}</h3>
<p class="settings-hint">${t('members.pair_hint')}</p>
${pairStatus && html`
<p class=${pairStatus === 'paired' ? 'success-msg' : 'error-msg'}>
${pairStatus === 'paired' ? t('members.pair_success') : pairStatus}
</p>
`}
- <div style="display:flex;gap:8px">
- <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace"
+ <form class="form-row" onSubmit=${doPair}>
+ <input type="text" placeholder="XXXX-XXXX" class="code-input"
value=${pairCode} onInput=${e => setPairCode(e.target.value)} required />
<button class="admin-btn" type="submit" disabled=${pairing}>
${pairing ? '...' : t('members.pair_btn')}
</button>
- </div>
- </form>
+ </form>
+ </div>
`}
- <div class="invite-form" style="margin-top:16px">
- <h4>${t('device.mine_title')}</h4>
- <p class="settings-hint">${t('device.mine_hint')}</p>
- ${deviceMsg && html`<p class="settings-hint">${deviceMsg}</p>`}
- ${devices.length === 0 && html`
- <p class="settings-hint">${t('device.mine_empty')}</p>
- `}
- ${devices.map(d => html`
- <div key=${d.pk_ed25519}
- style="display:flex;align-items:center;gap:8px;margin:4px 0">
- <span style="font-family:monospace">${d.pk_ed25519.slice(0, 16)}…</span>
- ${d.is_this_one && html`<span class="badge">${t('device.this_one')}</span>`}
- <span class="settings-hint">${d.pinned_via}${d.label ? ' · ' + d.label : ''}</span>
- ${!d.is_this_one && devices.length > 1 && html`
- <button class="admin-btn" onClick=${() => revokeDevice(d)}>
- ${t('device.revoke')}
- </button>
+ ${connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('device.mine_title')}</h3>
+ <p class="settings-hint">${t('device.mine_hint')}</p>
+ ${deviceMsg && html`<p class="settings-hint">${deviceMsg}</p>`}
+ ${devices.length === 0
+ ? html`<p class="settings-hint">${t('device.mine_empty')}</p>`
+ : html`
+ <ul class="device-list">
+ ${devices.map(d => html`
+ <li class="device-row" key=${d.pk_ed25519}>
+ <span class="device-key">${d.pk_ed25519.slice(0, 16)}…</span>
+ <span class="device-meta">
+ ${d.is_this_one && html`
+ <span class="badge">${t('device.this_one')}</span>${' '}
+ `}
+ ${d.pinned_via}${d.label ? ' · ' + d.label : ''}
+ </span>
+ ${!d.is_this_one && devices.length > 1 && html`
+ <button class="admin-btn" onClick=${() => revokeDevice(d)}>
+ ${t('device.revoke')}
+ </button>
+ `}
+ </li>
+ `)}
+ </ul>
`}
+ <form onSubmit=${approveDevice} class="settings-subform">
+ <p class="settings-hint">${t('device.approve_hint')}</p>
+ <div class="form-row">
+ <input type="text" placeholder="XXXX-XXXX" class="code-input"
+ value=${approveCode} onInput=${e => setApproveCode(e.target.value)} />
+ <button class="admin-btn" type="submit">${t('device.approve_btn')}</button>
+ </div>
+ </form>
+ </div>
+ `}
+
+ ${/* Before the roster, not after it: this is what someone came here to
+ do, and a list of two hundred names is a long way to scroll for
+ it. */ html`
+ <div class="settings-section danger-section">
+ <h3 class="settings-heading">${t('members.danger_title')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">
+ ${isOwner ? t('members.danger_delete_hint')
+ : t('members.danger_leave_hint')}
+ </span>
+ ${isOwner
+ ? html`
+ <button class="admin-btn danger" onClick=${async () => {
+ if (!confirm(t('group.delete_group_confirm', { name: group.name }))) return;
+ try {
+ await hubFetch('/v1/groups/' + groupId, { method: 'DELETE', token });
+ navigate('/');
+ window.location.reload();
+ } catch (err) { setError(err.message); }
+ }}>${t('group.delete_group')}</button>
+ `
+ : html`
+ <button class="admin-btn danger" onClick=${async () => {
+ if (!confirm(t('group.leave_confirm', { name: group.name }))) return;
+ try {
+ await hubFetch('/v1/groups/' + groupId + '/leave',
+ { method: 'POST', token });
+ // Dropped from the list here rather than reloading: a
+ // reload would tear down the WebRTC connections other
+ // groups hold.
+ if (onLeft) onLeft(groupId);
+ } catch (err) { setError(err.message); }
+ }}>${t('group.leave')}</button>
+ `}
</div>
- `)}
- <form onSubmit=${approveDevice} style="margin-top:12px">
- <p class="settings-hint">${t('device.approve_hint')}</p>
- <div style="display:flex;gap:8px">
- <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace"
- value=${approveCode} onInput=${e => setApproveCode(e.target.value)} />
- <button class="admin-btn" type="submit">${t('device.approve_btn')}</button>
- </div>
- </form>
+ </div>
+ `}
+
+ <div class="settings-section">
+ <h3 class="settings-heading">
+ ${t('group.tab_members')} (${members.length})
+ </h3>
+ <table class="admin-table">
+ <thead>
+ <tr>
+ <th>${t('admin.col_username')}</th>
+ <th>${t('members.group_role')}</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ ${members.map(m => html`
+ <tr key=${m.user_id}>
+ <td>${m.username}</td>
+ <td>
+ ${m.user_id === adminId
+ ? html`<span class="badge badge-owner">${t('members.owner')}</span>`
+ : html`<span class="badge">${t('members.member')}</span>`
+ }
+ </td>
+ <td class="admin-actions">
+ ${isAdmin && m.user_id !== adminId && html`
+ <button class="admin-btn danger" disabled=${removing === m.user_id}
+ onClick=${() => {
+ if (!confirm(t('members.remove_confirm', { user: m.username }))) return;
+ removeMember(m);
+ }}>
+ ${removing === m.user_id ? '...' : t('members.remove')}
+ </button>
+ `}
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ ${isAdmin && members.length > 1 && html`
+ <p class="settings-hint">${t('members.remove_hint')}</p>
+ `}
</div>
</div>
`;
@@ -3018,15 +3082,28 @@ function formatClock(seconds) {
}
/**
- * Where this browser last left off in a given file.
+ * Where *this account on this device* last left off in a given file.
*
* localStorage rather than the node: it needs no protocol, no storage anyone
* else has to keep, and nothing new learns what you watch. The cost is that
* the position does not follow you from the laptop to the phone.
+ *
+ * The account has to be in the key. Without it the position is per *device* —
+ * so a second person signing in on the same machine was offered "resume where
+ * you left off" in a film they had never opened, which is both wrong and a
+ * small disclosure of what someone else watches. Found by signing in with a
+ * fresh account and being offered a resume point.
*/
+function resumeKey(fileId) {
+ const auth = loadAuth();
+ return auth && auth.userId ? `mb:pos:${auth.userId}:${fileId}` : null;
+}
+
function readResumePosition(fileId) {
try {
- const raw = localStorage.getItem(`mb:pos:${fileId}`);
+ const key = resumeKey(fileId);
+ if (!key) return 0;
+ const raw = localStorage.getItem(key);
const at = raw ? parseFloat(raw) : 0;
return Number.isFinite(at) && at > RESUME_MIN_S ? at : 0;
} catch {
@@ -3036,16 +3113,40 @@ function readResumePosition(fileId) {
function writeResumePosition(fileId, at, duration) {
try {
+ const key = resumeKey(fileId);
+ if (!key) return;
if (!Number.isFinite(at) || at < RESUME_MIN_S
|| (duration && at > duration * RESUME_MAX_FRACTION)) {
- localStorage.removeItem(`mb:pos:${fileId}`);
+ localStorage.removeItem(key);
return;
}
- localStorage.setItem(`mb:pos:${fileId}`, String(Math.floor(at)));
+ localStorage.setItem(key, String(Math.floor(at)));
} catch { /* nothing to be done, and nothing worth failing over */ }
}
-function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
+/**
+ * Drop the positions written before they were scoped to an account.
+ *
+ * Re-keying them is not possible — there is no record of whose they were, and
+ * guessing would hand them to whoever signs in next, which is the bug. They go.
+ */
+function purgeUnscopedResumePositions() {
+ try {
+ const stale = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ // `mb:pos:<file>` is the old shape; `mb:pos:<user>:<file>` is current.
+ if (key && key.startsWith('mb:pos:') && key.split(':').length === 3) {
+ stale.push(key);
+ }
+ }
+ stale.forEach((key) => localStorage.removeItem(key));
+ } catch { /* storage disabled: nothing was written either */ }
+}
+
+purgeUnscopedResumePositions();
+
+function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
const [phase, setPhase] = useState('loading');
const [error, setError] = useState('');
const videoRef = useRef(null);
@@ -3676,6 +3777,11 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
}}>
<div class="video-top-bar">
<span class="video-title">${entry.name} (${formatSize(entry.size)})</span>
+ ${onDownload && html`
+ <button class="video-close" onClick=${onDownload}
+ title="${t('group.download')}">
+ <${Icon} name="download" /></button>
+ `}
<button class="video-close" onClick=${onClose} title="${t('video.close')}">
<${Icon} name="close" /></button>
</div>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index e099edb..d222bba 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -77,6 +77,10 @@ export default {
'group.tab_files': 'Dateien',
'group.tab_chat': 'Chat',
'group.tab_members': 'Mitglieder',
+ 'group.tab_settings': "Einstellungen",
+ 'members.danger_title': "Gefahrenbereich",
+ 'members.danger_leave_hint': "Sie verlieren den Zugriff auf die Dateien und den Chat dieser Gruppe.",
+ 'members.danger_delete_hint': "Die Gruppe verschwindet für alle Mitglieder. Das lässt sich nicht rückgängig machen.",
'group.filter': 'Dateien filtern …',
'group.col_name': 'Name',
'group.col_size': 'Größe',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index a621084..33e8f5e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -78,6 +78,10 @@ export default {
'group.tab_files': 'Files',
'group.tab_chat': 'Chat',
'group.tab_members': 'Members',
+ 'group.tab_settings': "Settings",
+ 'members.danger_title': "Danger zone",
+ 'members.danger_leave_hint': "You will lose access to this group's files and chat.",
+ 'members.danger_delete_hint': "This removes the group for every member. It cannot be undone.",
'group.filter': 'Filter files...',
'group.col_name': 'Name',
'group.col_size': 'Size',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 5fb4950..81b2c97 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -75,6 +75,10 @@ export default {
'group.tab_files': 'Archivos',
'group.tab_chat': 'Chat',
'group.tab_members': 'Miembros',
+ 'group.tab_settings': "Ajustes",
+ 'members.danger_title': "Zona de riesgo",
+ 'members.danger_leave_hint': "Perderá el acceso a los archivos y al chat de este grupo.",
+ 'members.danger_delete_hint': "El grupo desaparece para todos sus miembros. No se puede deshacer.",
'group.filter': 'Filtrar archivos...',
'group.col_name': 'Nombre',
'group.col_size': 'Tamaño',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index c9480b6..e44b03b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -76,6 +76,10 @@ export default {
'group.tab_files': 'Fichiers',
'group.tab_chat': 'Discussion',
'group.tab_members': 'Membres',
+ 'group.tab_settings': "Paramètres",
+ 'members.danger_title': "Zone sensible",
+ 'members.danger_leave_hint': "Vous perdrez l’accès aux fichiers et à la discussion de ce groupe.",
+ 'members.danger_delete_hint': "Le groupe disparaît pour tous ses membres. C’est irréversible.",
'group.filter': 'Filtrer les fichiers...',
'group.col_name': 'Nom',
'group.col_size': 'Taille',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index ac7c06c..206c749 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -76,6 +76,10 @@ export default {
'group.tab_files': 'File',
'group.tab_chat': 'Chat',
'group.tab_members': 'Membri',
+ 'group.tab_settings': "Impostazioni",
+ 'members.danger_title': "Zona critica",
+ 'members.danger_leave_hint': "Perderai l’accesso ai file e alla chat di questo gruppo.",
+ 'members.danger_delete_hint': "Il gruppo scompare per tutti i membri. Non è reversibile.",
'group.filter': 'Filtra i file...',
'group.col_name': 'Nome',
'group.col_size': 'Dimensione',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index b5d60ca..b4ccfaf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -74,6 +74,10 @@ export default {
'group.tab_files': 'ファイル',
'group.tab_chat': 'チャット',
'group.tab_members': 'メンバー',
+ 'group.tab_settings': "設定",
+ 'members.danger_title': "取り扱い注意",
+ 'members.danger_leave_hint': "このグループのファイルとチャットにアクセスできなくなります。",
+ 'members.danger_delete_hint': "グループはすべてのメンバーから消えます。元に戻せません。",
'group.filter': 'ファイルを絞り込み…',
'group.col_name': '名前',
'group.col_size': 'サイズ',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 304c90a..ac75f26 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -77,6 +77,10 @@ export default {
'group.tab_files': 'Bestanden',
'group.tab_chat': 'Chat',
'group.tab_members': 'Leden',
+ 'group.tab_settings': "Instellingen",
+ 'members.danger_title': "Gevarenzone",
+ 'members.danger_leave_hint': "U verliest de toegang tot de bestanden en de chat van deze groep.",
+ 'members.danger_delete_hint': "De groep verdwijnt voor alle leden. Dit kan niet ongedaan worden gemaakt.",
'group.filter': 'Bestanden filteren...',
'group.col_name': 'Naam',
'group.col_size': 'Grootte',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 98f36ce..fe9c18d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -81,6 +81,10 @@ export default {
'group.tab_files': 'Pliki',
'group.tab_chat': 'Czat',
'group.tab_members': 'Członkowie',
+ 'group.tab_settings': "Ustawienia",
+ 'members.danger_title': "Strefa ryzyka",
+ 'members.danger_leave_hint': "Utracisz dostęp do plików i czatu tej grupy.",
+ 'members.danger_delete_hint': "Grupa zniknie dla wszystkich członków. Tego nie można cofnąć.",
'group.filter': 'Filtruj pliki...',
'group.col_name': 'Nazwa',
'group.col_size': 'Rozmiar',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 5db5cd5..270af8e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -77,6 +77,10 @@ export default {
'group.tab_files': 'Arquivos',
'group.tab_chat': 'Conversa',
'group.tab_members': 'Membros',
+ 'group.tab_settings': "Configurações",
+ 'members.danger_title': "Zona de risco",
+ 'members.danger_leave_hint': "Você perderá o acesso aos arquivos e ao chat deste grupo.",
+ 'members.danger_delete_hint': "O grupo desaparece para todos os membros. Não há como desfazer.",
'group.filter': 'Filtrar arquivos...',
'group.col_name': 'Nome',
'group.col_size': 'Tamanho',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index b5f16fb..6e19df9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -74,6 +74,10 @@ export default {
'group.tab_files': '文件',
'group.tab_chat': '聊天',
'group.tab_members': '成员',
+ 'group.tab_settings': "设置",
+ 'members.danger_title': "危险区域",
+ 'members.danger_leave_hint': "您将无法再访问该群组的文件和聊天。",
+ 'members.danger_delete_hint': "该群组将对所有成员消失,且无法恢复。",
'group.filter': '筛选文件…',
'group.col_name': '名称',
'group.col_size': '大小',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 828e104..cb56321 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -940,6 +940,8 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
display: flex;
align-items: center;
justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
padding: 8px 0;
}
.settings-row + .settings-row {
@@ -989,6 +991,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
display: flex;
align-items: center;
justify-content: space-between;
+ gap: 8px;
padding: 12px 20px;
z-index: 210;
}
@@ -1000,7 +1003,8 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- max-width: calc(100% - 60px);
+ flex: 1;
+ min-width: 0;
}
.video-close {
@@ -1242,16 +1246,103 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
font-style: italic;
}
-/* A row whose actions cell is empty — the group owner, who cannot be removed,
- or your own account in the admin table — used to sit lower than the rest:
- `display: flex` makes the cell the height of its button, and nothing when
- there is no button. Reserving the height keeps every row aligned without
- rendering a disabled button nobody can use. */
+/* Two things this cell has to do: hold a row of buttons, and stay as tall as
+ the rest of its row even when it is empty — the group owner cannot be
+ removed, and neither can your own account in the admin table.
+
+ `display: flex` did the second one and broke the first. **A flex <td> stops
+ being a table cell**: it no longer stretches to the height of its row, so
+ its bottom border is drawn wherever its own content ends. Measured, in a row
+ whose other cells were `top 76, height 40`, the actions cell came out
+ `top 77, height 30` — its rule nine pixels above the rest, which is the
+ staggered lines across the members table.
+
+ So the cell goes back to being a table cell, and an empty one is held open
+ by a zero-width strut the height of a button. Reported as "un décalage sur
+ les lignes du tableau listant les membres". */
.admin-actions {
+ white-space: nowrap;
+}
+.admin-actions::before {
+ content: '';
+ display: inline-block;
+ width: 0;
+ height: 30px;
+ vertical-align: middle;
+}
+.admin-actions > * { vertical-align: middle; }
+.admin-actions > * + * { margin-left: 6px; }
+
+/* ── Group settings sections ──────────────────────────────────────────────
+ The group tab was three forms and a table, each laid out with inline styles
+ on the element that needed it, which is why no two of them lined up. These
+ are the pieces they all wanted. */
+
+/* An input and its button. Wraps rather than overflowing on a phone, and the
+ input takes the slack so the button keeps its size. */
+.form-row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+.form-row input {
+ flex: 1;
+ min-width: 160px;
+}
+
+/* A pairing or invitation code: typed one character at a time, off a screen or
+ a piece of paper, so it is spaced and unambiguous. */
+.code-input,
+.code-display {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+.code-display {
+ font-size: 1.4em;
+ letter-spacing: 2px;
+ margin: 6px 0;
+}
+
+.settings-subform {
+ margin-top: 14px;
+ padding-top: 14px;
+ border-top: 1px solid var(--border);
+}
+
+.device-list {
+ list-style: none;
+ margin: 10px 0 0;
+ padding: 0;
+}
+.device-row {
display: flex;
- gap: 6px;
align-items: center;
- min-height: 30px;
+ gap: 10px;
+ padding: 8px 0;
+ border-bottom: 1px solid var(--border);
+}
+.device-row:last-child { border-bottom: none; }
+.device-key {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.85em;
+}
+.device-meta {
+ color: var(--text-secondary);
+ font-size: 0.85em;
+ flex: 1;
+ min-width: 0;
+}
+
+/* Leaving or deleting a group. Marked, because the two buttons in this tab
+ that cannot be undone should not look like the ones that can. */
+.danger-section {
+ border-color: color-mix(in srgb, var(--error) 40%, var(--border));
+}
+.danger-section .settings-heading { color: var(--error); }
+
+.badge-owner {
+ background: var(--accent);
+ color: var(--accent-text);
}
.admin-btn {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 1a63e23..853ae75 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -44,6 +44,11 @@ const UPLOAD_BUFFER_HIGH = 1024 * 1024;
// Segments of 256 KB: 24 in flight is 6 MB, enough to keep playback fed over a
// slow link and small enough that nothing accumulates.
+// How long to collect ICE candidates before sending the offer anyway. Long
+// enough for a STUN round trip on a slow link, short enough that a STUN server
+// that never answers costs a pause rather than the whole attempt.
+const ICE_GATHER_TIMEOUT_MS = 4000;
+
const STREAM_CREDITS = 24;
function _aborted() {
@@ -155,10 +160,31 @@ class MeshBayTransport {
const offer = await this._pc.createOffer();
await this._pc.setLocalDescription(offer);
+ // Wait for candidates, but not indefinitely.
+ //
+ // This is non-trickle signaling: the offer carries its candidates, so the
+ // SDP is only sent once gathering is done. When gathering *never* finishes
+ // — a STUN server that is slow, filtered, or being resolved through a DNS
+ // that is not answering — this promise never settles, and joining a group
+ // hangs with no error and nothing on screen. Reported after exactly that,
+ // and it succeeded on a later attempt, which is the shape of a network
+ // wait rather than a refusal.
+ //
+ // Past the deadline the offer goes out with whatever has been gathered.
+ // Host candidates are already there, which is enough on a LAN — the case
+ // this project cares most about — and the reflexive ones normally arrive
+ // in well under a second when STUN is reachable at all. A partial offer
+ // that usually connects beats a promise that never returns.
await new Promise((resolve) => {
if (this._pc.iceGatheringState === 'complete') return resolve();
+ const done = () => { clearTimeout(timer); resolve(); };
+ const timer = setTimeout(() => {
+ console.warn('[MeshBay] ICE gathering did not finish in',
+ ICE_GATHER_TIMEOUT_MS, 'ms — offering what we have');
+ done();
+ }, ICE_GATHER_TIMEOUT_MS);
this._pc.onicegatheringstatechange = () => {
- if (this._pc.iceGatheringState === 'complete') resolve();
+ if (this._pc.iceGatheringState === 'complete') done();
};
});
diff --git a/packages/meshbay-hub/tests/test_connect_never_hangs.py b/packages/meshbay-hub/tests/test_connect_never_hangs.py
new file mode 100644
index 0000000..d6f1369
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_connect_never_hangs.py
@@ -0,0 +1,91 @@
+"""
+Joining a group must fail, or succeed — never wait forever.
+
+Reported: a first attempt to connect hung with nothing on screen, and the same
+account connected a few minutes later. That is the shape of a network wait with
+no deadline, not of a refusal, and there were two of them.
+
+**ICE gathering.** Signaling here is non-trickle — the offer carries its
+candidates, so it is not sent until gathering says it is done. A STUN server
+that is slow, filtered, or resolved through a DNS that is not answering means
+`icegatheringstatechange` never reaches `complete`, and `connect()` never
+returns. Same shape as the `fullscreen` denial: a promise that never settles
+produces no error to find.
+
+**The hub call in the desktop client.** Node's `fetch` has no default timeout,
+so a host that accepts a connection and then says nothing holds the request for
+as long as the OS allows. `hub:probe` had a deadline; `hub:fetch`, which carries
+signaling, did not.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+TRANSPORT = STATIC / "transport.js"
+MAIN = (Path(__file__).resolve().parents[2] / "meshbay-client" / "src" / "main.js")
+
+pytestmark = pytest.mark.skipif(not TRANSPORT.exists(),
+ reason="SPA sources unavailable")
+
+
+def _gathering_block() -> str:
+ """The wait on ICE gathering, and only it."""
+ source = TRANSPORT.read_text(encoding="utf-8")
+ start = source.index("iceGatheringState")
+ return source[max(0, start - 900):start + 700]
+
+
+def test_ice_gathering_has_a_deadline():
+ block = _gathering_block()
+ assert "setTimeout" in block, (
+ "the wait on ICE gathering can never end, and connect() with it")
+ assert "ICE_GATHER_TIMEOUT_MS" in block
+
+
+def test_the_deadline_is_long_enough_for_a_stun_round_trip():
+ """Cutting gathering off too early drops the reflexive candidate and breaks
+ every connection that is not on the same network."""
+ source = TRANSPORT.read_text(encoding="utf-8")
+ match = re.search(r"const ICE_GATHER_TIMEOUT_MS = (\d+);", source)
+ assert match, "the constant is gone or was renamed"
+ assert 2000 <= int(match.group(1)) <= 10000
+
+
+def test_a_timed_out_gathering_still_sends_the_offer():
+ """Host candidates are already gathered, which is enough on a LAN. Giving
+ up instead would turn a slow STUN server into a refusal to connect."""
+ source = TRANSPORT.read_text(encoding="utf-8")
+ block = _gathering_block()
+ # The deadline resolves the promise; it does not reject it.
+ assert "reject" not in block.split("setTimeout", 1)[1][:300]
+ # And the offer is still posted afterwards.
+ assert "webrtc/offer" in source[source.index("iceGatheringState"):]
+
+
+@pytest.mark.skipif(not MAIN.exists(), reason="the desktop client is not present")
+def test_every_hub_call_from_the_client_has_a_deadline():
+ source = MAIN.read_text(encoding="utf-8")
+ block = source.split("ipcMain.handle('hub:fetch'", 1)[1].split("ipcMain.handle", 1)[0]
+ assert "AbortSignal.timeout" in block, (
+ "a hub that accepts the connection and says nothing holds this for "
+ "as long as the OS allows")
+
+
+@pytest.mark.skipif(not MAIN.exists(), reason="the desktop client is not present")
+def test_the_deadline_outlasts_the_hubs_own_longest_call():
+ """Signaling waits fifteen seconds for a node to answer an offer. A client
+ deadline under that would abort calls that were about to succeed."""
+ source = MAIN.read_text(encoding="utf-8")
+ match = re.search(r"const HUB_FETCH_TIMEOUT_MS = (\d+);", source)
+ assert match, "the constant is gone or was renamed"
+ assert int(match.group(1)) > 15000
+
+
+@pytest.mark.skipif(not MAIN.exists(), reason="the desktop client is not present")
+def test_a_timeout_says_so_rather_than_saying_fetch_failed():
+ source = MAIN.read_text(encoding="utf-8")
+ block = source.split("ipcMain.handle('hub:fetch'", 1)[1].split("ipcMain.handle", 1)[0]
+ assert "TimeoutError" in block and "did not answer" in block
diff --git a/packages/meshbay-hub/tests/test_resume_position.py b/packages/meshbay-hub/tests/test_resume_position.py
new file mode 100644
index 0000000..ef239c5
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_resume_position.py
@@ -0,0 +1,158 @@
+"""
+"Resume where you left off" belongs to an account, not to a machine.
+
+The position was stored as `mb:pos:<file>` in localStorage — per *device*. Sign
+in with a second account on the same computer and the player offered to resume a
+film that account had never opened. Wrong on its own terms, and a small
+disclosure of what the other person watches: the offer only appears for files
+someone has actually been through.
+
+The functions are lifted out of `app.js` and run for real, rather than having
+their source inspected, because the thing worth holding is the behaviour of two
+accounts sharing one storage — which no assertion about the source text says.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+APP = STATIC / "app.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not APP.exists(),
+ reason="node or the SPA sources are not available")
+
+WANTED = ("resumeKey", "readResumePosition", "writeResumePosition",
+ "purgeUnscopedResumePositions")
+
+
+def _extract() -> str:
+ """The real source of the functions under test, and nothing else.
+
+ `app.js` imports preact and cannot be loaded outside a browser, so the
+ declarations are sliced out by brace matching. A rename breaks this loudly,
+ which is the intent — a silently skipped test is worse than a failing one.
+ """
+ source = APP.read_text(encoding="utf-8")
+ out = [
+ f"const RESUME_MIN_S = {_const(source, 'RESUME_MIN_S')};",
+ f"const RESUME_MAX_FRACTION = {_const(source, 'RESUME_MAX_FRACTION')};",
+ ]
+ for name in WANTED:
+ start = source.index(f"function {name}(")
+ depth, i = 0, source.index("{", start)
+ while True:
+ if source[i] == "{":
+ depth += 1
+ elif source[i] == "}":
+ depth -= 1
+ if depth == 0:
+ break
+ i += 1
+ out.append(source[start:i + 1])
+ return "\n".join(out)
+
+
+def _const(source: str, name: str) -> str:
+ match = re.search(rf"^const {name} = ([^;]+);", source, re.M)
+ assert match, f"{name} is gone or was renamed"
+ return match.group(1)
+
+
+def _run(body: str, tmp_path: Path):
+ script = tmp_path / "case.mjs"
+ script.write_text(
+ "const store = new Map();\n"
+ "globalThis.localStorage = {\n"
+ " get length() { return store.size; },\n"
+ " key: i => Array.from(store.keys())[i] ?? null,\n"
+ " getItem: k => (store.has(k) ? store.get(k) : null),\n"
+ " setItem: (k, v) => store.set(k, String(v)),\n"
+ " removeItem: k => store.delete(k),\n"
+ "};\n"
+ # Whoever is signed in, swapped by the cases below.
+ "let AUTH = null;\n"
+ "function loadAuth() { return AUTH; }\n"
+ f"{_extract()}\n"
+ "const out = [];\n"
+ "const say = (...a) => out.push(...a);\n"
+ "const keys = () => Array.from(store.keys()).sort();\n"
+ f"{body}\n"
+ "console.log(JSON.stringify(out));\n",
+ encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+# ── The bug ─────────────────────────────────────────────────────────────────
+
+def test_a_second_account_is_not_offered_the_firsts_position(tmp_path):
+ """The report: a brand-new account was offered a resume point."""
+ assert _run("""
+AUTH = { userId: 'alice' };
+writeResumePosition('film1', 600, 7200);
+say(readResumePosition('film1'));
+
+AUTH = { userId: 'bob' };
+say(readResumePosition('film1'));
+""", tmp_path) == [600, 0]
+
+
+def test_each_account_keeps_its_own_place_in_the_same_film(tmp_path):
+ """Two people watching one film on one machine is the ordinary case, and
+ neither should move the other's bookmark."""
+ assert _run("""
+AUTH = { userId: 'alice' }; writeResumePosition('film1', 600, 7200);
+AUTH = { userId: 'bob' }; writeResumePosition('film1', 1800, 7200);
+AUTH = { userId: 'alice' }; say(readResumePosition('film1'));
+AUTH = { userId: 'bob' }; say(readResumePosition('film1'));
+""", tmp_path) == [600, 1800]
+
+
+def test_nothing_is_written_when_nobody_is_signed_in(tmp_path):
+ assert _run("""
+AUTH = null;
+writeResumePosition('film1', 600, 7200);
+say(keys().length, readResumePosition('film1'));
+""", tmp_path) == [0, 0]
+
+
+def test_positions_written_before_the_fix_are_dropped(tmp_path):
+ """
+ They cannot be re-keyed: there is no record of whose they were, and guessing
+ hands them to whoever signs in next, which is the bug itself.
+ """
+ assert _run("""
+localStorage.setItem('mb:pos:film1', '600'); // the old shape
+localStorage.setItem('mb:pos:alice:film2', '900'); // the new one
+localStorage.setItem('mb_auth', '{}'); // nothing to do with this
+purgeUnscopedResumePositions();
+say(...keys());
+""", tmp_path) == ["mb:pos:alice:film2", "mb_auth"]
+
+
+# ── What must still hold ────────────────────────────────────────────────────
+
+def test_a_glance_at_the_opening_is_not_a_bookmark(tmp_path):
+ assert _run("""
+AUTH = { userId: 'alice' };
+writeResumePosition('film1', 12, 7200);
+say(readResumePosition('film1'));
+""", tmp_path) == [0]
+
+
+def test_a_film_watched_to_the_end_stops_offering_to_resume(tmp_path):
+ """And the earlier bookmark goes with it, rather than sitting there
+ offering the last thirty seconds forever."""
+ assert _run("""
+AUTH = { userId: 'alice' };
+writeResumePosition('film1', 3600, 7200);
+writeResumePosition('film1', 7150, 7200);
+say(readResumePosition('film1'), keys().length);
+""", tmp_path) == [0, 0]
diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py
index 9d02d42..1556cb7 100644
--- a/packages/meshbay-hub/tests/test_spa_ordering.py
+++ b/packages/meshbay-hub/tests/test_spa_ordering.py
@@ -125,24 +125,67 @@ def _component(name: str) -> str:
return source[start:end if end != -1 else len(source)]
-def test_members_panel_renders_what_it_owns():
- panel = _component("MembersPanel")
+def test_the_group_settings_panel_renders_what_it_owns():
+ panel = _component("GroupSettingsPanel")
assert "members.map(" in panel, "the member list is not rendered"
assert "onSubmit=${doInvite}" in panel, "the invite form is not rendered"
assert "onSubmit=${doPair}" in panel, "the pairing form is not rendered"
+ assert "device.mine_title" in panel, "the devices section is not rendered"
-def test_the_invite_form_comes_before_the_list():
- panel = _component("MembersPanel")
- assert panel.index("onSubmit=${doInvite}") < panel.index("members.map("), \
- "the invite form belongs above the member list"
+def test_the_roster_comes_last():
+ """
+ It is the only part of this tab with no upper bound. Two hundred members
+ would put every form and every control below the fold, which is what the
+ order is for — asked for in those terms.
+ """
+ panel = _component("GroupSettingsPanel")
+ listing = panel.index("members.map(")
+ for name, marker in (("the invite form", "onSubmit=${doInvite}"),
+ ("the pairing form", "onSubmit=${doPair}"),
+ ("the devices section", "device.mine_title"),
+ ("leaving and deleting", "members.danger_title")):
+ assert panel.index(marker) < listing, f"{name} belongs above the roster"
+
+
+def test_leaving_a_group_lives_with_the_group_settings():
+ """It used to sit in the page header beside the group's name, which is
+ neither where it belongs nor where anyone looked for it."""
+ panel = _component("GroupSettingsPanel")
+ assert "group.leave_confirm" in panel and "group.delete_group_confirm" in panel
+ page = _component("GroupPage")
+ assert "group.leave_confirm" not in page, "still in the header as well"
+
+
+def test_leaving_does_not_require_the_node_to_be_up():
+ """
+ Moving these into a tab that only rendered on a live connection would have
+ made them unreachable exactly when a node is down — which is when someone
+ most wants to leave. Membership is hub-side; the tab bar does not wait for
+ the node.
+ """
+ page = _component("GroupPage")
+ tabs = page[page.index("group-tabs"):]
+ tabs = tabs[:tabs.index("</div>")]
+ before = page[:page.index("group-tabs")]
+ guard = before[before.rindex("${"):]
+ assert "status === 'connected'" not in guard, (
+ "the tab bar is gated on the connection, so a group on an offline node "
+ "cannot be left")
+
+
+def test_the_node_dependent_sections_say_when_the_node_is_down():
+ """The other half of that: inviting needs the node to wrap the group key,
+ so it must explain itself rather than silently doing nothing."""
+ panel = _component("GroupSettingsPanel")
+ assert "connected &&" in panel and "!connected &&" in panel
-def test_admin_page_does_not_borrow_the_members_panel_state():
+def test_admin_page_does_not_borrow_the_group_settings_state():
admin = _component("AdminPage")
for name in ("doInvite", "adminId", "inviteCode", "setInviteUser"):
assert name not in admin, \
- f"AdminPage references {name}, which only exists in MembersPanel"
+ f"AdminPage references {name}, which only exists in GroupSettingsPanel"
# ── Upload pipelining ───────────────────────────────────────────────────────
diff --git a/packages/meshbay-hub/tests/test_table_rows_measured.py b/packages/meshbay-hub/tests/test_table_rows_measured.py
new file mode 100644
index 0000000..60ec878
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_table_rows_measured.py
@@ -0,0 +1,116 @@
+"""
+Every cell in a row ends where its row ends.
+
+Reported as "un décalage sur les lignes du tableau listant les membres": the
+horizontal rules between rows came out staggered rather than straight.
+
+The cause was `display: flex` on the actions `<td>`. **A flex table cell stops
+being a table cell** — it no longer stretches to the height of its row, so its
+`border-bottom` is drawn wherever its own content happens to end. Measured
+before the fix, in a row whose other cells were `top 76, height 40`, the actions
+cell was `top 77, height 30`: its rule nine pixels above the rest.
+
+Nothing in the stylesheet says this. `min-height: 30px` was already there, added
+for a related symptom, and reads as though it settles the question. Only the
+rectangles show it does not — which is what this file is for.
+"""
+
+import json
+import subprocess
+from pathlib import Path
+
+import shutil
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "layout_probe.py"
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("google-chrome") is None or not (STATIC / "style.css").exists(),
+ reason="Chrome or the SPA stylesheet is not available")
+
+WIDTHS = [420, 900]
+
+# The members table as `MembersTab` renders it: the owner's row carries no
+# button, which is the row that used to break. Two ordinary rows after it, so a
+# rule between two *equal* rows can be told from a rule against the odd one.
+TABLE = """
+<div class="main"><div class="settings-section">
+<table class="admin-table">
+ <thead><tr><th>User</th><th>Role</th><th></th></tr></thead>
+ <tbody>
+ <tr><td>grenet</td>
+ <td><span class="badge">Owner</span></td>
+ <td class="admin-actions"></td></tr>
+ <tr><td>toto</td>
+ <td><span class="badge">Member</span></td>
+ <td class="admin-actions"><button class="admin-btn danger">Remove</button></td></tr>
+ <tr><td>alice</td>
+ <td><span class="badge">Member</span></td>
+ <td class="admin-actions"><button class="admin-btn danger">Remove</button></td></tr>
+ </tbody>
+</table>
+</div></div>
+"""
+
+ROWS = (1, 2, 3)
+SELECTORS = [f"tbody tr:nth-child({r}) td:nth-child({c})"
+ for r in ROWS for c in (1, 2, 3)]
+
+
+@pytest.fixture(scope="module")
+def measured(tmp_path_factory):
+ fragment = tmp_path_factory.mktemp("table") / "fragment.html"
+ fragment.write_text(TABLE)
+ proc = subprocess.run(
+ ["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS),
+ str(fragment), *SELECTORS],
+ capture_output=True, text=True, timeout=180)
+ assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
+ out = json.loads(proc.stdout)
+ assert "error" not in out, f"no measurement: {out}"
+ return out
+
+
+def _cells(measured, width: int, row: int) -> list[dict]:
+ boxes = measured[str(width)]["boxes"]
+ return [boxes[f"tbody tr:nth-child({row}) td:nth-child({c})"] for c in (1, 2, 3)]
+
+
+@pytest.mark.parametrize("width", WIDTHS)
+@pytest.mark.parametrize("row", ROWS)
+def test_the_rule_under_a_row_is_one_straight_line(measured, width, row):
+ bottoms = [c["top"] + c["height"] for c in _cells(measured, width, row)]
+ assert max(bottoms) - min(bottoms) <= 1, (
+ f"row {row} at {width}px ends at {bottoms} — the border under the "
+ f"actions cell is drawn {max(bottoms) - min(bottoms)}px off the others")
+
+
+@pytest.mark.parametrize("width", WIDTHS)
+@pytest.mark.parametrize("row", ROWS)
+def test_every_cell_in_a_row_starts_at_the_same_height(measured, width, row):
+ tops = [c["top"] for c in _cells(measured, width, row)]
+ assert max(tops) - min(tops) <= 1, f"row {row} at {width}px starts at {tops}"
+
+
+@pytest.mark.parametrize("width", WIDTHS)
+def test_the_row_without_a_button_is_as_tall_as_the_others(measured, width):
+ """The owner cannot be removed, so that row has an empty actions cell. It
+ still has to be a row, not a thin one that reads as a rendering fault."""
+ heights = [_cells(measured, width, r)[0]["height"] for r in ROWS]
+ assert max(heights) - min(heights) <= 1, (
+ f"row heights at {width}px are {heights}")
+
+
+@pytest.mark.parametrize("width", WIDTHS)
+def test_the_rows_are_stacked_with_no_gap_or_overlap(measured, width):
+ """A cell that does not fill its row leaves the next one starting early or
+ late; consecutive rows meeting exactly is what says the table is intact."""
+ for row in ROWS[:-1]:
+ below = _cells(measured, width, row + 1)[0]["top"]
+ for cell in _cells(measured, width, row):
+ end = cell["top"] + cell["height"]
+ assert abs(below - end) <= 1, (
+ f"at {width}px a cell of row {row} ends at {end} while row "
+ f"{row + 1} starts at {below}")