summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/meshbay-client/src/main.js14
-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
18 files changed, 838 insertions, 153 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index 8e11f1f..6228189 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -252,6 +252,10 @@ function publicKeyB64(privateKey) {
// full-screen is the whole list.
const GRANTED_PERMISSIONS = new Set(['fullscreen']);
+// No hub call is still going to be answered after this. The hub's longest is
+// signaling a WebRTC offer, which gives up at fifteen seconds of its own.
+const HUB_FETCH_TIMEOUT_MS = 30000;
+
// ── Window ──────────────────────────────────────────────────────────────────
let mainWindow = null;
@@ -414,8 +418,18 @@ function registerBridge() {
method: (init && init.method) || 'GET',
headers: (init && init.headers) || {},
body: (init && init.body) || undefined,
+ // Node's fetch waits as long as the OS lets it, which for a host that
+ // accepts a connection and then says nothing is minutes. The hub's own
+ // longest call is signaling, which gives up at fifteen seconds, so
+ // anything past this is not an answer that is still coming.
+ signal: AbortSignal.timeout(HUB_FETCH_TIMEOUT_MS),
});
} catch (e) {
+ if (e && (e.name === 'TimeoutError' || e.name === 'AbortError')) {
+ throw new Error(
+ `${target.origin} accepted the connection but did not answer within `
+ + `${Math.round(HUB_FETCH_TIMEOUT_MS / 1000)}s.`);
+ }
// 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));
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}")