diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..e21e6386b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,13 @@ +# Claude Code Instructions + +## Git Commits +- Use Conventional Commits format: `type(scope): description` (e.g. `fix: ...`, `feat: ...`, `chore: ...`). +- Keep commit subject lines concise; use the body for detail. +- Never include `Co-Authored-By` lines in commit messages. + +## Code Style +- 4-space indentation, never tabs. +- Always use semicolons. +- Brace style: (`if (x) {`), single-line blocks allowed. +- Always use curly braces for `if`/`else`/`for`/`while`. +- No trailing whitespace. diff --git a/src/index.html b/src/index.html index 11b78711d..5c7e665e0 100644 --- a/src/index.html +++ b/src/index.html @@ -27,7 +27,7 @@ 1) { + throw new Error("Selector must match exactly one element, but matched " + + $el.length + ": " + rectOrNodeOrSelector); + } + element = $el[0]; + } else if (rectOrNodeOrSelector instanceof HTMLElement) { + // Case 2: DOM node (Element instance) + element = rectOrNodeOrSelector; + } else if (typeof rectOrNodeOrSelector === 'object') { + // Case 3: Plain rect object {x, y, width, height} + return rectOrNodeOrSelector; // pass through for validation in _capturePageBinary + } else { + throw new Error("Expected a rect object, DOM node, or jQuery selector string"); + } + // Convert DOM element to rect via getBoundingClientRect(). + // getBoundingClientRect() returns values in the zoomed CSS coordinate space, but + // the native capture APIs (Electron capturePage, Tauri capture_page) expect + // coordinates in the unzoomed viewport space. Divide by the webview zoom factor + // to convert. + const zoomFactor = (window.PhStore && window.PhStore.getItem("desktopZoomScale")) || 1; + const domRect = element.getBoundingClientRect(); + return { + x: Math.round(domRect.x * zoomFactor), + y: Math.round(domRect.y * zoomFactor), + width: Math.round(domRect.width * zoomFactor), + height: Math.round(domRect.height * zoomFactor) + }; +} + +async function _capturePageBinary(rectOrNodeOrSelector) { + if (!Phoenix.isNativeApp) { + throw new Error("Screenshot capture is not supported in browsers"); + } + const rect = _resolveRect(rectOrNodeOrSelector); + if (rect !== undefined) { + if (rect.x === undefined || rect.y === undefined || + rect.width === undefined || rect.height === undefined) { + throw new Error("rect must include all fields: x, y, width, height"); + } + if (typeof rect.x !== 'number' || typeof rect.y !== 'number' || + typeof rect.width !== 'number' || typeof rect.height !== 'number') { + throw new Error("rect fields x, y, width, height must be numbers"); + } + if (rect.x < 0 || rect.y < 0 || rect.width < 0 || rect.height < 0) { + throw new Error("rect fields x, y, width, height must be non-negative"); + } + if (rect.width <= 0 || rect.height <= 0) { + throw new Error("rect width and height must be greater than 0"); + } + const zoomFactor = (window.PhStore && window.PhStore.getItem("desktopZoomScale")) || 1; + const maxWidth = Math.ceil(window.innerWidth * zoomFactor); + const maxHeight = Math.ceil(window.innerHeight * zoomFactor); + if (rect.x + rect.width > maxWidth) { + throw new Error("rect x + width exceeds window innerWidth"); + } + if (rect.y + rect.height > maxHeight) { + throw new Error("rect y + height exceeds window innerHeight"); + } + } + if (window.__TAURI__) { + const bytes = await window.__TAURI__.invoke('capture_page', { rect }); + return new Uint8Array(bytes); + } + if (window.__ELECTRON__) { + return window.electronAPI.capturePage(rect); + } +} + Phoenix.app = { getNodeState: function (cbfn){ cbfn(new Error('Node cannot be run in phoenix browser mode')); @@ -794,6 +874,83 @@ Phoenix.app = { return window.electronAPI.onWindowEvent(eventName, callback); } return () => {}; // No-op for unsupported platforms + }, + /** + * Captures a screenshot and returns the raw PNG bytes. + * @param {Object|HTMLElement|string} [rectOrNodeOrSelector] - Area to capture. Can be: + * - A rect object `{x, y, width, height}` specifying pixel coordinates + * - A DOM element whose bounding rect will be captured + * - A jQuery selector string (must match exactly one element) + * - Omit to capture the full page + * @returns {Promise} PNG image data + * @example Capture a specific rectangle + * const bytes = await Phoenix.app.screenShotBinary({ + * x: 100, y: 100, width: 400, height: 300 + * }); + * @example Capture a DOM element + * const element = document.getElementById("preview"); + * const bytes = await Phoenix.app.screenShotBinary(element); + * @example Capture using a selector + * const bytes = await Phoenix.app.screenShotBinary("#preview"); + * @example Capture the full page + * const bytes = await Phoenix.app.screenShotBinary(); + */ + screenShotBinary: function (rectOrNodeOrSelector) { + return _capturePageBinary(rectOrNodeOrSelector); + }, + /** + * Captures a screenshot and returns it as a PNG Blob. + * @param {Object|HTMLElement|string} [rectOrNodeOrSelector] - Area to capture. Can be: + * - A rect object `{x, y, width, height}` specifying pixel coordinates + * - A DOM element whose bounding rect will be captured + * - A jQuery selector string (must match exactly one element) + * - Omit to capture the full page + * @returns {Promise} PNG Blob with type "image/png" + * @example Display in an image element + * const blob = await Phoenix.app.screenShotToBlob("#preview"); + * const url = URL.createObjectURL(blob); + * document.getElementById("imgOutput").src = url; + * @example Draw to a canvas + * const blob = await Phoenix.app.screenShotToBlob(); + * const bitmap = await createImageBitmap(blob); + * ctx.drawImage(bitmap, 0, 0); + */ + screenShotToBlob: async function (rectOrNodeOrSelector) { + const bytes = await _capturePageBinary(rectOrNodeOrSelector); + return new Blob([bytes], { type: "image/png" }); + }, + /** + * Captures a screenshot and writes it to a PNG file. + * @param {string} filePathToSave - VFS path to save the PNG file to + * @param {Object|HTMLElement|string} [rectOrNodeOrSelector] - Area to capture. Can be: + * - A rect object `{x, y, width, height}` specifying pixel coordinates + * - A DOM element whose bounding rect will be captured + * - A jQuery selector string (must match exactly one element) + * - Omit to capture the full page + * @returns {Promise} + * @throws {Error} If filePathToSave is not a non-empty string + * @example Save the full page + * await Phoenix.app.screenShotToPNGFile("/project/output/screenshot.png"); + * @example Save a specific element + * await Phoenix.app.screenShotToPNGFile( + * "/project/output/preview.png", + * "#preview" + * ); + */ + screenShotToPNGFile: async function (filePathToSave, rectOrNodeOrSelector) { + if (!filePathToSave || typeof filePathToSave !== 'string') { + throw new Error("filePathToSave must be a non-empty string"); + } + const bytes = await _capturePageBinary(rectOrNodeOrSelector); + return new Promise((resolve, reject) => { + fs.writeFile(filePathToSave, bytes.buffer, 'binary', (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); } }; diff --git a/test/spec/Native-platform-test.js b/test/spec/Native-platform-test.js index 0e4ca767c..40c12c5ef 100644 --- a/test/spec/Native-platform-test.js +++ b/test/spec/Native-platform-test.js @@ -390,6 +390,263 @@ define(function (require, exports, module) { }); }); + describe("Screenshot Capture API Tests", function () { + const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]; // PNG magic bytes + + function isPNG(bytes) { + if (bytes.length < 8) { + return false; + } + for (let i = 0; i < PNG_SIGNATURE.length; i++) { + if (bytes[i] !== PNG_SIGNATURE[i]) { + return false; + } + } + return true; + } + + describe("screenShotBinary", function () { + it("Should return a Uint8Array of PNG data for full page capture", async function () { + const bytes = await Phoenix.app.screenShotBinary(); + expect(bytes instanceof Uint8Array).toBeTrue(); + expect(bytes.length).toBeGreaterThan(0); + expect(isPNG(bytes)).withContext("Result should be valid PNG data").toBeTrue(); + }); + + it("Should return a Uint8Array of PNG data for bounded capture", async function () { + const bytes = await Phoenix.app.screenShotBinary({x: 0, y: 0, width: 100, height: 100}); + expect(bytes instanceof Uint8Array).toBeTrue(); + expect(bytes.length).toBeGreaterThan(0); + expect(isPNG(bytes)).withContext("Result should be valid PNG data").toBeTrue(); + }); + + it("Should throw when rect is missing required fields", async function () { + await expectAsync( + Phoenix.app.screenShotBinary({x: 0, y: 0}) + ).toBeRejectedWithError("rect must include all fields: x, y, width, height"); + }); + + it("Should throw when rect fields are not numbers", async function () { + await expectAsync( + Phoenix.app.screenShotBinary({x: "0", y: 0, width: 100, height: 100}) + ).toBeRejectedWithError("rect fields x, y, width, height must be numbers"); + }); + + it("Should throw when rect fields are negative", async function () { + await expectAsync( + Phoenix.app.screenShotBinary({x: -1, y: 0, width: 100, height: 100}) + ).toBeRejectedWithError("rect fields x, y, width, height must be non-negative"); + }); + + it("Should throw when rect width is 0", async function () { + await expectAsync( + Phoenix.app.screenShotBinary({x: 0, y: 0, width: 0, height: 100}) + ).toBeRejectedWithError("rect width and height must be greater than 0"); + }); + + it("Should throw when rect height is 0", async function () { + await expectAsync( + Phoenix.app.screenShotBinary({x: 0, y: 0, width: 100, height: 0}) + ).toBeRejectedWithError("rect width and height must be greater than 0"); + }); + + it("Should throw when rect exceeds window width bounds", async function () { + await expectAsync( + Phoenix.app.screenShotBinary({x: 0, y: 0, width: 999999, height: 100}) + ).toBeRejectedWithError("rect x + width exceeds window innerWidth"); + }); + + it("Should throw when rect exceeds window height bounds", async function () { + await expectAsync( + Phoenix.app.screenShotBinary({x: 0, y: 0, width: 100, height: 999999}) + ).toBeRejectedWithError("rect y + height exceeds window innerHeight"); + }); + + it("Should capture a screenshot of a DOM element", async function () { + const el = document.createElement("div"); + el.id = "screenshot-test-element"; + el.style.cssText = "position:fixed;top:0;left:0;width:100px;height:100px;background:red;"; + document.body.appendChild(el); + try { + const bytes = await Phoenix.app.screenShotBinary(el); + expect(bytes instanceof Uint8Array).toBeTrue(); + expect(bytes.length).toBeGreaterThan(0); + expect(isPNG(bytes)).withContext("Result should be valid PNG data").toBeTrue(); + } finally { + el.remove(); + } + }); + + it("Should capture a screenshot using a jQuery selector string", async function () { + const el = document.createElement("div"); + el.id = "screenshot-test-element"; + el.style.cssText = "position:fixed;top:0;left:0;width:100px;height:100px;background:red;"; + document.body.appendChild(el); + try { + const bytes = await Phoenix.app.screenShotBinary("#screenshot-test-element"); + expect(bytes instanceof Uint8Array).toBeTrue(); + expect(bytes.length).toBeGreaterThan(0); + expect(isPNG(bytes)).withContext("Result should be valid PNG data").toBeTrue(); + } finally { + el.remove(); + } + }); + + it("Should throw when jQuery selector matches no elements", async function () { + await expectAsync( + Phoenix.app.screenShotBinary("#nonexistent-element-xyz") + ).toBeRejectedWithError("No element found for selector: #nonexistent-element-xyz"); + }); + + it("Should throw when jQuery selector matches multiple elements", async function () { + await expectAsync( + Phoenix.app.screenShotBinary("div") + ).toBeRejectedWithError(/Selector must match exactly one element, but matched \d+: div/); + }); + + it("Should throw for invalid argument type", async function () { + await expectAsync( + Phoenix.app.screenShotBinary(42) + ).toBeRejectedWithError("Expected a rect object, DOM node, or jQuery selector string"); + }); + }); + + describe("screenShotToBlob", function () { + it("Should return a Blob of type image/png for full page capture", async function () { + const blob = await Phoenix.app.screenShotToBlob(); + expect(blob instanceof Blob).toBeTrue(); + expect(blob.type).toEqual("image/png"); + expect(blob.size).toBeGreaterThan(0); + }); + + it("Should return a Blob of type image/png for bounded capture", async function () { + const blob = await Phoenix.app.screenShotToBlob({x: 0, y: 0, width: 100, height: 100}); + expect(blob instanceof Blob).toBeTrue(); + expect(blob.type).toEqual("image/png"); + expect(blob.size).toBeGreaterThan(0); + }); + + it("Should return a Blob when given a DOM element", async function () { + const el = document.createElement("div"); + el.id = "screenshot-blob-test-element"; + el.style.cssText = "position:fixed;top:0;left:0;width:100px;height:100px;background:red;"; + document.body.appendChild(el); + try { + const blob = await Phoenix.app.screenShotToBlob(el); + expect(blob instanceof Blob).toBeTrue(); + expect(blob.type).toEqual("image/png"); + expect(blob.size).toBeGreaterThan(0); + } finally { + el.remove(); + } + }); + + it("Should return a Blob when given a jQuery selector", async function () { + const el = document.createElement("div"); + el.id = "screenshot-blob-test-element"; + el.style.cssText = "position:fixed;top:0;left:0;width:100px;height:100px;background:red;"; + document.body.appendChild(el); + try { + const blob = await Phoenix.app.screenShotToBlob("#screenshot-blob-test-element"); + expect(blob instanceof Blob).toBeTrue(); + expect(blob.type).toEqual("image/png"); + expect(blob.size).toBeGreaterThan(0); + } finally { + el.remove(); + } + }); + }); + + describe("screenShotToPNGFile", function () { + let testDir; + let testFilePath; + const testFileName = "screenshot-test-output.png"; + + beforeEach(async function () { + const appLocalData = fs.getTauriVirtualPath(await platform.appLocalDataDir()); + testDir = appLocalData; + testFilePath = `${testDir}/${testFileName}`; + }); + + afterEach(async function () { + // Always clean up the test file, even if the test failed + await SpecRunnerUtils.deletePathAsync(testFilePath).catch(() => {}); + }); + + it("Should write a valid PNG file", async function () { + await Phoenix.app.screenShotToPNGFile(testFilePath); + // Read back and verify PNG signature + const content = await new Promise((resolve, reject) => { + fs.readFile(testFilePath, 'binary', (err, data) => { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + }); + const bytes = new Uint8Array(content); + expect(isPNG(bytes)).withContext("Written file should be valid PNG").toBeTrue(); + }); + + it("Should write a valid PNG file with bounded rect", async function () { + await Phoenix.app.screenShotToPNGFile(testFilePath, {x: 0, y: 0, width: 100, height: 100}); + const content = await new Promise((resolve, reject) => { + fs.readFile(testFilePath, 'binary', (err, data) => { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + }); + const bytes = new Uint8Array(content); + expect(isPNG(bytes)).withContext("Written file should be valid PNG").toBeTrue(); + }); + + it("Should write a valid PNG file when given a jQuery selector", async function () { + const el = document.createElement("div"); + el.id = "screenshot-file-test-element"; + el.style.cssText = "position:fixed;top:0;left:0;width:100px;height:100px;background:red;"; + document.body.appendChild(el); + try { + await Phoenix.app.screenShotToPNGFile(testFilePath, "#screenshot-file-test-element"); + const content = await new Promise((resolve, reject) => { + fs.readFile(testFilePath, 'binary', (err, data) => { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + }); + const bytes = new Uint8Array(content); + expect(isPNG(bytes)).withContext("Written file should be valid PNG").toBeTrue(); + } finally { + el.remove(); + } + }); + + it("Should throw when filePathToSave is not provided", async function () { + await expectAsync( + Phoenix.app.screenShotToPNGFile() + ).toBeRejectedWithError("filePathToSave must be a non-empty string"); + }); + + it("Should throw when filePathToSave is not a string", async function () { + await expectAsync( + Phoenix.app.screenShotToPNGFile(123) + ).toBeRejectedWithError("filePathToSave must be a non-empty string"); + }); + + it("Should throw when filePathToSave is an empty string", async function () { + await expectAsync( + Phoenix.app.screenShotToPNGFile("") + ).toBeRejectedWithError("filePathToSave must be a non-empty string"); + }); + }); + }); + describe("Credentials OTP API Tests", function () { const scopeName = "testScope"; const trustRing = window.specRunnerTestKernalModeTrust;