fix: bind clinical image and export actions to their original account
This commit is contained in:
parent
dc83f6e21b
commit
40e7dd5206
7 changed files with 556 additions and 82 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
24
public/js/assistant/sharing.js
Normal file
24
public/js/assistant/sharing.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
297
test/assistant-sharing-boundary.test.js
Normal file
297
test/assistant-sharing-boundary.test.js
Normal 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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
Loading…
Reference in a new issue