diff --git a/.gitignore b/.gitignore index 54afb48..b0fdd44 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* +/.claude +/.idea + # local env files .env*.local diff --git a/README.md b/README.md index 9567902..d0c1a49 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,17 @@ The Percy Chrome Extension is a versatile Chrome extension, developed within the ## Percy Desktop App -Percy Desktop App is an electron application designed to enable Percy local servers on your system. It works alongside the Percy Chrome Extension. Once the DOM snapshot are captured via extension, they are then sent to cloud via percy-cli for further rendering across different browsers and resolutions. So make sure to enable the desktop app before finalizing the build. +Percy Desktop App is a lightweight menu bar application (built with plain Node.js, no Electron) designed to enable Percy local servers on your system. It works alongside the Percy Chrome Extension. Once the DOM snapshots are captured via extension, they are then sent to cloud via percy-cli for further rendering across different browsers and resolutions. So make sure the desktop app is running (look for the Percy icon in your menu bar / system tray) before finalizing the build. -To install the Percy Desktop App, kindly select the appropriate link corresponding to your operating system: - - If you are using Windows, please click on the [link](https://github.com/BrowserStackCE/percy-desktop-app/releases/download/v0.0.1/win.percy-desktop-app-0.0.1.Setup.exe) for Windows users. - - If you are using macOS, please choose the [link](https://github.com/BrowserStackCE/percy-desktop-app/releases/download/v0.0.1/osx.percy-desktop-app-darwin-x64-0.0.1.zip) designated for macOS. - - If you are using Linux, please opt for the [link](https://github.com/BrowserStackCE/percy-desktop-app/releases/download/v0.0.1/linux.percy-desktop-app_0.0.1_amd64.deb) tailored for Linux users. +To build the app for your operating system: -To know more about Percy Desktop App, please refer [this](https://github.com/BrowserStackCE/percy-desktop-app/blob/develop/README.md) documentation. +```bash +cd desktop-app +npm install +npm run build:mac # or build:win / build:linux +``` + +Then launch `dist/mac/Percy Desktop App.app` (macOS) or the generated executable for your platform. To know more about Percy Desktop App, please refer to the [desktop-app documentation](desktop-app/README.md). ### Important: diff --git a/desktop-app/.gitignore b/desktop-app/.gitignore new file mode 100644 index 0000000..3708e40 --- /dev/null +++ b/desktop-app/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +native/build/ +dist/ diff --git a/desktop-app/README.md b/desktop-app/README.md new file mode 100644 index 0000000..3da9d83 --- /dev/null +++ b/desktop-app/README.md @@ -0,0 +1,66 @@ +# Percy Desktop App + +A lightweight desktop menu bar app for the [Percy Chrome Extension](https://github.com/BrowserStackCE/percy-chrome-extension), built with plain Node.js — no Electron. It replaces the previous Electron-based desktop app. + +The Chrome extension captures DOM snapshots in the browser, but uploading them to Percy requires a local Percy server (the Percy CLI). This app sits in your menu bar / system tray and exposes a small HTTP API on `localhost:3778` that the extension calls to start that server when you finalize a build. + +Once launched you get a small Percy icon in the macOS menu bar (or Windows/Linux system tray) with: + +- **Percy server: running / stopped** — live status +- **Stop Percy server** +- **Quit** + +## Why not Electron? + +The Electron app only used Electron for the tray icon — the actual work is a tiny HTTP server that spawns the Percy CLI. This version does the same with a plain Node.js process, a ~100-line native Swift menu bar helper on macOS (universal arm64 + Intel), and the small [systray2](https://www.npmjs.com/package/systray2) helper on Windows/Linux. No ~200 MB browser runtime. + +## Install / Run (from a build) + +Download or build the app for your OS (see Building below), then: + +- **macOS**: open `Percy Desktop App.app` (right-click → Open the first time, since the build is not notarized). The Percy icon appears in the menu bar; there is no Dock icon. +- **Windows**: run `percy-desktop-app.exe`. The tray icon appears next to the clock (a console window with logs also opens). +- **Linux**: run `percy-desktop-app`. + +On the first build finalization the app downloads the standalone Percy CLI (~80 MB) from the [official percy/cli releases](https://github.com/percy/cli/releases) into `~/.percy-desktop-app/bin` — a one-time step, after which starting the Percy server takes a few seconds. + +## Run from source (development) + +Requires Node.js 18+: + +```bash +cd desktop-app +npm install +npm start +``` + +On macOS the native menu bar helper is compiled on first run (needs Xcode command line tools); without them it falls back to the bundled systray helper, and failing that it runs headless. `npm start -- --headless` skips the tray entirely. + +## Building the apps + +```bash +npm run build:mac # dist/mac/Percy Desktop App.app (run on macOS) +npm run build:win # dist/win/percy-desktop-app.exe +npm run build:linux # dist/linux/percy-desktop-app +``` + +Packaging uses [@yao-pkg/pkg](https://github.com/yao-pkg/pkg) to produce self-contained executables (Node.js bundled in — end users do not need Node). The mac build compiles the Swift tray helper as a universal binary, assembles the `.app` bundle (`LSUIElement` = menu-bar-only, no Dock icon), and applies an ad-hoc code signature; for public distribution, sign with a Developer ID certificate and notarize. + +## HTTP API + +| Method | Path | Description | +| --- | --- | --- | +| `GET` | `/healthcheck` | App health, plus whether the Percy server is running | +| `POST` | `/percy/start` | Start the local Percy server. Body: Percy config JSON (`version`, `percy.token`, `snapshot`, `discovery`) | +| `POST` | `/percy/snapshot` | Proxy a snapshot upload to the Percy server (which rejects requests with a `chrome-extension://` Origin, so the extension can't call it directly) | +| `POST` | `/percy/stop` | Stop the local Percy server | + +`POST /percy/start` responds `200` once the Percy server is healthy, `400` for invalid config, and `500` with the underlying Percy error (e.g. `Invalid API token.`) if the server fails to start. + +## Notes + +- The server binds to `127.0.0.1` only — it is not reachable from other machines. +- State-changing endpoints (`/percy/start`, `/percy/stop`) reject requests from web page origins; only the Chrome extension (`chrome-extension://` origin) and local tools without an `Origin` header (e.g. `curl`) are accepted. The `Host` header is also validated to defend against DNS rebinding. +- Snapshot/discovery config is written to `~/.percy-desktop-app/.percy.json`. Your Percy token is never written to disk; it is passed to the Percy CLI through the environment. +- Logs are written to `~/.percy-desktop-app/app.log`. +- The Percy server itself listens on `localhost:5338`, the standard Percy CLI port the extension talks to directly for snapshots and stopping. diff --git a/desktop-app/assets/icon.icns b/desktop-app/assets/icon.icns new file mode 100644 index 0000000..219b3aa Binary files /dev/null and b/desktop-app/assets/icon.icns differ diff --git a/desktop-app/assets/icon.ico b/desktop-app/assets/icon.ico new file mode 100644 index 0000000..a48a79b Binary files /dev/null and b/desktop-app/assets/icon.ico differ diff --git a/desktop-app/assets/icon.png b/desktop-app/assets/icon.png new file mode 100644 index 0000000..e7f4443 Binary files /dev/null and b/desktop-app/assets/icon.png differ diff --git a/desktop-app/assets/tray.png b/desktop-app/assets/tray.png new file mode 100644 index 0000000..25ee296 Binary files /dev/null and b/desktop-app/assets/tray.png differ diff --git a/desktop-app/native/tray.swift b/desktop-app/native/tray.swift new file mode 100644 index 0000000..b361334 --- /dev/null +++ b/desktop-app/native/tray.swift @@ -0,0 +1,76 @@ +// Minimal menu bar helper for the Percy desktop app. +// Protocol (JSON lines over stdio): +// stdin: {"type":"menu","tooltip":"...","items":[{"title":"...","enabled":true}, {"title":"-"}]} +// stdout: {"type":"ready"} once, then {"type":"click","index":N} per click +// argv[1]: path to the tray icon PNG (rendered as a template image). +// Exits when stdin closes (i.e. the parent Node process dies). +import AppKit + +final class AppDelegate: NSObject, NSApplicationDelegate { + var statusItem: NSStatusItem! + + func applicationDidFinishLaunching(_ notification: Notification) { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + if CommandLine.arguments.count > 1, let image = NSImage(contentsOfFile: CommandLine.arguments[1]) { + image.isTemplate = true + image.size = NSSize(width: 18, height: 18) + statusItem.button?.image = image + } else { + statusItem.button?.title = "P" + } + statusItem.menu = NSMenu() + readCommands() + emit("{\"type\":\"ready\"}") + } + + func emit(_ line: String) { + print(line) + fflush(stdout) + } + + func readCommands() { + DispatchQueue.global(qos: .utility).async { + while let line = readLine(strippingNewline: true) { + guard let data = line.data(using: .utf8), + let command = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + else { continue } + DispatchQueue.main.async { self.apply(command) } + } + DispatchQueue.main.async { NSApp.terminate(nil) } + } + } + + func apply(_ command: [String: Any]) { + guard command["type"] as? String == "menu", + let itemSpecs = command["items"] as? [[String: Any]] + else { return } + let menu = NSMenu() + menu.autoenablesItems = false + for (index, spec) in itemSpecs.enumerated() { + let title = spec["title"] as? String ?? "" + if title == "-" { + menu.addItem(.separator()) + continue + } + let item = NSMenuItem(title: title, action: #selector(clicked(_:)), keyEquivalent: "") + item.target = self + item.tag = index + item.isEnabled = spec["enabled"] as? Bool ?? true + menu.addItem(item) + } + statusItem.menu = menu + if let tooltip = command["tooltip"] as? String { + statusItem.button?.toolTip = tooltip + } + } + + @objc func clicked(_ sender: NSMenuItem) { + emit("{\"type\":\"click\",\"index\":\(sender.tag)}") + } +} + +let app = NSApplication.shared +app.setActivationPolicy(.accessory) +let delegate = AppDelegate() +app.delegate = delegate +app.run() diff --git a/desktop-app/package-lock.json b/desktop-app/package-lock.json new file mode 100644 index 0000000..45e3632 --- /dev/null +++ b/desktop-app/package-lock.json @@ -0,0 +1,1329 @@ +{ + "name": "percy-desktop-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "percy-desktop-app", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "adm-zip": "^0.5.10", + "systray2": "^2.1.4", + "zod": "^3.22.4" + }, + "bin": { + "percy-desktop-app": "src/index.js" + }, + "devDependencies": { + "@yao-pkg/pkg": "^5.11.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@yao-pkg/pkg": { + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-5.16.1.tgz", + "integrity": "sha512-crUlnNFSReFNFuXDc4f3X2ignkFlc9kmEG7Bp/mJMA1jYyqR0lqjZGLgrSDYTYiNsYud8AzgA3RY1DrMdcUZWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.23.0", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", + "@yao-pkg/pkg-fetch": "3.5.16", + "into-stream": "^6.0.0", + "minimist": "^1.2.6", + "multistream": "^4.1.0", + "picocolors": "^1.1.0", + "picomatch": "^4.0.2", + "prebuild-install": "^7.1.1", + "resolve": "^1.22.0", + "stream-meter": "^1.0.4", + "tinyglobby": "^0.2.9" + }, + "bin": { + "pkg": "lib-es5/bin.js" + } + }, + "node_modules/@yao-pkg/pkg-fetch": { + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.5.16.tgz", + "integrity": "sha512-mCnZvZz0/Ylpk4TGyt34pqWJyBGYJM8c3dPoMRV8Knodv2QhcYS4iXb5kB/JNWkrRtCKukGZIKkMLXZ3TQlzPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.6", + "picocolors": "^1.1.0", + "progress": "^2.0.3", + "semver": "^7.3.5", + "tar-fs": "^2.1.1", + "yargs": "^16.2.0" + }, + "bin": { + "pkg-fetch": "lib-es5/bin.js" + } + }, + "node_modules/@yao-pkg/pkg-fetch/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@yao-pkg/pkg-fetch/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/@yao-pkg/pkg-fetch/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@yao-pkg/pkg-fetch/node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@yao-pkg/pkg-fetch/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/@yao-pkg/pkg/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/adm-zip": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/from2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", + "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.0" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/into-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-6.0.0.tgz", + "integrity": "sha512-XHbaOAvP+uFKUFsOgoNPRjLkwB+I22JFPFe5OjTkQ0nwgj6+pSjb4NmB6VMxaPshLiOf+zcpOCBQuLwC1KHhZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "from2": "^2.3.0", + "p-is-promise": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multistream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/multistream/-/multistream-4.1.0.tgz", + "integrity": "sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "once": "^1.4.0", + "readable-stream": "^3.6.0" + } + }, + "node_modules/multistream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-is-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", + "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/stream-meter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/stream-meter/-/stream-meter-1.0.4.tgz", + "integrity": "sha512-4sOEtrbgFotXwnEuzzsQBYEV1elAeFSO8rSGeTwabuX1RRn/kEq9JVH7I0MRBhKVRR0sJkr0M0QCH7yOLf9fhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.1.4" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/systray2": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/systray2/-/systray2-2.1.4.tgz", + "integrity": "sha512-ncviZ7m2fecb2tAAx7pl4nlbnwvSyL4GIKnRUvfiJXTsHysh3chSlxVxNk7Yj8jlfF2TsBn2CjLwCfhJQkR7/w==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.2", + "fs-extra": "^10.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/desktop-app/package.json b/desktop-app/package.json new file mode 100644 index 0000000..dcf44c3 --- /dev/null +++ b/desktop-app/package.json @@ -0,0 +1,44 @@ +{ + "name": "percy-desktop-app", + "version": "1.0.0", + "description": "Desktop menu bar app for the Percy Chrome Extension — starts and stops a local Percy server", + "author": "BrowserStack Pvt Ltd", + "license": "MIT", + "main": "src/index.js", + "bin": { + "percy-desktop-app": "src/index.js" + }, + "scripts": { + "start": "node src/index.js", + "build": "node scripts/build.js", + "build:mac": "node scripts/build.js mac", + "build:win": "node scripts/build.js win", + "build:linux": "node scripts/build.js linux" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "adm-zip": "^0.5.10", + "systray2": "^2.1.4", + "zod": "^3.22.4" + }, + "devDependencies": { + "@yao-pkg/pkg": "^5.11.5" + }, + "pkg": { + "assets": [ + "node_modules/systray2/traybin/**/*", + "assets/**/*" + ] + }, + "keywords": [ + "percy", + "browserstack", + "visual-testing" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/BrowserStackCE/percy-chrome-extension.git" + } +} diff --git a/desktop-app/scripts/build.js b/desktop-app/scripts/build.js new file mode 100644 index 0000000..81abce0 --- /dev/null +++ b/desktop-app/scripts/build.js @@ -0,0 +1,103 @@ +#!/usr/bin/env node +// Builds distributable desktop apps with @yao-pkg/pkg: +// node scripts/build.js [mac|win|linux] (defaults to the host platform) +// mac -> dist/mac/Percy Desktop App.app (menu bar app, no Dock icon) +// win -> dist/win/percy-desktop-app.exe +// linux -> dist/linux/percy-desktop-app +const { execFileSync } = require('node:child_process') +const { chmodSync, cpSync, mkdirSync, rmSync, writeFileSync } = require('node:fs') +const { join } = require('node:path') +const { version } = require('../package.json') + +const ROOT = join(__dirname, '..') +const DIST = join(ROOT, 'dist') +const HOST = { darwin: 'mac', win32: 'win', linux: 'linux' }[process.platform] +const target = process.argv[2] || HOST + +const PKG_TARGETS = { + mac: `node20-macos-${process.arch === 'arm64' ? 'arm64' : 'x64'}`, + win: 'node20-win-x64', + linux: 'node20-linux-x64' +} + +function run(cmd, args, opts = {}) { + console.log(`$ ${cmd} ${args.join(' ')}`) + execFileSync(cmd, args, { stdio: 'inherit', cwd: ROOT, ...opts }) +} + +function pkgBuild(pkgTarget, outPath) { + run('npx', ['pkg', '.', '--targets', pkgTarget, '--output', outPath]) +} + +function compileMacTrayHelper(outPath) { + // universal binary so the same .app works on Apple Silicon and Intel Macs + const src = join(ROOT, 'native', 'tray.swift') + const tmpArm = join(DIST, 'tray-arm64') + const tmpX64 = join(DIST, 'tray-x64') + try { + run('xcrun', ['swiftc', '-O', '-target', 'arm64-apple-macos11', src, '-o', tmpArm]) + run('xcrun', ['swiftc', '-O', '-target', 'x86_64-apple-macos11', src, '-o', tmpX64]) + run('lipo', ['-create', '-output', outPath, tmpArm, tmpX64]) + } catch { + console.log('universal build failed, compiling for host architecture only') + run('xcrun', ['swiftc', '-O', src, '-o', outPath]) + } finally { + rmSync(tmpArm, { force: true }) + rmSync(tmpX64, { force: true }) + } +} + +function infoPlist() { + return ` + + + + CFBundleNamePercy Desktop App + CFBundleDisplayNamePercy Desktop App + CFBundleIdentifiercom.browserstack.percy-desktop-app + CFBundleExecutablepercy-desktop-app + CFBundleIconFileicon.icns + CFBundlePackageTypeAPPL + CFBundleVersion${version} + CFBundleShortVersionString${version} + LSMinimumSystemVersion11.0 + LSUIElement + NSHighResolutionCapable + + +` +} + +function buildMac() { + if (process.platform !== 'darwin') throw new Error('the mac build must run on macOS') + const appDir = join(DIST, 'mac', 'Percy Desktop App.app') + const contents = join(appDir, 'Contents') + const macos = join(contents, 'MacOS') + const resources = join(contents, 'Resources') + rmSync(appDir, { recursive: true, force: true }) + mkdirSync(macos, { recursive: true }) + mkdirSync(resources, { recursive: true }) + + pkgBuild(PKG_TARGETS.mac, join(macos, 'percy-desktop-app')) + compileMacTrayHelper(join(resources, 'percy-tray')) + chmodSync(join(resources, 'percy-tray'), 0o755) + cpSync(join(ROOT, 'assets', 'icon.icns'), join(resources, 'icon.icns')) + cpSync(join(ROOT, 'assets', 'tray.png'), join(resources, 'tray.png')) + writeFileSync(join(contents, 'Info.plist'), infoPlist()) + + // ad-hoc signature so macOS on Apple Silicon will launch the bundle; + // replace with a Developer ID identity + notarization for distribution + run('codesign', ['--force', '--deep', '--sign', '-', appDir]) + console.log(`\nBuilt ${appDir}`) +} + +function buildFlat(name) { + const out = join(DIST, name, `percy-desktop-app${name === 'win' ? '.exe' : ''}`) + mkdirSync(join(DIST, name), { recursive: true }) + pkgBuild(PKG_TARGETS[name], out) + console.log(`\nBuilt ${out}`) +} + +if (target === 'mac') buildMac() +else if (target === 'win' || target === 'linux') buildFlat(target) +else throw new Error(`Unknown target "${target}" — use mac, win or linux`) diff --git a/desktop-app/src/index.js b/desktop-app/src/index.js new file mode 100644 index 0000000..ca1e6f4 --- /dev/null +++ b/desktop-app/src/index.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node +const { version } = require('../package.json') +const { log, LOG_PATH } = require('./log') +const { isPercyRunning, killPercy, stopPercy } = require('./percy') +const { APP_PORT, startAppServer } = require('./server') +const { startTray, STOP_INDEX, QUIT_INDEX } = require('./tray') + +const STATUS_POLL_MS = 3000 + +async function main() { + let tray = null + + const shutdown = () => { + log('[app] shutting down') + killPercy() + tray?.destroy() + process.exit(0) + } + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) + + try { + await startAppServer() + } catch (err) { + if (err.code === 'EADDRINUSE') { + log(`[app] port ${APP_PORT} is already in use — is another instance of the Percy desktop app running?`) + process.exit(1) + } + throw err + } + log(`[app] Percy desktop app v${version} listening on http://localhost:${APP_PORT} (log: ${LOG_PATH})`) + + if (!process.argv.includes('--headless')) { + tray = await startTray(version, async (index) => { + if (index === STOP_INDEX) { + await stopPercy() + tray?.setPercyRunning(await isPercyRunning()) + } else if (index === QUIT_INDEX) { + shutdown() + } + }) + } + if (tray) { + log('[app] menu bar icon is up') + const refresh = async () => { + tray.setPercyRunning(await isPercyRunning()) + setTimeout(refresh, STATUS_POLL_MS) + } + refresh() + } +} + +main().catch((err) => { + log(`[app] fatal: ${err.stack || err}`) + process.exit(1) +}) diff --git a/desktop-app/src/log.js b/desktop-app/src/log.js new file mode 100644 index 0000000..8cce0ec --- /dev/null +++ b/desktop-app/src/log.js @@ -0,0 +1,19 @@ +const { appendFileSync, mkdirSync } = require('node:fs') +const { join } = require('node:path') +const { WORK_DIR } = require('./paths') + +// A double-clicked app has no terminal, so mirror everything to a log file +const LOG_PATH = join(WORK_DIR, 'app.log') + +function log(...args) { + const line = args.map(String).join(' ') + console.log(line) + try { + mkdirSync(WORK_DIR, { recursive: true }) + appendFileSync(LOG_PATH, `${new Date().toISOString()} ${line}\n`) + } catch { + // logging must never take the app down + } +} + +module.exports = { log, LOG_PATH } diff --git a/desktop-app/src/paths.js b/desktop-app/src/paths.js new file mode 100644 index 0000000..3ac7fd2 --- /dev/null +++ b/desktop-app/src/paths.js @@ -0,0 +1,22 @@ +const { homedir } = require('node:os') +const { dirname, join } = require('node:path') + +const IS_PACKAGED = typeof process.pkg !== 'undefined' +const PROJECT_ROOT = join(__dirname, '..') + +// Where bundled read-only resources live. In the packaged macOS .app the real +// files sit in Contents/Resources next to the executable; in dev (and inside +// the pkg snapshot for win/linux) they live under the project's assets dir. +const RESOURCES_DIR = IS_PACKAGED && process.platform === 'darwin' + ? join(dirname(process.execPath), '..', 'Resources') + : join(PROJECT_ROOT, 'assets') + +// Writable per-user state: percy binary, config, logs +const WORK_DIR = join(homedir(), '.percy-desktop-app') + +module.exports = { + IS_PACKAGED, + PROJECT_ROOT, + RESOURCES_DIR, + WORK_DIR +} diff --git a/desktop-app/src/percy.js b/desktop-app/src/percy.js new file mode 100644 index 0000000..b8075b5 --- /dev/null +++ b/desktop-app/src/percy.js @@ -0,0 +1,190 @@ +const { spawn } = require('node:child_process') +const { chmodSync, createWriteStream, existsSync, mkdirSync, rmSync, writeFileSync } = require('node:fs') +const { join } = require('node:path') +const { Readable } = require('node:stream') +const { pipeline } = require('node:stream/promises') +const AdmZip = require('adm-zip') +const { log } = require('./log') +const { WORK_DIR } = require('./paths') + +const PERCY_SERVER_URL = 'http://localhost:5338' +const BIN_DIR = join(WORK_DIR, 'bin') +const CONFIG_PATH = join(WORK_DIR, '.percy.json') +const PERCY_BIN = join(BIN_DIR, process.platform === 'win32' ? 'percy.exe' : 'percy') +const DOWNLOAD_ASSETS = { + darwin: 'percy-osx.zip', + win32: 'percy-win.zip', + linux: 'percy-linux.zip' +} +const START_TIMEOUT_MS = 180_000 + +// Percy's standalone CLI is x86_64-only, and so is the Chromium it downloads. +// On Apple Silicon the first Rosetta translation of that Chromium takes longer +// than Percy's 30s launch timeout, so prefer the locally installed Chrome +// (native, and guaranteed present for users of the Chrome extension). +const CHROME_PATHS = { + darwin: [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge' + ], + win32: [ + `${process.env.PROGRAMFILES}\\Google\\Chrome\\Application\\chrome.exe`, + `${process.env['PROGRAMFILES(X86)']}\\Google\\Chrome\\Application\\chrome.exe`, + `${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`, + `${process.env.PROGRAMFILES}\\Microsoft\\Edge\\Application\\msedge.exe` + ], + linux: [ + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium-browser', + '/usr/bin/chromium' + ] +} + +function detectBrowser() { + if (process.env.PERCY_BROWSER_EXECUTABLE) return process.env.PERCY_BROWSER_EXECUTABLE + return (CHROME_PATHS[process.platform] || []).find((path) => existsSync(path)) +} + +let child = null + +function isPercyRunning() { + return fetch(`${PERCY_SERVER_URL}/percy/healthcheck`) + .then((res) => res.ok) + .catch(() => false) +} + +// The packaged app has no node/npm to lean on, so it runs the standalone +// Percy CLI binary, downloaded once from the latest GitHub release +async function ensurePercyBinary() { + if (existsSync(PERCY_BIN)) return + const asset = DOWNLOAD_ASSETS[process.platform] + if (!asset) throw new Error(`Unsupported platform: ${process.platform}`) + + const url = `https://github.com/percy/cli/releases/latest/download/${asset}` + log(`[percy] downloading Percy CLI from ${url} ...`) + const res = await fetch(url) + if (!res.ok) throw new Error(`Failed to download Percy CLI (HTTP ${res.status})`) + + mkdirSync(BIN_DIR, { recursive: true }) + const zipPath = join(BIN_DIR, 'percy.zip') + await pipeline(Readable.fromWeb(res.body), createWriteStream(zipPath)) + new AdmZip(zipPath).extractAllTo(BIN_DIR, true) + rmSync(zipPath, { force: true }) + if (!existsSync(PERCY_BIN)) throw new Error('Downloaded archive did not contain the percy binary') + if (process.platform !== 'win32') chmodSync(PERCY_BIN, 0o755) + log('[percy] Percy CLI downloaded') +} + +function waitForStarted(proc) { + return new Promise((resolve, reject) => { + const deadline = Date.now() + START_TIMEOUT_MS + let settled = false + let stderr = '' + const finish = (err) => { + if (settled) return + settled = true + err ? reject(err) : resolve() + } + proc.stderr.on('data', (chunk) => { stderr += chunk }) + // "Percy has started!" is the true ready signal — the healthcheck + // endpoint responds a little earlier, while percy still rejects + // snapshots with "Not running" + proc.stdout.on('data', (chunk) => { + if (String(chunk).includes('Percy has started')) finish() + }) + proc.on('exit', (code) => { + finish(new Error(`Percy exited with code ${code} before it was ready.\n${stderr.trim()}`)) + }) + const poll = async () => { + if (settled || proc.exitCode !== null) return + if (Date.now() > deadline) { + proc.kill() + return finish(new Error('Timed out waiting for the Percy server to start')) + } + if (await isPercyRunning()) { + // fallback in case the log line ever changes: healthy server + // plus a grace period counts as started + setTimeout(() => { + if (proc.exitCode === null) finish() + }, 3000) + return + } + setTimeout(poll, 500) + } + poll() + }) +} + +async function startPercy(config) { + if (await isPercyRunning()) { + log('[percy] server is already running') + return { alreadyRunning: true } + } + await ensurePercyBinary() + + // token goes through the environment, everything else through the config file + const { percy, ...fileConfig } = config + // generous launch timeout: first launch of Percy's x86_64 Chromium on + // Apple Silicon needs Rosetta translation, which can exceed the 30s default + fileConfig.discovery = { + ...fileConfig.discovery, + 'launch-options': { timeout: 120_000, ...fileConfig.discovery?.['launch-options'] } + } + mkdirSync(WORK_DIR, { recursive: true }) + writeFileSync(CONFIG_PATH, JSON.stringify(fileConfig, null, 2)) + + const env = { + ...process.env, + PERCY_TOKEN: percy.token, + PERCY_BRANCH: 'percy-web-extension' + } + const browser = detectBrowser() + if (browser) { + env.PERCY_BROWSER_EXECUTABLE = browser + log(`[percy] using browser: ${browser}`) + } + + log('[percy] starting local Percy server...') + child = spawn(PERCY_BIN, ['exec:start', '--config', CONFIG_PATH], { + cwd: WORK_DIR, + env + }) + child.stdout.on('data', (chunk) => log(`[percy] ${String(chunk).trimEnd()}`)) + child.stderr.on('data', (chunk) => log(`[percy] ${String(chunk).trimEnd()}`)) + child.on('exit', (code) => { + log(`[percy] server process exited (code ${code})`) + child = null + }) + + try { + await waitForStarted(child) + } catch (err) { + child = null + throw err + } + log(`[percy] server is up at ${PERCY_SERVER_URL}`) + return { alreadyRunning: false } +} + +async function stopPercy() { + // ask the Percy CLI server to shut down gracefully, fall back to killing the child + const stopped = await fetch(`${PERCY_SERVER_URL}/percy/stop`, { method: 'POST' }) + .then((res) => res.ok) + .catch(() => false) + if (!stopped && child) { + child.kill() + child = null + } + return stopped +} + +function killPercy() { + if (child) { + child.kill() + child = null + } +} + +module.exports = { PERCY_SERVER_URL, isPercyRunning, startPercy, stopPercy, killPercy } diff --git a/desktop-app/src/schemas.js b/desktop-app/src/schemas.js new file mode 100644 index 0000000..10382cf --- /dev/null +++ b/desktop-app/src/schemas.js @@ -0,0 +1,41 @@ +const { z } = require('zod') + +const DiscoveryOptions = z.object({ + "allowed-hostnames": z.array(z.string()).optional(), + "disallowed-hostnames": z.array(z.string()).optional(), + "request-headers": z.record(z.string(), z.string()).default({}), + "authorization": z.object({ + username: z.string().optional(), + password: z.string().optional() + }).optional(), + "disable-cache": z.boolean().optional(), + "cookies": z.string().optional(), + "device-pixel-ratio": z.string().optional().transform((v) => v == null ? v : Number(v)), + "user-agent": z.string().optional(), + "network-idle-timeout": z.number().optional(), + "concurrency": z.number().optional(), + "launch-options": z.object({ + executable: z.string().optional(), + timeout: z.number().optional(), + args: z.array(z.string()).optional() + }).passthrough().optional() +}) + +const SnapshotOptions = z.object({ + "widths": z.array(z.number().or(z.string())).max(10).default([375, 1280]).transform((v) => v.map((i) => Number(i))), + "min-height": z.string().or(z.number()).default("1024").transform((v) => Number(v)), + "percy-css": z.string().optional(), + "scope": z.string().optional(), + "enable-javascript": z.boolean().default(false) +}) + +const PercyConfig = z.object({ + version: z.literal("2").default("2"), + percy: z.object({ + token: z.string().min(1) + }), + snapshot: SnapshotOptions.default({}), + discovery: DiscoveryOptions.default({}) +}) + +module.exports = { DiscoveryOptions, SnapshotOptions, PercyConfig } diff --git a/desktop-app/src/server.js b/desktop-app/src/server.js new file mode 100644 index 0000000..11d77ce --- /dev/null +++ b/desktop-app/src/server.js @@ -0,0 +1,118 @@ +const { createServer } = require('node:http') +const { ZodError } = require('zod') +const { log } = require('./log') +const { PERCY_SERVER_URL, isPercyRunning, startPercy, stopPercy } = require('./percy') +const { PercyConfig } = require('./schemas') + +const APP_PORT = 3778 + +function readRawBody(req) { + return new Promise((resolve, reject) => { + let body = '' + req.on('data', (chunk) => { body += chunk }) + req.on('end', () => resolve(body)) + req.on('error', reject) + }) +} + +async function readJsonBody(req) { + const body = await readRawBody(req) + try { + return body ? JSON.parse(body) : {} + } catch { + throw new Error('Invalid JSON body') + } +} + +function sendJson(res, status, payload) { + res.writeHead(status, { 'content-type': 'application/json' }) + res.end(JSON.stringify(payload)) +} + +const ALLOWED_HOSTS = new Set([`localhost:${APP_PORT}`, `127.0.0.1:${APP_PORT}`]) + +// Websites in the user's browser can send requests to localhost, so only +// accept state-changing requests from the Chrome extension (or from local +// tools like curl, which send no Origin header at all) +function isTrustedOrigin(req) { + const origin = req.headers.origin + return origin == null || origin.startsWith('chrome-extension://') +} + +async function handle(req, res) { + // reject DNS-rebinding requests, where the Host header is an attacker's domain + if (!ALLOWED_HOSTS.has(req.headers.host)) { + return sendJson(res, 403, { error: 'Forbidden' }) + } + + const { pathname } = new URL(req.url, `http://localhost:${APP_PORT}`) + + if (req.method === 'GET' && pathname === '/healthcheck') { + return sendJson(res, 200, { app: 'percy-desktop-app', percyRunning: await isPercyRunning() }) + } + + if (req.method === 'POST' && pathname === '/percy/start') { + if (!isTrustedOrigin(req)) { + return sendJson(res, 403, { error: 'Forbidden' }) + } + const body = await readJsonBody(req) + const config = PercyConfig.parse(body) + const { alreadyRunning } = await startPercy(config) + return sendJson(res, 200, { started: true, alreadyRunning }) + } + + // Proxy snapshot uploads to the Percy server. Percy CLI rejects requests + // carrying a non-loopback Origin (which every chrome-extension request + // has), so the extension sends snapshots here and we forward them + // origin-less, server to server. + if (req.method === 'POST' && pathname === '/percy/snapshot') { + if (!isTrustedOrigin(req)) { + return sendJson(res, 403, { error: 'Forbidden' }) + } + const body = await readRawBody(req) + const search = new URL(req.url, `http://localhost:${APP_PORT}`).search + const upstream = await fetch(`${PERCY_SERVER_URL}/percy/snapshot${search}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body + }).catch(() => null) + if (!upstream) { + return sendJson(res, 502, { error: 'Percy server is not running' }) + } + const text = await upstream.text() + if (!upstream.ok) { + log(`[app] percy rejected snapshot (HTTP ${upstream.status}): ${text.slice(0, 1000)}`) + } + res.writeHead(upstream.status, { 'content-type': upstream.headers.get('content-type') || 'application/json' }) + return res.end(text) + } + + if (req.method === 'POST' && pathname === '/percy/stop') { + if (!isTrustedOrigin(req)) { + return sendJson(res, 403, { error: 'Forbidden' }) + } + const stopped = await stopPercy() + return sendJson(res, 200, { stopped }) + } + + sendJson(res, 404, { error: 'Not found' }) +} + +function startAppServer() { + const server = createServer((req, res) => { + handle(req, res).catch((err) => { + const isBadRequest = err instanceof ZodError || err.message === 'Invalid JSON body' + const message = err instanceof ZodError + ? err.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ') + : err.message + log(`[app] ${req.method} ${req.url} failed: ${message}`) + sendJson(res, isBadRequest ? 400 : 500, { error: message }) + }) + }) + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(APP_PORT, '127.0.0.1', () => resolve(server)) + }) +} + +module.exports = { APP_PORT, startAppServer } diff --git a/desktop-app/src/tray.js b/desktop-app/src/tray.js new file mode 100644 index 0000000..e3e6678 --- /dev/null +++ b/desktop-app/src/tray.js @@ -0,0 +1,148 @@ +const { spawn, spawnSync } = require('node:child_process') +const { existsSync, mkdirSync, readFileSync } = require('node:fs') +const { dirname, join } = require('node:path') +const readline = require('node:readline') +const { log } = require('./log') +const { PROJECT_ROOT, RESOURCES_DIR } = require('./paths') + +// Menu layout (indexes are the click ids reported back by both tray backends) +const STOP_INDEX = 3 +const QUIT_INDEX = 5 + +function menuItems(version, running) { + return [ + { title: `Percy Desktop App v${version}`, enabled: false }, + { title: `Percy server: ${running ? 'running' : 'stopped'}`, enabled: false }, + { title: '-' }, + { title: 'Stop Percy server', enabled: running }, + { title: '-' }, + { title: 'Quit', enabled: true } + ] +} + +//#region macOS — native Swift menu bar helper + +function compileMacHelper(outPath) { + mkdirSync(dirname(outPath), { recursive: true }) + log('[tray] compiling native menu bar helper...') + const result = spawnSync('xcrun', [ + 'swiftc', '-O', join(PROJECT_ROOT, 'native', 'tray.swift'), '-o', outPath + ], { encoding: 'utf8' }) + if (result.status !== 0) { + throw new Error(`swiftc failed: ${result.stderr || result.error?.message}`) + } +} + +function resolveMacHelper() { + const bundled = join(RESOURCES_DIR, 'percy-tray') + if (existsSync(bundled)) return bundled + const built = join(PROJECT_ROOT, 'native', 'build', 'percy-tray') + if (!existsSync(built)) compileMacHelper(built) + return built +} + +function startMacTray(version, onAction) { + const helper = resolveMacHelper() + const iconPath = join(RESOURCES_DIR, 'tray.png') + const proc = spawn(helper, existsSync(iconPath) ? [iconPath] : []) + const lines = readline.createInterface({ input: proc.stdout }) + + let running = false + const pushMenu = () => { + proc.stdin.write(`${JSON.stringify({ + type: 'menu', + tooltip: 'Percy Desktop App', + items: menuItems(version, running) + })}\n`) + } + + return new Promise((resolve, reject) => { + proc.once('error', reject) + proc.once('exit', (code) => reject(new Error(`tray helper exited early (code ${code})`))) + lines.on('line', (line) => { + let message + try { message = JSON.parse(line) } catch { return } + if (message.type === 'ready') { + pushMenu() + resolve({ + setPercyRunning(value) { + if (value !== running) { + running = value + pushMenu() + } + }, + destroy() { proc.kill() } + }) + } else if (message.type === 'click') { + onAction(message.index) + } + }) + }) +} + +//#endregion + +//#region Windows / Linux — systray2 (bundled native helper) + +async function startSystray(version, onAction) { + const SysTray = require('systray2').default + const iconFile = process.platform === 'win32' ? 'icon.ico' : 'tray.png' + const icon = readFileSync(join(RESOURCES_DIR, iconFile)).toString('base64') + + const toSystrayItem = (item) => item.title === '-' + ? SysTray.separator + : { title: item.title, tooltip: '', checked: false, enabled: item.enabled !== false } + + const systray = new SysTray({ + menu: { + icon, + isTemplateIcon: process.platform === 'darwin', + title: '', + tooltip: 'Percy Desktop App', + items: menuItems(version, false).map(toSystrayItem) + }, + debug: false, + // copy the helper binary out of the pkg snapshot so it can be executed + copyDir: true + }) + systray.onClick((action) => onAction(action.seq_id)) + await systray.ready() + + let running = false + return { + setPercyRunning(value) { + if (value === running) return + running = value + const items = menuItems(version, running) + for (const index of [1, STOP_INDEX]) { + systray.sendAction({ + type: 'update-item', + item: toSystrayItem(items[index]), + seq_id: index + }) + } + }, + destroy() { systray.kill(false) } + } +} + +//#endregion + +// Returns a tray handle, or null when no tray backend works (headless mode) +async function startTray(version, onAction) { + if (process.platform === 'darwin') { + try { + return await startMacTray(version, onAction) + } catch (err) { + log(`[tray] native helper unavailable (${err.message}), trying systray fallback`) + } + } + try { + return await startSystray(version, onAction) + } catch (err) { + log(`[tray] no tray icon available, running headless: ${err.message}`) + return null + } +} + +module.exports = { startTray, STOP_INDEX, QUIT_INDEX } diff --git a/package-lock.json b/package-lock.json index 6a620f1..d08cc44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "percy-extension", - "version": "0.0.1", + "version": "0.0.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "percy-extension", - "version": "0.0.1", + "version": "0.0.2", "dependencies": { "@ant-design/icons": "^5.2.5", "@parcel/watcher": "^2.3.0", diff --git a/utils/percy-utils.ts b/utils/percy-utils.ts index 569a239..43d0d20 100644 --- a/utils/percy-utils.ts +++ b/utils/percy-utils.ts @@ -1,4 +1,3 @@ -import { it } from "node:test"; import type { PercyBuild } from "~schemas/build"; import { type Preferences, PreferncesSchema } from "~schemas/preferences"; import type { Snapshot } from '~schemas/snapshot' @@ -57,7 +56,9 @@ export class Percy { static async sendSnapshot(options: any, params?: any) { let query = params ? `?${new URLSearchParams(params)}` : ''; - return fetch(`${baseurl}/percy/snapshot${query}`, { + // routed through the desktop app: percy CLI rejects requests with a + // chrome-extension:// Origin, the app forwards them origin-less + return fetch(`${appUrl}/percy/snapshot${query}`, { body: JSON.stringify(options), method: 'POST' }).then(async (res) => { @@ -70,7 +71,9 @@ export class Percy { } static async stopPercy() { - return fetch(`${baseurl}/percy/stop`).then((res) => res.status == 200).catch(() => false) + // routed through the desktop app (percy CLI rejects cross-origin + // requests); the app POSTs percy's /percy/stop for us + return fetch(`${appUrl}/percy/stop`, { method: 'POST' }).then((res) => res.status == 200).catch(() => false) } static async startPercy() { @@ -107,6 +110,11 @@ export class Percy { static async finalise() { await LocalStorage.set('finalizing', true) + // Starting percy and uploading snapshots involve fetches that can run + // well past 30s, and pending fetches don't reset Chrome's service + // worker idle timer — without keepalive pings the worker is killed + // mid-finalize. Extension API calls do reset the timer. + const keepAlive = setInterval(() => chrome.runtime.getPlatformInfo(), 20_000) try { const running = await Percy.isEnabled() if (!running) { @@ -156,6 +164,7 @@ export class Percy { console.log(err) return false } finally { + clearInterval(keepAlive) await LocalStorage.set('finalizing', false) } }