From 6732bfff7e761078f8633952c3c3c4f990eab4e3 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 12 Feb 2026 11:41:32 +0530 Subject: [PATCH 1/5] chore: claude config --- CLAUDE.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 CLAUDE.md 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. From 57f3da1fa7c9e42ffacf236731153ddf3e6d16bf Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 13 Feb 2026 11:47:24 +0530 Subject: [PATCH 2/5] feat: screenshot api and tests --- src/index.html | 2 +- src/phoenix/shell.js | 57 +++++++++++ test/spec/Native-platform-test.js | 156 ++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) 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 @@ window.innerWidth) { + throw new Error("rect x + width exceeds window innerWidth"); + } + if (rect.y + rect.height > window.innerHeight) { + 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 +829,28 @@ Phoenix.app = { return window.electronAPI.onWindowEvent(eventName, callback); } return () => {}; // No-op for unsupported platforms + }, + screenShotBinary: function (rect) { + return _capturePageBinary(rect); + }, + screenShotToBlob: async function (rect) { + const bytes = await _capturePageBinary(rect); + return new Blob([bytes], { type: "image/png" }); + }, + screenShotToPNGFile: async function (filePathToSave, rect) { + if (!filePathToSave || typeof filePathToSave !== 'string') { + throw new Error("filePathToSave must be a non-empty string"); + } + const bytes = await _capturePageBinary(rect); + 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..297ff4b76 100644 --- a/test/spec/Native-platform-test.js +++ b/test/spec/Native-platform-test.js @@ -390,6 +390,162 @@ 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"); + }); + }); + + 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); + }); + }); + + 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 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; From 762dd11a36b9255a8019bf8969ae8b32042e6c30 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 13 Feb 2026 14:29:12 +0530 Subject: [PATCH 3/5] feat: screenshot APIs accept DOM nodes and jQuery selectors Add _resolveRect() helper to convert DOM elements and jQuery selector strings to capture rects, with zoom factor compensation. Update _capturePageBinary bounds validation to account for webview scale factor. Add tests for DOM element, selector, and error case inputs. --- src/phoenix/shell.js | 89 +++++++++++++++++++++++--- test/spec/Native-platform-test.js | 101 ++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 9 deletions(-) diff --git a/src/phoenix/shell.js b/src/phoenix/shell.js index 66f04e8e0..f245f7cdb 100644 --- a/src/phoenix/shell.js +++ b/src/phoenix/shell.js @@ -147,10 +147,52 @@ Phoenix.libs = { // global API is only usable/stable after App init Phoenix.globalAPI = {}; -async function _capturePageBinary(rect) { +function _resolveRect(rectOrNodeOrSelector) { + if (rectOrNodeOrSelector === undefined || rectOrNodeOrSelector === null) { + return undefined; // full page capture + } + let element; + // Case 1: jQuery selector string + if (typeof rectOrNodeOrSelector === 'string') { + const $el = $(rectOrNodeOrSelector); + if ($el.length === 0) { + throw new Error("No element found for selector: " + + rectOrNodeOrSelector); + } + if ($el.length > 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) { @@ -166,10 +208,11 @@ async function _capturePageBinary(rect) { if (rect.width <= 0 || rect.height <= 0) { throw new Error("rect width and height must be greater than 0"); } - if (rect.x + rect.width > window.innerWidth) { + const zoomFactor = (window.PhStore && window.PhStore.getItem("desktopZoomScale")) || 1; + if (rect.x + rect.width > window.innerWidth * zoomFactor) { throw new Error("rect x + width exceeds window innerWidth"); } - if (rect.y + rect.height > window.innerHeight) { + if (rect.y + rect.height > window.innerHeight * zoomFactor) { throw new Error("rect y + height exceeds window innerHeight"); } } @@ -830,18 +873,46 @@ Phoenix.app = { } return () => {}; // No-op for unsupported platforms }, - screenShotBinary: function (rect) { - return _capturePageBinary(rect); + /** + * 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 + */ + screenShotBinary: function (rectOrNodeOrSelector) { + return _capturePageBinary(rectOrNodeOrSelector); }, - screenShotToBlob: async function (rect) { - const bytes = await _capturePageBinary(rect); + /** + * 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" + */ + screenShotToBlob: async function (rectOrNodeOrSelector) { + const bytes = await _capturePageBinary(rectOrNodeOrSelector); return new Blob([bytes], { type: "image/png" }); }, - screenShotToPNGFile: async function (filePathToSave, rect) { + /** + * 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} + */ + screenShotToPNGFile: async function (filePathToSave, rectOrNodeOrSelector) { if (!filePathToSave || typeof filePathToSave !== 'string') { throw new Error("filePathToSave must be a non-empty string"); } - const bytes = await _capturePageBinary(rect); + const bytes = await _capturePageBinary(rectOrNodeOrSelector); return new Promise((resolve, reject) => { fs.writeFile(filePathToSave, bytes.buffer, 'binary', (err) => { if (err) { diff --git a/test/spec/Native-platform-test.js b/test/spec/Native-platform-test.js index 297ff4b76..40c12c5ef 100644 --- a/test/spec/Native-platform-test.js +++ b/test/spec/Native-platform-test.js @@ -461,6 +461,54 @@ define(function (require, exports, module) { 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 () { @@ -477,6 +525,36 @@ define(function (require, exports, module) { 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 () { @@ -526,6 +604,29 @@ define(function (require, exports, module) { 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() From 986156ef1d6199d1eb269e7232c8ebe119701c9f Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 13 Feb 2026 14:40:41 +0530 Subject: [PATCH 4/5] docs: screenshot api --- src/phoenix/shell.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/phoenix/shell.js b/src/phoenix/shell.js index f245f7cdb..6607d4970 100644 --- a/src/phoenix/shell.js +++ b/src/phoenix/shell.js @@ -881,6 +881,17 @@ Phoenix.app = { * - 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); @@ -893,6 +904,14 @@ Phoenix.app = { * - 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); @@ -907,6 +926,14 @@ Phoenix.app = { * - 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') { From c32162a2123ca7483931367e82d79e954c7b2c39 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 13 Feb 2026 15:25:11 +0530 Subject: [PATCH 5/5] fix: floating point precision in screenshot bounds check Use Math.ceil on zoom-adjusted dimensions to prevent false rejections when innerWidth/Height * zoomFactor produces fractional values. --- src/phoenix/shell.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/phoenix/shell.js b/src/phoenix/shell.js index 6607d4970..856437081 100644 --- a/src/phoenix/shell.js +++ b/src/phoenix/shell.js @@ -209,10 +209,12 @@ async function _capturePageBinary(rectOrNodeOrSelector) { throw new Error("rect width and height must be greater than 0"); } const zoomFactor = (window.PhStore && window.PhStore.getItem("desktopZoomScale")) || 1; - if (rect.x + rect.width > window.innerWidth * zoomFactor) { + 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 > window.innerHeight * zoomFactor) { + if (rect.y + rect.height > maxHeight) { throw new Error("rect y + height exceeds window innerHeight"); } }