transcript: fix duplicate copy on multi-device + iOS download hijacking the app
- Duplicate ("Transcript shows two times"): finalizeTranscript had a race — for the
SAME user on two devices (#12 multi-device), both devices leaving at once each
passed the subscriber membership check across an await before either removed the
sub, so both wrote a private transcript. Now the subscriber is CLAIMED
SYNCHRONOUSLY (subs.delete filter) before any await, so only the first writer wins.
Verified with a concurrency simulation (2 concurrent leaves -> 1 write).
- iOS download: the /mrec transcript link had no `download` attribute, so WKWebView
NAVIGATED to the file and loaded it inline with no way back (had to force-quit the
app). Added download + data-mime so browsers download it and the existing native
click-interceptor catches it: it now saves to the Files folder and opens in native
Quick Look (view + its own share/save — into Files or Word) instead of hijacking
the WebView. recDTO now exposes the recording mime.
Web/server only — no native build needed; live on next app launch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+8
-4
@@ -32,21 +32,25 @@ async function meetingContext(room) {
|
||||
async function finalizeTranscript(room, onlyUserId) {
|
||||
const subs = transcriptSubs.get(room); if (!subs || !subs.size) { if (!onlyUserId) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } return; }
|
||||
const buf = transcriptBuffers.get(room) || [];
|
||||
const ids = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs];
|
||||
// CLAIM the subscriber(s) SYNCHRONOUSLY (before any await). Two concurrent finalize calls for the SAME uid —
|
||||
// e.g. the same user's two devices both leaving at once (#12 multi-device) — would otherwise both pass the
|
||||
// membership check during the awaits below and write the transcript TWICE (the "transcript shows two times"
|
||||
// bug). subs.delete() returns true only for the first caller, so the loser claims nothing and writes nothing.
|
||||
const candidates = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs];
|
||||
const ids = candidates.filter((uid) => subs.delete(uid));
|
||||
if (ids.length && buf.length) {
|
||||
const ctx = await meetingContext(room);
|
||||
const lines = buf.map((s) => { const ts = new Date(s.t); const hh = String(ts.getHours()).padStart(2, '0'), mm = String(ts.getMinutes()).padStart(2, '0'); return '[' + hh + ':' + mm + '] ' + s.speaker + ': ' + s.text; });
|
||||
const body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n';
|
||||
for (const uid of ids) {
|
||||
let user = null; try { user = await R.users.byId(uid); } catch (_) {}
|
||||
if (!user) { subs.delete(uid); continue; }
|
||||
if (!user) continue;
|
||||
const id = A.id(); const file = 'm_' + id + '.txt';
|
||||
try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; }
|
||||
// groupId null → private to its creator (see canSeeRec / /mrec auth).
|
||||
await R.recordings.create({ id, teamId: user.team_id, room, groupId: null, meetingId: ctx.meetingId, title: ctx.title, kind: 'transcript', file, mime: 'text/plain', size: null, durationMs: null, createdBy: uid, createdByName: user.name || user.email });
|
||||
subs.delete(uid);
|
||||
}
|
||||
} else { ids.forEach((uid) => subs.delete(uid)); }
|
||||
}
|
||||
if (!subs.size) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } // last subscriber done
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user