Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
/.claude
/.idea


# local env files
.env*.local
Expand Down
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
3 changes: 3 additions & 0 deletions desktop-app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
native/build/
dist/
66 changes: 66 additions & 0 deletions desktop-app/README.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added desktop-app/assets/icon.icns
Binary file not shown.
Binary file added desktop-app/assets/icon.ico
Binary file not shown.
Binary file added desktop-app/assets/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added desktop-app/assets/tray.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
76 changes: 76 additions & 0 deletions desktop-app/native/tray.swift
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading