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 -2
View File
@@ -3229,7 +3229,13 @@ async function nativeSaveFile(url, name, meta){
e.preventDefault(); e.stopPropagation(); // never let the WebView navigate to the file
var P=C.Plugins;
// Real inline save into the app's Files folder (id comes from /files/<id>; mime refines the subfolder).
if(P&&P.Filesystem){ nativeSaveFile(href, a.getAttribute('download')||'', { mime:a.getAttribute('data-mime')||'' }); return; }
if(P&&P.Filesystem){
var isRec=/\/mrec\//.test(href); var dn=a.getAttribute('download')||'';
// recordings/transcripts: after saving, OPEN in native Quick Look (view + its own share/save — e.g. into
// Files or Word) instead of the WebView loading the file inline with no way back.
nativeSaveFile(href, dn, { mime:a.getAttribute('data-mime')||'' }).then(function(){ if(isRec){ var rid=String(href).split('?')[0].split('/').pop(); var rec=bzLibGet(rid); if(rec) bzOpenFile(rec, dn); } });
return;
}
var isImg = /\.(png|jpe?g|gif|webp|heic|heif|bmp|svg)(\?|#|$)/i.test(href) || !!a.closest('.lightbox');
toast(isImg ? 'Press and hold the image, then tap "Save to Photos".' : 'Saving files needs the latest app update.');
}, true); // capture phase so it wins over the lightbox / bubble handlers
@@ -5077,7 +5083,7 @@ async function loadScheduledMeetings(){
+'<div class="si-meta">'+meta.join(' · ')+'</div>'
+(m.description?'<div class="si-desc">'+pEsc(m.description)+'</div>':'')
+(m.invited&&m.invited.length?'<div class="si-invited" title="'+pEsc(m.invited.join(', '))+'">'+ic('users',12)+' '+pEsc(m.invited.slice(0,3).join(', '))+(m.invited.length>3?(' +'+(m.invited.length-3)):'')+'</div>':'')
+(m.recordings&&m.recordings.length?'<div class="si-recs">'+m.recordings.map(r=>'<a class="rec-dl '+(r.kind==='video'?'vid':'txt')+'" href="'+pEsc(r.url)+'" title="Download '+(r.kind==='video'?'recording':'transcript')+'">'+ic('download',14)+'<span>'+(r.kind==='video'?'Recording':'Transcript')+'</span>'+(r.kind==='video'&&r.durationMs?'<span class="rd-dur">'+fmtElapsed(r.durationMs)+'</span>':'')+'</a>').join('')+'</div>':'')+'</div>'
+(m.recordings&&m.recordings.length?'<div class="si-recs">'+m.recordings.map(r=>{ var isVid=r.kind==='video'; var _dn=String(m.title||'Meeting').replace(/[\/\\:*?"<>|]+/g,'_')+(isVid?' recording':' transcript')+(isVid?(/mp4/.test(r.mime||'')?'.mp4':'.webm'):'.txt'); return '<a class="rec-dl '+(isVid?'vid':'txt')+'" href="'+pEsc(r.url)+'" download="'+pEsc(_dn)+'" data-mime="'+pEsc(r.mime||(isVid?'video/webm':'text/plain'))+'" title="Download '+(isVid?'recording':'transcript')+'">'+ic('download',14)+'<span>'+(isVid?'Recording':'Transcript')+'</span>'+(isVid&&r.durationMs?'<span class="rd-dur">'+fmtElapsed(r.durationMs)+'</span>':'')+'</a>'; }).join('')+'</div>':'')+'</div>'
+'<div class="si-actions">'
+((m.status!=='past'&&!cancelled)?'<button class="iconbtn copylink" data-link="'+pEsc(m.link||'')+'" title="Copy invite link" aria-label="Copy invite link">'+ic('link',14)+'</button>':'')
+((m.status!=='past'&&!cancelled&&canStart)?'<button class="btn sm join" data-code="'+pEsc(m.roomCode)+'">'+(m.status==='running'?'Join':'Start')+'</button>':'')