Skip to content
Merged
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
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,48 @@ jobs:
- run: npm ci
- run: npm run check # ESLint + Vitest
- run: npm run build # ensure it bundles

# End-to-end tests drive the app's OWN Electron (Playwright `_electron`) under a
# virtual display — no bundled browser, no network, same as the Docker test env.
# 22.04 keeps the pre-t64 apt package names the app's Electron links against.
#
# E2E is the behavioural source of truth: it runs as the merge gate on pull
# requests to main only (not on post-merge pushes — main is reached through a
# PR). Make it a required status check in the branch-protection rule for main
# so a red run blocks the merge.
e2e:
if: github.event_name == 'pull_request'
runs-on: ubuntu-22.04
env:
# Never let Playwright pull Chromium/Firefox/WebKit — the suite uses none.
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
# Container/CI switch: --no-sandbox + software rendering (see security.js).
DIFFBRO_DOCKER: '1'
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- run: npm install -g npm@11
- run: npm ci
# Electron's runtime libraries + Xvfb (the same set the Docker test env installs).
- name: Install Electron runtime libraries
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 libatspi2.0-0 \
libdrm2 libgbm1 libasound2 libx11-xcb1 libxcb-dri3-0 libsecret-1-0 xvfb
- name: Run end-to-end tests under Xvfb
run: xvfb-run -a npm run test:e2e # electron-vite build && playwright test
# Report + per-failure traces (with screenshots) for inspection after the run.
- name: Upload Playwright report and traces
if: always()
uses: actions/upload-artifact@v6
with:
name: e2e-report
path: |
playwright-report/
test-results/
if-no-files-found: ignore
retention-days: 14
17 changes: 15 additions & 2 deletions DEVELOPMENT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ electron-builder.
- [x] Vue 3 + Pinia wired up
- [x] Monaco diff editor with worker configuration for Vite
- [x] Secure IPC: contextIsolation on, nodeIntegration off, file access in main
- [x] Adapter registry with text adapter (language detection by extension)
- [x] Adapter registry with text adapter (language detection by extension,
falling back to content sniffing for extensionless files and pasted text
so paste-mode diffs and files like `Dockerfile` still get syntax coloring)
- [x] electron-builder config (NSIS for Windows, DMG for macOS)

First run: `npm install && npm run dev`
Expand Down Expand Up @@ -46,7 +48,14 @@ First run: `npm install && npm run dev`
- [x] App menu: Open Left (Ctrl+1) / Open Right (Ctrl+2) / Swap (Ctrl+Shift+S)
/ Clear (Ctrl+K) / Paste mode (Ctrl+T) / Toggle split (Ctrl+\)
- [x] Diff stats in toolbar (n additions, m deletions) via
`editor.getLineChanges()` on `onDidUpdateDiff`
`editor.getLineChanges()` on `onDidUpdateDiff`; two loaded sides with no
changes surface an explicit "No differences" state instead of a bare +0/−0
- [x] Copy diff as a git-style unified patch (toolbar / Edit menu /
Ctrl+Shift+C): a pure line-level LCS in `utils/unifiedDiff.js` (guarded for
size), copied via the main-process clipboard (`window.api.copyText`)
- [x] Live re-diff on focus follows the two comparison sides *and* a partial-paste
loaded file (any slot with a real path), coalescing multiple changes into a
single "reloaded" notice

## Phase 2 – Polish (~2–3 days)

Expand Down Expand Up @@ -169,6 +178,10 @@ First run: `npm install && npm run dev`
- [x] Diff search gains match-case, whole-word, and safety-limited regex
(`utils/searchRegex.js` refuses over-long / catastrophic patterns)
- [x] Partial paste mode: diff pasted text against a dropped/chosen file
- [x] Unsaved-work guards: replacing an active comparison (drop new files, or
open one into a loaded slot) confirms first, unless it's already saved
(`diffStore.diffSaved`); the snippet editor confirms before Cancel/×
discards a dirty draft (`useSnippetDraft` — unit + e2e tested)
- [x] Tools menu grouped per format (Base64 / JSON / XML / SQL / Text
Encryption); Help → Keyboard Shortcuts lists bindings for the host OS
- [x] Help → Report an Issue confirms before leaving the offline sandbox, then
Expand Down
151 changes: 61 additions & 90 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,93 +4,42 @@

# Diff Bro

An **offline-only** desktop diff viewer / sharing app for Windows and macOS — GitHub-style
rendering, serious about privacy. Electron + Vue 3 + Pinia + Monaco.

It never makes a network request: files stay on your machine, enforced by a
session-level kill-switch, a strict CSP, and a sandboxed renderer. See
[docs/security.md](docs/security.md).
**An offline-only desktop diff viewer for Windows and macOS.** GitHub-style
side-by-side comparison, syntax highlighting, and encrypted local history — with
a hard promise: it never touches the network.

<p align="center">
<img src="docs/screenshots/diff-dark.png" width="820" alt="Diff Bro comparing two JSON files side by side, with word-level highlights and add/remove counts">
</p>

## Download

Grab the latest installer for your OS from the
[**latest release**](https://github.com/mindaugaskasp/diff-bro/releases/latest)
— no account, no telemetry, no auto-update:

| OS | Download |
| --- | --- |
| **Windows** (10/11) | `diff-bro-Setup-v<version>.exe` |
| **macOS** (Apple silicon, 12+) | `diff-bro-v<version>.dmg` |

Installer filenames carry the version, so a download stays identifiable once
it's off the release page.

**macOS via Homebrew** (updates with `brew upgrade`):

```bash
brew tap mindaugaskasp/tap
brew install --cask diff-bro
xattr -dr com.apple.quarantine "/Applications/Diff Bro.app"
```

The third line clears the Gatekeeper quarantine described below (the build
isn't notarized yet); Homebrew 6 removed the `--no-quarantine` flag that used
to skip it. The cask is refreshed automatically on every release.

Builds are **unsigned** for now (no Apple/Microsoft cert yet):

- **Windows** — SmartScreen warns; click **More info → Run anyway**.
- **macOS** — Gatekeeper says *"Diff Bro is damaged and can't be opened."* It
isn't damaged — that's just how recent macOS reports an unsigned,
un-notarized app that was quarantined on download. Drag it to **Applications**,
then run once in Terminal:

```bash
xattr -dr com.apple.quarantine "/Applications/Diff Bro.app"
```
## Why Diff Bro

and open it normally. More detail in [docs/packaging.md](docs/packaging.md).
- **Truly offline.** No account, no telemetry, no auto-update, no CDN. The app
makes *zero* network requests — enforced by a session-level kill switch, a
strict CSP, and a sandboxed renderer, not just a promise. Your files never
leave your machine.
- **Private by default.** Anything you keep — saved diffs, snippets, keys — is
encrypted on-device (AES-256-GCM), with the key held by your OS keychain.
Saved diffs auto-expire.
- **Fast and familiar.** The Monaco editor that powers VS Code, with GitHub-style
rendering you already know.

## Features

- **Diff** two files or pasted text — split/inline, word-level highlights,
syntax highlighting, live re-diff when a file changes on disk, and an in-view
search with match-case, whole-word, and safety-limited regex, match count and
jump-to-match.
- **Paste mode** compares two pasted snippets, or mixes the two — paste one
side and drop/choose a real file on the other (partial paste).
- **Drag & drop** files onto the window (two at once builds the diff; a third
starts over). Fixed, single window; five themes (Light, Dark, Solar, Neon,
Contrast) picked in Settings → Appearance; clamped zoom.
- **Saved diffs** — AES-256-GCM encrypted at rest, auto-expiring (≤ 24 h),
organized into drag-reorderable categories, favoritable. Categories are a
local organizing tool and never travel with a shared diff.
- **Rearrangeable sidebar** — reorder the Saved / External / Snippets sections
to taste; the order (and other preferences) persist in a plaintext
`settings.json`.
- **Share** a saved diff as a sealed, signed `.diffbro` file for one recipient;
manage named trusted keys under the **Security** menu.
- **Snippets** — an encrypted, tagged, non-expiring text library with
per-snippet syntax (JSON / SQL / Markdown / YAML / Python / Bash / PHP / …),
filter + copy, and passphrase-protected export/import. **Mermaid** snippets
render to a diagram — a live preview while editing plus a resizable
zoom/pan viewer, themed to match the app, all offline.
- **Tools** — grouped by format (Base64, and JSON / XML / SQL format+validate,
Monaco-highlighted, with "Add to Snippets"), plus a passphrase text
Encrypt/Decrypt. **Help → Keyboard Shortcuts** lists every binding for your OS.
- **Settings** — split into Appearance / Storage / Limits panes; backed by a
plaintext `settings.json` (theme, data folder, shortcut-bar visibility, and
user-raisable comparison-file / snippet size limits with safe defaults).
- **Config backup/restore** — one passphrase-encrypted file for your keys,
trusted hosts, snippets and settings (not diffs).

JSON/XML content shows an inline "pretty-print it?" banner before you diff.

## Screenshots
- **Diff** two files or pasted text — split or inline, word-level highlights,
syntax highlighting, in-view search, and a live re-diff when a file changes on
disk. Copy the result as a git-style unified patch.
- **Paste mode** for quick throwaway comparisons, including pasted text against a
real file.
- **Drag & drop** files onto the window; it warns before discarding unsaved work.
- **Saved diffs** — encrypted, auto-expiring, organized into categories.
- **Share** a diff as a sealed, signed file only its intended recipient can open.
- **Snippets** — an encrypted, tagged text library with per-language
highlighting and live **Mermaid** diagram rendering.
- **Tools** — Base64, JSON / XML / SQL format + validate, and passphrase text
encryption.
- **Yours to arrange** — five themes, a rearrangeable sidebar, and adjustable
limits, all remembered between sessions.

<table>
<tr>
Expand All @@ -105,27 +54,49 @@ JSON/XML content shows an inline "pretty-print it?" banner before you diff.
</tr>
</table>

## Quick start
## Download

Grab the latest installer from the
[**latest release**](https://github.com/mindaugaskasp/diff-bro/releases/latest):

| OS | Download |
| --- | --- |
| **Windows** (10/11) | `diff-bro-Setup-v<version>.exe` |
| **macOS** (Apple silicon, 12+) | `diff-bro-v<version>.dmg` |

**macOS via Homebrew:**

```bash
npm install
npm run dev
npm run check # ESLint + Vitest — run before every change lands
brew tap mindaugaskasp/tap
brew install --cask diff-bro
xattr -dr com.apple.quarantine "/Applications/Diff Bro.app"
```

No local Node? The same flow runs in Docker: `make dev` (app via noVNC at
<http://localhost:6080/vnc.html>) and `make check`. See
[docker/README.md](docker/README.md) and `make help`.
Builds are currently **unsigned**, so Windows SmartScreen and macOS Gatekeeper
will warn on first launch (the `xattr` line above clears it on macOS). Full
details in [docs/packaging.md](docs/packaging.md).

## Tech

Electron · Vue 3 · Pinia · Monaco. Text first, with an adapter registry for
richer formats to come. Security and offline guarantees are non-negotiable — see
the [security model](docs/security.md).

## Build from source

```bash
npm install
npm run dev # run locally
npm run check # lint + tests
```

End-to-end tests (`make e2e`) drive the built app through Playwright's Electron
integration in the Docker display env — launch smoke, Settings panes, theme
persistence, snippet copy. No bundled browser and no network, in keeping with
the offline guarantee.
No local Node? The same flow runs in Docker: `make dev`, `make check`,
`make e2e`. See [docker/README.md](docker/README.md) and `make help`.

## Docs

- [Architecture](docs/architecture.md) — processes, trust boundary, directory map.
- [Security model](docs/security.md) — offline guarantee, sharing, keys, backup.
- [Packaging & releasing](docs/packaging.md) — installers, signing notes, CI.
- Coding standards and hard rules live in [CLAUDE.md](CLAUDE.md); roadmap in
- Coding standards live in [CLAUDE.md](CLAUDE.md); roadmap in
[DEVELOPMENT_PLAN.md](DEVELOPMENT_PLAN.md).
55 changes: 55 additions & 0 deletions e2e/config-backup.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { test, expect, openSettings, openMenu, stubSaveDialog, stubOpenDialog } from './fixtures.mjs'

// Config backup seals identity keys, trusted hosts, snippets and settings into
// one passphrase-encrypted file; restore applies it back. The crypto is
// unit-tested — this proves the menu → dialog → file round-trip, using a known
// setting (theme) as the observable that survives the trip.
test('backing up then restoring recovers the saved settings', async ({ app, page }) => {
const backupPath = join(tmpdir(), `diffbro-e2e-cfg-${Date.now()}.diffbroconf`)

// A distinctive theme to carry through the backup.
await openSettings(page)
await page.getByTitle('Use the Neon theme').click()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'neon')
await page.keyboard.press('Escape')

// Back up (save dialog stubbed to a temp file).
await stubSaveDialog(app, backupPath)
await openMenu(page, 'Security', 'Configuration', 'Back Up')
const backup = page.getByRole('dialog', { name: 'Back up configuration' })
await backup.getByLabel('Passphrase').fill('backup-pass-123')
await backup.getByRole('button', { name: 'Back up' }).click()
await expect(page.getByText(/backed up/i)).toBeVisible()

// Change the theme away from the backed-up value.
await openMenu(page, 'View', 'Toggle Light/Dark Theme') // neon is dark-ground → light
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')

// Restore brings it back.
await stubOpenDialog(app, backupPath)
await openMenu(page, 'Security', 'Configuration', 'Restore')
const restore = page.getByRole('dialog', { name: 'Restore configuration' })
await restore.getByLabel('Passphrase').fill('backup-pass-123')
await restore.getByRole('button', { name: 'Restore' }).click()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'neon')
})

test('restore rejects a wrong passphrase', async ({ app, page }) => {
const backupPath = join(tmpdir(), `diffbro-e2e-cfg-wrong-${Date.now()}.diffbroconf`)

await stubSaveDialog(app, backupPath)
await openMenu(page, 'Security', 'Configuration', 'Back Up')
const backup = page.getByRole('dialog', { name: 'Back up configuration' })
await backup.getByLabel('Passphrase').fill('the-right-one')
await backup.getByRole('button', { name: 'Back up' }).click()
await expect(page.getByText(/backed up/i)).toBeVisible()

await stubOpenDialog(app, backupPath)
await openMenu(page, 'Security', 'Configuration', 'Restore')
const restore = page.getByRole('dialog', { name: 'Restore configuration' })
await restore.getByLabel('Passphrase').fill('the-wrong-one')
await restore.getByRole('button', { name: 'Restore' }).click()
await expect(page.getByText(/Wrong passphrase/i)).toBeVisible()
})
Loading
Loading