fix: bind clinical image and export actions to their original account
All checks were successful
Forgejo Android APK / Root app tests (push) Successful in 31s
Forgejo Android APK / Build signed APK (push) Successful in 1m57s

This commit is contained in:
Daniel 2026-09-07 07:45:48 +02:00
parent dc83f6e21b
commit 40e7dd5206
7 changed files with 556 additions and 82 deletions

View file

@ -1,9 +1,11 @@
import { escapeAttr, escapeHtml } from './citations.js';
import { captureSharingOwner, assertSharingOwner, validSharingOwner, sharingFilename } from './sharing.js';
export function createAssistantExporter(options) {
options = options || {};
var exportCacheKey = '';
var exportCacheItems = null;
var inlineExportClose = null;
function invalidate() {
exportCacheKey = '';
@ -11,6 +13,8 @@ export function createAssistantExporter(options) {
}
function exportAnswerPdf(state) {
var owner;
try { owner = captureSharingOwner(); } catch (e) { return; }
state = state || {};
if (!state.lastAnswer) {
if (typeof options.showToast === 'function') options.showToast('No answer to export', 'error');
@ -19,74 +23,111 @@ export function createAssistantExporter(options) {
var exportItems = collectExportItems(state.messages || [], state.lastAnswer, state.lastSources || [], options.presentMessage);
var cacheKey = buildExportCacheKey(exportItems, state.lastGeneratedImageSrc || '');
if (exportCacheKey === cacheKey && exportCacheItems) {
showPrintableExport(exportCacheItems, state.lastGeneratedImageSrc || '');
showPrintableExport(exportCacheItems, state.lastGeneratedImageSrc || '', owner);
return;
}
exportCacheKey = cacheKey;
exportCacheItems = exportItems;
showPrintableExport(exportItems, state.lastGeneratedImageSrc || '');
showPrintableExport(exportItems, state.lastGeneratedImageSrc || '', owner);
}
function showPrintableExport(items, imageSrc) {
function showPrintableExport(items, imageSrc, owner) {
if (!validSharingOwner(owner)) return;
if (shouldUseInlineExport()) {
writeInlineChatExport(items, imageSrc);
writeInlineChatExport(items, imageSrc, owner);
return;
}
var doc = openExportWindow();
var doc = openExportWindow(owner);
if (!doc) {
if (typeof options.showToast === 'function') options.showToast('Allow popups to export PDF', 'error');
if (validSharingOwner(owner) && typeof options.showToast === 'function') options.showToast('Allow popups to export PDF', 'error');
return;
}
writePrintableChatExport(doc, items, imageSrc);
writePrintableChatExport(doc, items, imageSrc, owner);
}
function openExportWindow() {
function openExportWindow(owner) {
assertSharingOwner(owner);
var doc = window.open('', '_blank', 'width=900,height=1100');
if (!doc) return null;
if (!validSharingOwner(owner)) { doc.close(); return null; }
doc.document.open();
doc.document.write('<!doctype html><html><head><title>Preparing Clinical Assistant Export</title><style>body{font-family:Arial,sans-serif;color:#111827;margin:36px;line-height:1.5}.spinner{width:18px;height:18px;border:3px solid #e5e7eb;border-top-color:#7c3aed;border-radius:50%;display:inline-block;vertical-align:middle;margin-right:8px;animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}</style></head><body><p><span class="spinner"></span>Preparing PDF export...</p></body></html>');
doc.document.close();
return doc;
}
function writePrintableChatExport(doc, items, imageSrc) {
function writePrintableChatExport(doc, items, imageSrc, owner) {
if (!doc || doc.closed) return;
var timer;
function close() {
clearTimeout(timer);
owner.signal.removeEventListener('abort', close);
try { doc.close(); } catch (e) { /* Only this export window is owned here. */ }
}
function print() {
if (!validSharingOwner(owner)) { close(); return; }
if (doc.closed) return;
try { doc.focus(); assertSharingOwner(owner); doc.print(); } catch (e) {}
}
owner.signal.addEventListener('abort', close, { once: true });
var html = buildPrintableChatHtml(items, imageSrc, false);
if (!validSharingOwner(owner)) { close(); return; }
doc.document.open();
doc.document.write(html);
doc.document.close();
try {
var printBtn = doc.document.getElementById('assistant-export-print');
if (printBtn) printBtn.addEventListener('click', function () { doc.focus(); doc.print(); });
var closeBtn = doc.document.getElementById('assistant-export-close');
if (closeBtn) closeBtn.addEventListener('click', function () { try { doc.close(); } catch (e) { doc.location.href = '/'; } });
} catch (e) {}
setTimeout(function () { try { doc.focus(); doc.print(); } catch (e) {} }, 500);
var printBtn = doc.document.getElementById('assistant-export-print');
if (printBtn) printBtn.addEventListener('click', print);
var closeBtn = doc.document.getElementById('assistant-export-close');
if (closeBtn) closeBtn.addEventListener('click', close);
timer = setTimeout(print, 500);
}
function writeInlineChatExport(items, imageSrc) {
function writeInlineChatExport(items, imageSrc, owner) {
assertSharingOwner(owner);
closeInlineExport();
var modal = document.createElement('div');
modal.id = 'assistant-export-modal';
modal.innerHTML = '<style>' + inlineExportCss() + exportTableScrollCss() + '</style><div class="assistant-export-sheet">' + buildPrintableChatBody(items, imageSrc, true) + '</div>';
if (!validSharingOwner(owner)) return;
function close() {
modal.remove();
owner.signal.removeEventListener('abort', close);
window.removeEventListener('popstate', close);
if (inlineExportClose === close) {
inlineExportClose = null;
document.body.classList.remove('assistant-export-open');
}
}
inlineExportClose = close;
owner.signal.addEventListener('abort', close, { once: true });
document.body.appendChild(modal);
document.body.classList.add('assistant-export-open');
var printBtn = modal.querySelector('#assistant-export-print');
var closeBtn = modal.querySelector('#assistant-export-close');
if (printBtn) printBtn.addEventListener('click', async function () {
if (!validSharingOwner(owner)) { close(); return; }
printBtn.disabled = true;
var originalText = printBtn.textContent;
printBtn.textContent = 'Preparing export...';
try {
var html = buildPrintableChatHtml(items, imageSrc, false);
if (await printWithNativeBridge(html)) return;
if (!(await saveInlineExport(html))) window.print();
var printed = await printWithNativeBridge(html, owner);
assertSharingOwner(owner);
if (printed) return;
var saved = await saveInlineExport(html, owner);
assertSharingOwner(owner);
if (!saved) window.print();
} catch (e) {
if (!validSharingOwner(owner) || e.name === 'AbortError') return;
if (typeof options.showToast === 'function') options.showToast(e.message || 'Export failed', 'error');
} finally {
printBtn.disabled = false;
printBtn.textContent = originalText;
if (validSharingOwner(owner)) {
printBtn.disabled = false;
printBtn.textContent = originalText;
} else close();
}
});
if (closeBtn) closeBtn.addEventListener('click', closeInlineExport);
if (closeBtn) closeBtn.addEventListener('click', close);
modal.addEventListener('click', function (event) {
if (!event.defaultPrevented && !event.button && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey) {
var citation = event.target.closest('.assistant-cite');
@ -100,16 +141,14 @@ export function createAssistantExporter(options) {
reference.scrollIntoView({ block: 'nearest' });
}
}
if (event.target === modal) closeInlineExport();
if (event.target === modal) close();
});
try { window.history.pushState({ assistantExport: true }, '', window.location.href); } catch (e) {}
window.addEventListener('popstate', closeInlineExport, { once: true });
window.addEventListener('popstate', close, { once: true });
}
function closeInlineExport() {
var existing = document.getElementById('assistant-export-modal');
if (existing) existing.remove();
document.body.classList.remove('assistant-export-open');
if (inlineExportClose) inlineExportClose();
}
function buildPrintableChatHtml(items, imageSrc, inline) {
@ -162,33 +201,43 @@ function shouldUseInlineExport() {
return isCapacitor || isSmallTouch;
}
async function saveInlineExport(html) {
async function saveInlineExport(html, owner) {
assertSharingOwner(owner);
var plugins = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins : null;
if (!plugins || !plugins.Filesystem) return false;
try {
var name = 'clinical-assistant-export-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.html';
var name = sharingFilename('clinical-assistant-export', 'html');
var saved = await plugins.Filesystem.writeFile({ path: name, data: utf8ToBase64(html), directory: 'CACHE' });
assertSharingOwner(owner);
if (plugins.Share && saved && saved.uri) {
try {
await plugins.Share.share({ title: 'Clinical Assistant Export', text: 'Open this export and use Print to save as PDF.', url: saved.uri, dialogTitle: 'Save or share export' });
assertSharingOwner(owner);
} catch (e) {
if (!isShareCancel(e)) throw e;
assertSharingOwner(owner);
if (e.name === 'AbortError' || !isShareCancel(e)) throw e;
}
}
if (typeof window.showToast === 'function') window.showToast('Export prepared', 'success');
return true;
} catch (e) {
assertSharingOwner(owner);
if (e.name === 'AbortError') throw e;
if (typeof window.showToast === 'function') window.showToast(e.message || 'Export failed', 'error');
return false;
}
}
async function printWithNativeBridge(html) {
async function printWithNativeBridge(html, owner) {
assertSharingOwner(owner);
if (!window.NativePrint || typeof window.NativePrint.printHtml !== 'function') return false;
try {
window.NativePrint.printHtml('Clinical Assistant Export', utf8ToBase64(html));
await window.NativePrint.printHtml('Clinical Assistant Export', utf8ToBase64(html));
assertSharingOwner(owner);
return true;
} catch (e) {
assertSharingOwner(owner);
if (e.name === 'AbortError') throw e;
if (typeof window.showToast === 'function') window.showToast(e.message || 'Native print failed', 'error');
return false;
}

View file

@ -1,9 +1,12 @@
import { captureSharingOwner, assertSharingOwner, validSharingOwner, sharingFilename } from './sharing.js';
import { escapeAttr } from './citations.js';
export function createAssistantImageStore() {
var generatedImages = {};
var generatedImageSeq = 0;
var previewKeyHandler = null;
var previewModal = null;
var previewOwner = null;
function renderGeneratedImage(src, alt, downloadUrl) {
var id = 'img-' + (++generatedImageSeq);
@ -19,8 +22,13 @@ export function createAssistantImageStore() {
var item = generatedImages[id];
var src = item && (item.src || item);
if (!src) return;
var owner;
try { owner = captureSharingOwner(); } catch (e) { return; }
closeImagePreview();
previewOwner = owner;
owner.signal.addEventListener('abort', closeImagePreview, { once: true });
var modal = document.createElement('div');
previewModal = modal;
modal.className = 'assistant-image-modal';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
@ -35,7 +43,10 @@ export function createAssistantImageStore() {
}
function closeImagePreview() {
document.querySelectorAll('.assistant-image-modal').forEach(function (el) { el.remove(); });
if (previewModal) previewModal.remove();
previewModal = null;
if (previewOwner) previewOwner.signal.removeEventListener('abort', closeImagePreview);
previewOwner = null;
document.body.classList.remove('assistant-image-preview-open');
if (previewKeyHandler) document.removeEventListener('keydown', previewKeyHandler);
previewKeyHandler = null;
@ -47,11 +58,15 @@ export function createAssistantImageStore() {
var downloadUrl = item && item.downloadUrl;
if (!src) return;
try {
var ticket = captureSharingOwner();
if (downloadUrl) {
await downloadFromServer(downloadUrl);
await downloadFromServer(downloadUrl, ticket);
assertSharingOwner(ticket);
return;
}
if (await saveWithNativeShare(src)) return;
var saved = await saveWithNativeShare(src, ticket);
assertSharingOwner(ticket);
if (saved) return;
if (isNativeApp()) {
if (typeof window.showToast === 'function') window.showToast('Image saving is not available in this app build yet. Update the app and try again.', 'error');
return;
@ -60,8 +75,10 @@ export function createAssistantImageStore() {
if (typeof window.showToast === 'function') window.showToast('Mobile browser download is not supported here. Use Preview and long-press the image to save it.', 'info');
return;
}
await downloadWithBrowser(src);
await downloadWithBrowser(src, ticket);
assertSharingOwner(ticket);
} catch (e) {
if (!validSharingOwner(ticket) || e.name === 'AbortError') return;
if (typeof window.showToast === 'function') window.showToast(e.message || 'Image download failed', 'error');
}
}
@ -80,23 +97,31 @@ export function createAssistantImageStore() {
};
}
async function saveWithNativeShare(src) {
async function saveWithNativeShare(src, ticket) {
assertSharingOwner(ticket);
if (isNativeApp()) {
if (await saveWithNativeImageBridge(src)) return true;
return await saveWithCapacitorShare(src);
var nativeSaved = await saveWithNativeImageBridge(src, ticket);
assertSharingOwner(ticket);
if (nativeSaved) return true;
} else {
var webShare = await shareWithWebFile(src, ticket);
assertSharingOwner(ticket);
if (webShare !== 'unavailable') return true;
}
var webShare = await shareWithWebFile(src);
if (webShare !== 'unavailable') return true;
return await saveWithCapacitorShare(src);
var saved = await saveWithCapacitorShare(src, ticket);
assertSharingOwner(ticket);
return saved;
}
async function saveWithNativeImageBridge(src) {
async function saveWithNativeImageBridge(src, ticket) {
assertSharingOwner(ticket);
if (!window.NativeFiles || typeof window.NativeFiles.saveImage !== 'function') return false;
try {
var base64 = await imageSourceToBase64(src);
var name = 'clinical-visual-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.png';
var base64 = await imageSourceToBase64(src, ticket);
assertSharingOwner(ticket);
var name = sharingFilename('clinical-visual', 'png');
var result = String(window.NativeFiles.saveImage(name, base64) || '');
assertSharingOwner(ticket);
if (result.indexOf('saved:') === 0) {
if (typeof window.showToast === 'function') window.showToast('Image saved to Photos', 'success');
return true;
@ -105,57 +130,84 @@ async function saveWithNativeImageBridge(src) {
window.showToast(result.slice(6) || 'Native image save failed', 'error');
}
} catch (e) {
assertSharingOwner(ticket);
if (e.name === 'AbortError') throw e;
if (typeof window.showToast === 'function') window.showToast(e.message || 'Native image save failed', 'error');
}
return false;
}
async function saveWithCapacitorShare(src) {
async function saveWithCapacitorShare(src, ticket) {
assertSharingOwner(ticket);
var plugins = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins : null;
if (!plugins) return false;
if (!plugins.Filesystem && plugins.Share && !/^data:image\//i.test(src)) {
try {
assertSharingOwner(ticket);
await plugins.Share.share({ title: 'Clinical visual', text: 'Clinical Assistant generated visual', url: src, dialogTitle: 'Save or share clinical visual' });
assertSharingOwner(ticket);
return true;
} catch (e) { return isShareCancel(e); }
} catch (e) {
assertSharingOwner(ticket);
if (e.name === 'AbortError') throw e;
return isShareCancel(e);
}
}
if (!plugins.Filesystem) return false;
try {
var base64 = await imageSourceToBase64(src);
var name = 'clinical-visual-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.png';
var base64 = await imageSourceToBase64(src, ticket);
assertSharingOwner(ticket);
var name = sharingFilename('clinical-visual', 'png');
var saved = await plugins.Filesystem.writeFile({ path: name, data: base64, directory: 'CACHE' });
assertSharingOwner(ticket);
if (plugins.Share && saved && saved.uri) {
try {
assertSharingOwner(ticket);
await plugins.Share.share({ title: 'Clinical visual', text: 'Clinical Assistant generated visual', url: saved.uri, dialogTitle: 'Save or share clinical visual' });
assertSharingOwner(ticket);
} catch (e) {
assertSharingOwner(ticket);
if (e.name === 'AbortError') throw e;
if (!isShareCancel(e)) throw e;
}
}
if (typeof window.showToast === 'function') window.showToast('Image prepared', 'success');
return true;
} catch (e) {
assertSharingOwner(ticket);
if (e.name === 'AbortError') throw e;
if (typeof window.showToast === 'function') window.showToast('Could not save with the app. Use Preview and long-press the image to save it.', 'error');
return false;
}
}
async function shareWithWebFile(src) {
async function shareWithWebFile(src, ticket) {
assertSharingOwner(ticket);
if (!navigator.share || !navigator.canShare || typeof File === 'undefined') return 'unavailable';
try {
var blob = await imageSourceToBlob(src);
var blob = await imageSourceToBlob(src, ticket);
assertSharingOwner(ticket);
var file = new File([blob], 'clinical-visual.png', { type: blob.type || 'image/png' });
if (!navigator.canShare({ files: [file] })) return 'unavailable';
assertSharingOwner(ticket);
await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' });
assertSharingOwner(ticket);
return 'shared';
} catch (e) { return isShareCancel(e) ? 'unavailable' : 'unavailable'; }
} catch (e) {
assertSharingOwner(ticket);
if (e.name === 'AbortError') throw e;
return 'unavailable';
}
}
async function downloadWithBrowser(src) {
async function downloadWithBrowser(src, ticket) {
assertSharingOwner(ticket);
if (isMobileBrowser()) {
if (typeof window.showToast === 'function') window.showToast('Mobile browser download is not supported here. Use Preview and long-press the image to save it.', 'info');
return;
}
var blob = await imageSourceToBlob(src);
var blob = await imageSourceToBlob(src, ticket);
assertSharingOwner(ticket);
var objectUrl = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = objectUrl;
@ -167,19 +219,29 @@ async function downloadWithBrowser(src) {
setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000);
}
async function downloadFromServer(url) {
var response = await fetch(url, { headers: authHeadersForDownload(), credentials: 'same-origin' });
async function downloadFromServer(url, ticket) {
assertSharingOwner(ticket);
var response = await fetch(url, { headers: authHeadersForDownload(), credentials: 'same-origin', signal: ticket.signal });
assertSharingOwner(ticket);
if (!response.ok) throw new Error('Image download failed');
var blob = await response.blob();
var name = 'clinical-visual-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.png';
if (await saveBlobWithNativeImageBridge(blob, name)) return;
if (await saveBlobWithCapacitorShare(blob, name)) return;
if (await shareBlobWithWebFile(blob, name)) return;
assertSharingOwner(ticket);
var name = sharingFilename('clinical-visual', 'png');
var saved = await saveBlobWithNativeImageBridge(blob, name, ticket);
assertSharingOwner(ticket);
if (saved) return;
saved = await saveBlobWithCapacitorShare(blob, name, ticket);
assertSharingOwner(ticket);
if (saved) return;
saved = await shareBlobWithWebFile(blob, name, ticket);
assertSharingOwner(ticket);
if (saved) return;
if (isMobileBrowser()) {
if (typeof window.showToast === 'function') window.showToast('Could not start download here. Use Preview and long-press the image to save it.', 'error');
return;
}
downloadBlobWithAnchor(blob, name);
assertSharingOwner(ticket);
downloadBlobWithAnchor(blob, name, ticket);
}
function authHeadersForDownload() {
@ -191,44 +253,67 @@ function authHeadersForDownload() {
return clean;
}
async function saveBlobWithNativeImageBridge(blob, name) {
async function saveBlobWithNativeImageBridge(blob, name, ticket) {
assertSharingOwner(ticket);
if (!window.NativeFiles || typeof window.NativeFiles.saveImage !== 'function') return false;
try {
var base64 = await blobToBase64(blob);
var base64 = await blobToBase64(blob, ticket);
assertSharingOwner(ticket);
var result = String(window.NativeFiles.saveImage(name, base64) || '');
assertSharingOwner(ticket);
if (result.indexOf('saved:') === 0) {
if (typeof window.showToast === 'function') window.showToast('Image saved to Photos', 'success');
return true;
}
} catch (e) {}
} catch (e) {
assertSharingOwner(ticket);
if (e.name === 'AbortError') throw e;
}
return false;
}
async function saveBlobWithCapacitorShare(blob, name) {
async function saveBlobWithCapacitorShare(blob, name, ticket) {
assertSharingOwner(ticket);
var plugins = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins : null;
if (!plugins || !plugins.Filesystem) return false;
try {
var base64 = await blobToBase64(blob);
var base64 = await blobToBase64(blob, ticket);
assertSharingOwner(ticket);
var saved = await plugins.Filesystem.writeFile({ path: name, data: base64, directory: 'CACHE' });
assertSharingOwner(ticket);
if (plugins.Share && saved && saved.uri) {
assertSharingOwner(ticket);
await plugins.Share.share({ title: 'Clinical visual', text: 'Clinical Assistant generated visual', url: saved.uri, dialogTitle: 'Save or share clinical visual' });
assertSharingOwner(ticket);
}
if (typeof window.showToast === 'function') window.showToast('Image prepared', 'success');
return true;
} catch (e) { return false; }
} catch (e) {
assertSharingOwner(ticket);
if (e.name === 'AbortError') throw e;
return false;
}
}
async function shareBlobWithWebFile(blob, name) {
async function shareBlobWithWebFile(blob, name, ticket) {
assertSharingOwner(ticket);
if (!navigator.share || !navigator.canShare || typeof File === 'undefined') return false;
try {
var file = new File([blob], name, { type: blob.type || 'image/png' });
if (!navigator.canShare({ files: [file] })) return false;
assertSharingOwner(ticket);
await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' });
assertSharingOwner(ticket);
return true;
} catch (e) { return false; }
} catch (e) {
assertSharingOwner(ticket);
if (e.name === 'AbortError') throw e;
return false;
}
}
function downloadBlobWithAnchor(blob, name) {
function downloadBlobWithAnchor(blob, name, ticket) {
assertSharingOwner(ticket);
var objectUrl = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = objectUrl;
@ -253,22 +338,30 @@ function isShareCancel(error) {
return /cancel|abort|dismiss|user denied|share canceled/.test(message);
}
async function imageSourceToBase64(src) {
async function imageSourceToBase64(src, ticket) {
assertSharingOwner(ticket);
if (/^data:image\//i.test(src)) return src.split(',')[1] || '';
var blob = await imageSourceToBlob(src);
return blobToBase64(blob);
var blob = await imageSourceToBlob(src, ticket);
assertSharingOwner(ticket);
var base64 = await blobToBase64(blob, ticket);
assertSharingOwner(ticket);
return base64;
}
async function blobToBase64(blob) {
async function blobToBase64(blob, ticket) {
assertSharingOwner(ticket);
return new Promise(function(resolve, reject) {
var reader = new FileReader();
reader.onload = function() { resolve(String(reader.result || '').split(',')[1] || ''); };
reader.onload = function() {
try { assertSharingOwner(ticket); resolve(String(reader.result || '').split(',')[1] || ''); } catch (e) { reject(e); }
};
reader.onerror = function() { reject(reader.error || new Error('Could not read image')); };
reader.readAsDataURL(blob);
});
}
async function imageSourceToBlob(src) {
async function imageSourceToBlob(src, ticket) {
assertSharingOwner(ticket);
if (/^data:image\//i.test(src)) {
var parts = src.split(',');
var meta = parts[0] || 'data:image/png;base64';
@ -277,9 +370,12 @@ async function imageSourceToBlob(src) {
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new Blob([bytes], { type: (meta.match(/data:([^;]+)/) || [])[1] || 'image/png' });
}
var response = await fetch(src, { credentials: 'omit' });
var response = await fetch(src, { credentials: 'omit', signal: ticket.signal });
assertSharingOwner(ticket);
if (!response.ok) throw new Error('Image download failed');
return response.blob();
var blob = await response.blob();
assertSharingOwner(ticket);
return blob;
}
export function buildContextualImagePrompt(request, lastAnswer) {

View file

@ -0,0 +1,24 @@
// Keep the original document boundary as well as its ticket across fallbacks.
export function captureSharingOwner() {
var boundary = window.AccountBoundary;
var owner = boundary && typeof boundary.capture === 'function' && typeof boundary.signal === 'function'
? { boundary: boundary, ticket: boundary.capture(), signal: boundary.signal() } : null;
assertSharingOwner(owner);
return owner;
}
export function validSharingOwner(owner) {
return !!(owner && owner.ticket && owner.boundary === window.AccountBoundary &&
owner.signal && !owner.signal.aborted && typeof owner.boundary.valid === 'function' && owner.boundary.valid(owner.ticket));
}
export function assertSharingOwner(owner) {
if (!validSharingOwner(owner)) {
throw owner && typeof owner.boundary.error === 'function' ? owner.boundary.error() : new DOMException('Account unavailable', 'AbortError');
}
}
export function sharingFilename(prefix, extension) {
// An already-admitted OS write can finish after freeze; never reuse B's path.
return prefix + '-' + window.crypto.randomUUID() + '.' + extension;
}

View file

@ -13,8 +13,10 @@ const table = '| Item | Value (mg/kg) | Notes | Sources |\n| :--- | ---: | :---:
const collapse = text => text.replace(/\s+/g, ' ').trim();
function ui(t, parser = marked) {
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test' });
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
const window = dom.window;
window.eval(read('public/js/accountBoundary.js'));
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
const style = window.document.createElement('style');
style.textContent = read('public/css/assistant.css');
window.document.head.appendChild(style);
@ -29,7 +31,7 @@ function ui(t, parser = marked) {
saveAssistantChat: async body => { saves.push(JSON.parse(JSON.stringify(savedChatPayload(body)))); return { success: true }; }
};
vm.createContext(context);
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/export.js', 'clinicalAssistant.js']) {
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'assistant/export.js', 'clinicalAssistant.js']) {
vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
}
t.after(() => window.close());

View file

@ -0,0 +1,297 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const read = file => fs.readFileSync(path.join(__dirname, '..', 'public/js', file), 'utf8');
const tick = () => new Promise(resolve => setImmediate(resolve));
const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; };
const image = 'data:image/png;base64,c3ludGhldGlj';
const remote = 'https://example.test/synthetic.png';
function ui(t, { native = false, inline = true } = {}) {
const dom = new JSDOM('<body><div id="unrelated">unrelated feature</div></body>', { url: 'https://example.test', runScripts: 'outside-only' });
const w = dom.window;
// Real boundary code, synthetic storage/identity, no auth/network services.
w.eval(read('accountBoundary.js'));
assert.equal(w.AccountBoundary.enter({ id: 'synthetic-A' }, true), true);
const calls = { native: [], writes: [], shares: [], web: [], anchors: [], toasts: [], prints: [], fetches: [], urls: [] };
const timers = [];
w.matchMedia = () => ({ matches: inline });
w.showToast = (...args) => calls.toasts.push(args);
w.print = () => calls.prints.push('browser');
if (native) w.Capacitor = { isNativePlatform: () => true, Plugins: {} };
w.URL.createObjectURL = () => { calls.urls.push('create'); return 'blob:synthetic'; };
w.URL.revokeObjectURL = () => calls.urls.push('revoke');
w.HTMLAnchorElement.prototype.click = function() { calls.anchors.push(this.download); };
w.fetch = async (url, options) => { calls.fetches.push({ url, options }); return { ok: true, blob: async () => new w.Blob(['synthetic'], { type: 'image/png' }) }; };
w.setTimeout = fn => { timers.push(fn); return timers.length; };
w.clearTimeout = id => { timers[id - 1] = () => {}; };
for (const file of ['assistant/citations.js', 'assistant/sharing.js', 'assistant/images.js', 'assistant/export.js']) {
if (file.endsWith('sharing.js') && !fs.existsSync(path.join(__dirname, '..', 'public/js', file))) continue;
vm.runInContext(read(file).replace(/^import[^;]+;\s*/gm, '').replace(/^export /gm, ''), dom.getInternalVMContext());
}
const store = w.createAssistantImageStore();
const exporter = w.createAssistantExporter({ showToast: w.showToast });
function freezeAndReplace() {
const original = w.AccountBoundary;
original.freeze();
// A real document cannot re-enter. A replacement object models a fresh B
// becoming visible to stale callbacks; they must retain A's boundary.
const b = new JSDOM('<body></body>', { url: 'https://example.test', runScripts: 'outside-only' });
b.window.eval(read('accountBoundary.js'));
assert.equal(b.window.AccountBoundary.enter({ id: 'synthetic-B' }, true), true);
w.AccountBoundary = b.window.AccountBoundary;
t.after(() => b.window.close());
}
function download(src = image, url = '') {
const html = store.renderGeneratedImage(src, 'synthetic', url);
return store.downloadImage(html.match(/data-assistant-download-image="([^"]+)"/)[1]);
}
function exported(answer = 'Synthetic answer A') {
exporter.exportAnswerPdf({ lastAnswer: answer, lastGeneratedImageSrc: image });
return w.document.querySelector('#assistant-export-modal');
}
function plugins() {
return { Filesystem: { writeFile: async args => { calls.writes.push(args); return { uri: 'file://' + args.path }; } },
Share: { share: async args => { calls.shares.push(args); } } };
}
t.after(() => w.close());
return { w, calls, timers, store, exporter, freezeAndReplace, download, exported, plugins };
}
for (const stage of ['fetch', 'blob', 'reader']) {
for (const server of [false, true]) {
test(`image ${server ? 'server' : 'legacy'} ${stage} continuation rejects A -> freeze -> B`, async t => {
const app = ui(t, { native: true });
const pending = deferred();
app.w.Capacitor.Plugins = app.plugins();
app.w.NativeFiles = { saveImage: (...args) => { app.calls.native.push(args); return 'saved:ok'; } };
if (stage === 'fetch') app.w.fetch = () => pending.promise;
if (stage === 'blob') app.w.fetch = async () => ({ ok: true, blob: () => pending.promise });
if (stage === 'reader') app.w.FileReader = class { readAsDataURL() { pending.promise.then(() => { this.result = image; this.onload(); }); } };
const job = app.download(remote, server ? '/api/synthetic-download' : '');
await tick();
app.freezeAndReplace();
pending.resolve(stage === 'fetch' ? { ok: true, blob: async () => new app.w.Blob(['synthetic']) } : new app.w.Blob(['synthetic']));
await job;
assert.deepEqual(app.calls.native, []);
assert.deepEqual(app.calls.writes, []);
assert.deepEqual(app.calls.shares, []);
assert.deepEqual(app.calls.toasts, []);
});
}
}
test('NativeFiles failure cannot recapture B for Share-only fallback', async t => {
const app = ui(t, { native: true });
app.w.NativeFiles = { saveImage() { app.freezeAndReplace(); throw new Error('bridge unavailable'); } };
app.w.Capacitor.Plugins = { Share: app.plugins().Share };
await app.download(remote);
assert.deepEqual(app.calls.shares, []);
assert.deepEqual(app.calls.toasts, []);
});
for (const server of [false, true]) {
test(`late ${server ? 'server' : 'legacy'} Filesystem write cannot share/toast or overwrite B's filename`, async t => {
const app = ui(t, { native: true });
const pending = deferred();
app.w.Capacitor.Plugins = app.plugins();
const paths = [];
app.w.Capacitor.Plugins.Filesystem.writeFile = args => { paths.push(args.path); return paths.length === 1 ? pending.promise : Promise.resolve({ uri: 'file://' + args.path }); };
const job = app.download(image, server ? '/api/synthetic-download' : '');
for (let i = 0; i < 100 && !paths.length; i++) await tick();
assert.equal(paths.length, 1, 'first write was admitted');
app.freezeAndReplace();
await app.download(image, server ? '/api/synthetic-download' : '');
const admitted = app.calls.shares.length;
app.calls.toasts.length = 0;
pending.resolve({ uri: 'file://' + paths[0] });
await job;
assert.equal(app.calls.shares.length, admitted);
assert.deepEqual(app.calls.toasts, []);
assert.notEqual(paths[0], paths[1]);
});
}
for (const mode of ['web conversion', 'web reject', 'browser conversion', 'share-only reject']) {
test(`${mode} cannot fall through to effects/toasts under B`, async t => {
const app = ui(t, { native: mode === 'share-only reject' });
const pending = deferred();
if (mode.startsWith('web')) {
app.w.navigator.canShare = () => true;
app.w.navigator.share = args => { app.calls.web.push(args); return mode === 'web reject' ? pending.promise : Promise.resolve(); };
}
if (mode.endsWith('conversion')) app.w.fetch = async () => ({ ok: true, blob: () => pending.promise });
if (mode === 'share-only reject') app.w.Capacitor.Plugins.Share = { share: args => { app.calls.shares.push(args); return pending.promise; } };
const job = app.download(remote);
await tick();
app.freezeAndReplace();
if (mode.endsWith('reject')) pending.reject(new Error('capability rejected'));
else pending.resolve(new app.w.Blob(['synthetic']));
await job;
assert.deepEqual(app.calls.anchors, []);
assert.deepEqual(app.calls.urls, []);
assert.deepEqual(app.calls.toasts, []);
assert.equal(app.calls.web.length, mode === 'web reject' ? 1 : 0);
assert.equal(app.calls.shares.length, mode === 'share-only reject' ? 1 : 0);
});
}
test('an active-owner policy AbortError is not a capability fallback', async t => {
const app = ui(t, { native: true });
app.w.NativeFiles = { saveImage() { throw new app.w.DOMException('Synthetic policy abort', 'AbortError'); } };
app.w.Capacitor.Plugins = app.plugins();
await app.download(image);
assert.deepEqual(app.calls.writes, []);
assert.deepEqual(app.calls.toasts, []);
});
test('download and export fail closed when boundary is absent, incomplete or inactive', async t => {
for (const boundary of [undefined, {}, { capture: () => 'A' }]) {
const app = ui(t, { native: true });
app.w.AccountBoundary = boundary;
app.w.Capacitor.Plugins = app.plugins();
await app.download();
assert.equal(app.exported(), null);
assert.deepEqual(app.calls.writes, []);
assert.deepEqual(app.calls.toasts, []);
}
const app = ui(t, { native: true });
app.w.AccountBoundary.freeze();
await app.download();
assert.equal(app.exported(), null);
});
for (const mode of ['native', 'filesystem', 'web', 'browser', 'share-only', 'native fallback', 'server native', 'server filesystem', 'server web', 'server browser']) {
test(`same-owner image succeeds: ${mode}`, async t => {
const app = ui(t, { native: /native|filesystem|share-only/.test(mode) });
if (/native/.test(mode)) app.w.NativeFiles = { saveImage: (...args) => { app.calls.native.push(args); return mode === 'native fallback' ? 'error:unavailable' : 'saved:ok'; } };
if (/filesystem|fallback/.test(mode)) app.w.Capacitor.Plugins = app.plugins();
if (mode === 'share-only') app.w.Capacitor.Plugins = { Share: app.plugins().Share };
if (/web/.test(mode)) { app.w.navigator.canShare = () => true; app.w.navigator.share = async args => { app.calls.web.push(args); }; }
await app.download(mode === 'share-only' ? remote : image, mode.startsWith('server') ? '/api/synthetic-download' : '');
assert.equal(app.calls.native.length + app.calls.shares.length + app.calls.web.length + app.calls.anchors.length, mode === 'native fallback' ? 2 : 1);
if (mode.startsWith('server')) {
assert.equal(app.calls.fetches[0].options.credentials, 'same-origin');
assert.equal(app.calls.fetches[0].options.signal, app.w.AccountBoundary.signal());
}
});
}
test('inline Print retains original export owner and closes only its preview on freeze', async t => {
const app = ui(t, { native: true });
app.w.NativePrint = { printHtml: (...args) => app.calls.prints.push(args) };
app.w.Capacitor.Plugins = app.plugins();
const modal = app.exported();
const print = modal.querySelector('#assistant-export-print');
app.freezeAndReplace();
assert.equal(modal.isConnected, false);
const newer = app.exported('Synthetic answer B');
print.click();
await tick();
assert.deepEqual(app.calls.prints, []);
assert.deepEqual(app.calls.writes, []);
assert.deepEqual(app.calls.toasts, []);
assert.equal(newer.isConnected, true);
assert.ok(app.w.document.querySelector('#unrelated'));
});
test('late inline Filesystem completion has no Share, print or toast and uses a distinct filename', async t => {
const app = ui(t, { native: true });
const pending = deferred();
app.w.Capacitor.Plugins = app.plugins();
app.w.Capacitor.Plugins.Filesystem.writeFile = args => { app.calls.writes.push(args); return app.calls.writes.length === 1 ? pending.promise : Promise.resolve({ uri: 'file://' + args.path }); };
const old = app.exported();
old.querySelector('#assistant-export-print').click();
await tick();
app.freezeAndReplace();
assert.equal(old.isConnected, false);
app.exported('Synthetic answer B').querySelector('#assistant-export-print').click();
await tick();
const shares = app.calls.shares.length;
app.calls.toasts.length = 0;
pending.resolve({ uri: 'file://' + app.calls.writes[0].path });
await tick();
assert.equal(app.calls.shares.length, shares);
assert.deepEqual(app.calls.prints, []);
assert.deepEqual(app.calls.toasts, []);
assert.notEqual(app.calls.writes[0].path, app.calls.writes[1].path);
});
for (const stage of ['native rejection', 'share rejection', 'native return', 'browser fallback']) {
test(`inline ${stage} checks original owner before fallbacks`, async t => {
const app = ui(t, { native: true });
app.w.Capacitor.Plugins = app.plugins();
if (stage.startsWith('native')) app.w.NativePrint = { printHtml() { app.freezeAndReplace(); if (stage.endsWith('rejection')) throw new Error('unavailable'); } };
if (stage === 'share rejection') app.w.Capacitor.Plugins.Share.share = async () => { app.freezeAndReplace(); throw new Error('unavailable'); };
if (stage === 'browser fallback') {
app.w.Capacitor.Plugins = {};
// The click handler yields on its first helper even if there is no bridge.
}
const modal = app.exported();
modal.querySelector('#assistant-export-print').click();
if (stage === 'browser fallback') app.freezeAndReplace();
await tick();
assert.deepEqual(app.calls.prints, []);
assert.deepEqual(app.calls.toasts, []);
assert.equal(app.calls.writes.length, stage === 'share rejection' ? 1 : 0);
assert.equal(modal.isConnected, false);
});
}
for (const mode of ['native', 'filesystem', 'browser']) {
test(`same-owner inline export succeeds: ${mode}`, async t => {
const app = ui(t, { native: true });
if (mode === 'native') app.w.NativePrint = { printHtml: (...args) => app.calls.prints.push(args) };
if (mode === 'filesystem') app.w.Capacitor.Plugins = app.plugins();
app.exported().querySelector('#assistant-export-print').click();
await tick();
assert.equal(app.calls.prints.length + app.calls.shares.length, 1);
const encoded = mode === 'native' ? app.calls.prints[0][1] : app.calls.writes[0]?.data;
if (encoded) {
const html = Buffer.from(encoded, 'base64').toString();
assert.match(html, /Synthetic answer A/);
assert.ok(html.includes(image));
}
});
}
for (const frozen of [false, true]) {
test(`desktop export ${frozen ? 'closes on freeze without delayed/click print' : 'prints on timer and click'}`, t => {
const app = ui(t, { inline: false });
const popup = new JSDOM('<body></body>', { url: 'https://example.test' });
const p = popup.window;
let closed = 0;
p.focus = () => {};
p.print = () => app.calls.prints.push('popup');
const dispose = p.close.bind(p);
t.after(dispose);
p.close = () => { closed++; }; // retain detached callbacks to exercise them
app.w.open = () => p;
app.exported();
const print = p.document.querySelector('#assistant-export-print');
if (frozen) app.freezeAndReplace();
for (const timer of app.timers) timer();
print.click();
assert.equal(app.calls.prints.length, frozen ? 0 : 2);
assert.equal(closed > 0, frozen);
assert.ok(app.w.document.querySelector('#unrelated'));
});
}
test('image preview closes only its owned modal on boundary closure', t => {
const app = ui(t);
const unrelated = app.w.document.createElement('div');
unrelated.className = 'assistant-image-modal';
app.w.document.body.append(unrelated);
app.store.renderGeneratedImage(image);
app.store.openImagePreview('img-1');
assert.equal(unrelated.isConnected, true);
const owned = app.w.document.querySelector('.assistant-image-modal[role="dialog"]');
assert.ok(owned);
app.freezeAndReplace();
assert.equal(owned.isConnected, false);
assert.equal(unrelated.isConnected, true);
});

View file

@ -37,6 +37,8 @@ test('actual markup loads the existing local DOMPurify distribution through its
test('native admin and assistant modules retain budget, table/source identity and safe live/saved/export rendering with or without DOMPurify', async t => {
const dom = new JSDOM('<div id="admin-tab">' + read('public/components/admin.html') + '</div><div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://app.example', runScripts: 'outside-only' });
const { window } = dom;
window.eval(read('public/js/accountBoundary.js'));
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
const document = window.document;
const calls = [];
const limit = 2000;

View file

@ -18,8 +18,12 @@ let moduleId = 0;
async function browser(t, module, handler) {
const component = module.startsWith('admin') ? 'admin' : 'assistant';
const dom = new JSDOM('<div id="' + component + '-tab">' + read('public/components/' + component + '.html') + '</div>', { url: 'https://synthetic.invalid' });
const dom = new JSDOM('<div id="' + component + '-tab">' + read('public/components/' + component + '.html') + '</div>', { url: 'https://synthetic.invalid', runScripts: 'outside-only' });
const { window } = dom;
if (component === 'assistant') {
window.eval(read('public/js/accountBoundary.js'));
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
}
const calls = []; const toasts = [];
const fetch = async (url, options = {}) => {
calls.push({ url, options, body: options.body && JSON.parse(options.body) });