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;