diff --git a/Cargo.toml b/Cargo.toml index c98b14966..72439e79c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,6 +119,9 @@ web-sys = { version = "=0.3.85", features = [ "console", "Node", "HtmlElement", + "HtmlCanvasElement", + "CanvasRenderingContext2d", + "ImageData", ] } iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["webgl", "fira-sans"] } diff --git a/docs/automation/web_save_smoke.cjs b/docs/automation/web_save_smoke.cjs new file mode 100644 index 000000000..71cab37a7 --- /dev/null +++ b/docs/automation/web_save_smoke.cjs @@ -0,0 +1,131 @@ +// Serve a Trunk build, then run: node docs/automation/web_save_smoke.cjs URL [chromium|firefox] +// Requires Playwright and its browser binaries. Uses a fresh, disposable browser profile. +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const playwright = require('playwright'); + +(async () => { + const [url = 'http://127.0.0.1:8080', engine = 'chromium'] = process.argv.slice(2); + assert(['chromium', 'firefox'].includes(engine)); + const output = await fs.mkdtemp(path.join(os.tmpdir(), 'ocs-web-save-')); + const browser = await playwright[engine].launch({ + headless: true, + ...(engine === 'chromium' + ? {args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader']} + : {firefoxUserPrefs: {'webgl.force-enabled': true}}), + }); + try { + for (const format of ['DWG 2018', 'DXF 2018']) { + const context = await browser.newContext({ + viewport: {width: 1280, height: 900}, deviceScaleFactor: 1, + }); + const page = await context.newPage(); + page.setDefaultTimeout(30000); + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + page.on('console', message => { + if (/panicked|Buffer is not mapped/.test(message.text())) errors.push(message.text()); + }); + await page.addInitScript(format => localStorage.setItem('opencadstudio.settings', + JSON.stringify({settings: {language: 'en-US', default_save_format: format, savetime_min: 0}})), format); + await page.goto(url); + await page.waitForFunction(() => window.wasmBindings?.ocs_control_submit, undefined, {timeout: 120000}); + const raw = request => page.evaluate(async request => { + const api = window.wasmBindings; + const ticket = api.ocs_control_submit(JSON.stringify(request)); + for (let i = 0; i < 600; i++) { + const reply = api.ocs_control_take(ticket); + if (reply) return JSON.parse(reply); + await new Promise(resolve => setTimeout(resolve, 50)); + } + throw new Error('Control timed out: ' + JSON.stringify(request)); + }, request); + let serial = 0; + async function act(request) { + const state = await raw({op: 'state'}); + const id = 'save-smoke-' + ++serial; + let reply = await raw({...request, request_id: id, + document_id: state.document_id, revision: state.revision}); + for (let i = 0; ['accepted', 'running'].includes(reply.status) && i < 600; i++) { + await page.waitForTimeout(50); + reply = await raw({op: 'operation', request_id: id}); + } + assert(reply.ok, JSON.stringify(reply)); + return reply; + } + async function save(command, label) { + const downloaded = page.waitForEvent('download', {timeout: 30000}); + await act({op: 'run', cmd: command}); + if (command === 'SAVEAS') { + assert.equal((await raw({op: 'state'})).modal, 'SaveDialog'); + await page.screenshot({path: path.join(output, `${format}-dialog.png`)}); + // Save As button in the fixed 1280x900, English canvas layout. + await page.mouse.click(585, 521); + } + const download = await downloaded; + const destination = path.join(output, `${label}-${download.suggestedFilename()}`); + await download.saveAs(destination); + const bytes = await fs.readFile(destination); + assert(bytes.length > 1000, 'Drawing download is empty'); + assert.deepEqual(errors, []); + return {name: download.suggestedFilename(), bytes}; + } + let state = await raw({op: 'state'}); + if (state.modal) await act({op: 'action', name: 'close_modal'}); + await act({op: 'new'}); + await act({op: 'run', cmd: 'LINE 0,0 10,0'}); + await act({op: 'action', name: 'zoom_extents'}); + await page.screenshot({path: path.join(output, `${format}-drawing.png`)}); + const first = await save('QSAVE', 'quick'); + if (format.startsWith('DWG')) { + const offset = first.bytes.indexOf(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + assert(offset >= 0, 'DWG preview PNG is missing'); + const preview = await page.evaluate(async bytes => { + const image = await createImageBitmap(new Blob([new Uint8Array(bytes)], {type: 'image/png'})); + const canvas = document.createElement('canvas'); + canvas.width = image.width; canvas.height = image.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(image, 0, 0); + const rgba = ctx.getImageData(0, 0, image.width, image.height).data; + const colors = new Set(); + for (let i = 0; i < rgba.length; i += 4) colors.add(rgba.slice(i, i + 4).join(',')); + image.close(); + return {width: canvas.width, height: canvas.height, colors: colors.size}; + }, [...first.bytes.subarray(offset)]); + assert.equal(preview.width, 256); + const [width, height] = (await raw({op: 'state'})).viewport_size; + assert(Math.abs(preview.height - Math.round(256 * height / width)) <= 1); + assert(preview.colors > 1, 'Preview lost the drawing'); + } + await save('SAVE', 'save'); + const savedAs = await save('SAVEAS', 'save-as'); + // A denied canvas read must still allow the drawing to be saved. + const canvasRead = await page.evaluateHandle(() => CanvasRenderingContext2D.prototype.getImageData); + await page.evaluate(() => { + CanvasRenderingContext2D.prototype.getImageData = () => { + throw new DOMException('Read denied for test', 'SecurityError'); + }; + }); + await save('QSAVE', 'capture-denied'); + await canvasRead.evaluate(original => CanvasRenderingContext2D.prototype.getImageData = original); + await canvasRead.dispose(); + const oldId = (await raw({op: 'state'})).document_id; + await act({op: 'action', name: 'close_document'}); + // Reparse the saved browser-private copy, as a Recent Documents entry does. + await act({op: 'open', path: savedAs.name}); + state = await raw({op: 'state'}); + const entities = await raw({op: 'entities'}); + assert.notEqual(state.document_id, oldId, 'File did not reopen'); + assert.equal(entities.total, 1, JSON.stringify(entities)); + assert.deepEqual(errors, []); + await page.screenshot({path: path.join(output, `${format}-reopened.png`)}); + console.log(`${engine}: ${format} QSAVE, SAVE, SAVEAS, denied capture, reopen passed`); + await context.close(); + } + console.log('Artifacts:', output); + } finally { + await browser.close(); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/src/app/update/file.rs b/src/app/update/file.rs index b16da004c..567258435 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1704,19 +1704,14 @@ pub(super) fn on_open_file(&mut self) -> Task { let Some(pending) = self.pending_web_thumbnail_save.take() else { return Task::none(); }; - iced::window::latest() - .then(|window| match window { - Some(window) => iced::window::screenshot(window).map(Some), - None => Task::done(None), - }) - .map(move |screenshot| Message::WebSaveScreenshot { - tab_id: pending.tab_id, - filename: pending.filename.clone(), - ext: pending.ext.clone(), - version: pending.version, - bounds: Some(pending.bounds), - screenshot, - }) + Task::done(Message::WebSaveScreenshot { + tab_id: pending.tab_id, + filename: pending.filename, + ext: pending.ext, + version: pending.version, + bounds: Some(pending.bounds), + screenshot: crate::sys::capture_canvas(), + }) } #[cfg(not(target_arch = "wasm32"))] diff --git a/src/sys.rs b/src/sys.rs index c17e21b90..06c14859d 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -170,6 +170,48 @@ pub fn reveal_in_file_manager(path: &std::path::Path) -> Result<(), String> { } } +/// Copy the rendered web canvas during the frame callback, before the browser +/// clears its drawing buffer. Canvas readback avoids Iced's synchronous GPU map. +#[cfg(target_arch = "wasm32")] +pub fn capture_canvas() -> Option { + use wasm_bindgen::JsCast; + + let window = web_sys::window()?; + let document = window.document()?; + let source = document + .query_selector("canvas") + .ok()?? + .dyn_into::() + .ok()?; + let (width, height) = (source.width(), source.height()); + if width == 0 || height == 0 { + return None; + } + let copy = document + .create_element("canvas") + .ok()? + .dyn_into::() + .ok()?; + copy.set_width(width); + copy.set_height(height); + let context = copy + .get_context("2d") + .ok()?? + .dyn_into::() + .ok()?; + context + .draw_image_with_html_canvas_element(&source, 0.0, 0.0) + .ok()?; + let pixels = context + .get_image_data(0.0, 0.0, width as f64, height as f64) + .ok()?; + Some(iced::window::Screenshot::new( + pixels.data().0, + iced::Size::new(width, height), + window.device_pixel_ratio() as f32, + )) +} + /// Web: read text from the system clipboard via the async Clipboard API. /// iced's own `clipboard::read` is a no-op on the web (the browser clipboard is /// async + permission-gated), so the editor paste paths use this instead. The