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:
2026-08-14 19:47:23 +05:30
parent e46ac1e7cc
commit 87a76c09f3
3 changed files with 17 additions and 7 deletions
+8 -4
View File
@@ -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
}