diff --git a/.gitattributes b/.gitattributes
index 565ca9d..b00732c 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,2 +1,4 @@
# Empty context lines inside the embedded Git patches intentionally contain a space.
src/demo.rs whitespace=-blank-at-eol
+# The signature covers these exact bytes, including line endings.
+tests/fixtures/update/payload.txt -text
diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml
new file mode 100644
index 0000000..9ca2a92
--- /dev/null
+++ b/.github/workflows/desktop.yml
@@ -0,0 +1,83 @@
+name: Windows and Linux alpha checks
+on:
+ push:
+ branches: [master]
+ paths-ignore: ['site/**', '**/*.md', 'assets/screenshot.png']
+ pull_request:
+ paths-ignore: ['site/**', '**/*.md', 'assets/screenshot.png']
+ workflow_dispatch:
+permissions:
+ contents: read
+concurrency:
+ group: desktop-${{ github.ref }}
+ cancel-in-progress: true
+jobs:
+ check:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: ubuntu-24.04
+ platform: linux
+ target: x86_64-unknown-linux-gnu
+ - os: windows-latest
+ platform: windows
+ target: x86_64-pc-windows-msvc
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 90
+ env:
+ CI: 'true'
+ APPIMAGE_EXTRACT_AND_RUN: '1'
+ steps:
+ - name: Use LF checkouts and predictable Git fixtures
+ run: git config --global core.autocrlf false
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: rustfmt, clippy
+ - uses: Swatinem/rust-cache@v2
+ with:
+ cache-on-failure: true
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Install Linux build and graphics dependencies
+ if: runner.os == 'Linux'
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y pkg-config gcc g++ clang cmake libfontconfig-dev libwayland-dev libwebkit2gtk-4.1-dev libxkbcommon-x11-dev libx11-xcb-dev libssl-dev libzstd-dev libvulkan1 mesa-vulkan-drivers xvfb xdg-utils libfuse2t64
+ - name: Check source
+ run: cargo fmt --check
+ - name: Functional tests
+ run: cargo test --locked
+ - name: Clippy
+ run: cargo clippy --locked --all-targets --features ui-tests -- -D warnings
+ - name: Linux renderer and interaction tests
+ if: runner.os == 'Linux'
+ run: xvfb-run -a cargo test --locked --features ui-tests
+ - name: Build native executable
+ run: cargo build --locked --bins
+ - name: Linux native startup
+ if: runner.os == 'Linux'
+ run: xvfb-run -a python scripts/smoke-desktop.py target/debug/diffy
+ - name: Windows native startup
+ if: runner.os == 'Windows'
+ run: python scripts/smoke-desktop.py target/debug/diffy.exe
+ - name: Install pinned packager
+ run: cargo install cargo-packager --version 0.11.8 --locked --root target/tools
+ - name: Package alpha
+ run: python scripts/release-desktop.py --platform ${{ matrix.platform }} --unsigned
+ - name: Validate packaged installation
+ if: runner.os == 'Windows'
+ run: python scripts/check-desktop-package.py --platform windows
+ - name: Validate Linux packaged installation and signed update
+ if: runner.os == 'Linux'
+ run: xvfb-run -a python scripts/check-desktop-package.py --platform linux
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: Diffy-${{ matrix.platform }}-x86_64-alpha-checks
+ path: |
+ dist/unsigned/${{ matrix.platform }}/
+ target/previews/
+ if-no-files-found: warn
diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml
index 96262fe..d3f5e37 100644
--- a/.github/workflows/site.yml
+++ b/.github/workflows/site.yml
@@ -28,6 +28,11 @@ jobs:
- run: npm run build
env:
DODO_CHECKOUT_URL: ${{ matrix.checkout }}
+ DIFFY_RELEASED_PLATFORMS: ${{ matrix.checkout == '' && 'macos' || 'macos,windows,linux' }}
+ - run: node scripts/check-build.mjs
+ env:
+ DODO_CHECKOUT_URL: ${{ matrix.checkout }}
+ DIFFY_RELEASED_PLATFORMS: ${{ matrix.checkout == '' && 'macos' || 'macos,windows,linux' }}
deploy:
name: Deploy production
needs: build
@@ -61,6 +66,7 @@ jobs:
- run: npm run build
env:
DODO_CHECKOUT_URL: ${{ vars.DODO_CHECKOUT_URL }}
+ DIFFY_RELEASED_PLATFORMS: ${{ vars.DIFFY_RELEASED_PLATFORMS || 'macos' }}
- name: Upload production site
uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4
with:
diff --git a/Cargo.lock b/Cargo.lock
index b5604f5..1e93edc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1521,13 +1521,16 @@ dependencies = [
[[package]]
name = "diffy"
-version = "0.2.3"
+version = "0.2.4"
dependencies = [
"agent-sessions",
"async-channel",
"base64 0.22.1",
"cargo-packager-updater",
"criterion",
+ "directories",
+ "embed-resource",
+ "fs2",
"gix",
"gpui-kit",
"ignore",
@@ -1544,10 +1547,13 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.10.9",
+ "tempfile",
"tracing",
"tracing-chrome",
"tracing-subscriber",
"url",
+ "which",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1572,6 +1578,15 @@ dependencies = [
"crypto-common 0.2.2",
]
+[[package]]
+name = "directories"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
+dependencies = [
+ "dirs-sys 0.5.0",
+]
+
[[package]]
name = "dirs"
version = "5.0.1"
@@ -1713,9 +1728,9 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "embed-resource"
-version = "3.0.12"
+version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f"
+checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd"
dependencies = [
"cc",
"memchr",
@@ -2119,6 +2134,16 @@ dependencies = [
"pkg-config",
]
+[[package]]
+name = "fs2"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
[[package]]
name = "fsevent-sys"
version = "4.1.0"
@@ -9781,6 +9806,15 @@ dependencies = [
"windows-targets 0.52.6",
]
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "windows-sys"
version = "0.60.2"
@@ -10032,12 +10066,12 @@ dependencies = [
[[package]]
name = "winreg"
-version = "0.56.0"
+version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
+checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
dependencies = [
"cfg-if",
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index deb6aff..81104c4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,9 +1,10 @@
[package]
name = "diffy"
-version = "0.2.3"
+version = "0.2.4"
edition = "2024"
description = "A small desktop Git diff viewer"
publish = false
+default-run = "diffy"
license = "MIT"
repository = "https://github.com/hitSlop/diffy"
@@ -21,6 +22,11 @@ ignore = "=0.4.33"
notify = "=8.2.0"
nucleo-matcher = "=0.3.1"
async-channel = "=2.5.0"
+directories = "=6.0.0"
+fs2 = "=0.4.3"
+interprocess = "=2.4.4"
+tempfile = "3.27"
+which = "8"
lru = "=0.18.5"
gix = { version = "=0.89.0", default-features = false, features = ["basic", "status", "sha1"] }
tracing = "0.1"
@@ -38,11 +44,22 @@ objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSString", "NSURL"] }
[target.'cfg(windows)'.dependencies]
-interprocess = "=2.4.4"
+windows-sys = { version = "=0.61.2", features = ["Win32_UI_WindowsAndMessaging"] }
[dev-dependencies]
criterion = { version = "0.8", default-features = false }
+[build-dependencies]
+embed-resource = "=3.0.11"
+
+[[bin]]
+name = "diffy"
+path = "src/main.rs"
+
+[[bin]]
+name = "diffy-cli"
+path = "src/terminal.rs"
+
[[bench]]
name = "performance"
harness = false
diff --git a/README.md b/README.md
index 4cf28a4..56839ed 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
# Diffy
-[Download the latest version for macOS](https://github.com/hitSlop/diffy/releases/latest) · Apple Silicon · macOS 13+
+[Downloads and installation](https://diffy.hitslop.com/downloads) · macOS **beta** · Windows/Linux **alpha**

-A small native Rust/GPUI Kit diff viewer for macOS.
+A small native Rust/GPUI Kit diff viewer. macOS is in beta; Windows and Linux are early alphas with limited hardware testing. One purchase supports every platform: **$14.99 during beta**, regular price ~~$24.99~~. Windows/Linux download links become available after release validation and publication.
- Pure-black surfaces and colored squircle project icons that grow gently on hover. Badges count unique changed files across Staged and Changes (partially staged files count once).
- Bundled Commit Mono Regular and Medium throughout the app, with programming ligatures disabled.
@@ -29,7 +29,7 @@ A small native Rust/GPUI Kit diff viewer for macOS.
## Run
-Requires Rust and Xcode Command Line Tools. Git must be on PATH for repository mode.
+Requires Rust 1.92+ and the native platform toolchain. macOS needs Xcode Command Line Tools; Windows needs Visual Studio 2022 C++ Build Tools, the Windows SDK, and CMake with the MSVC Rust toolchain; Linux needs the development libraries listed in RELEASING.md. Git must be installed for repository mode.
```sh
cargo run --locked
@@ -68,10 +68,12 @@ diffy --version
`diffy .` adds or selects the repository in the existing Diffy window and brings it forward. A closed window is recreated; a stopped app is launched. Relative paths are resolved in your terminal, and nested folders resolve to the repository root. `diffy` without paths opens your saved projects. Reinstall the command if you move `Diffy.app`; normal updates at the same location keep it working.
-CLI installation is currently macOS-only. Development binaries still accept repository paths directly through `cargo run`.
+CLI installation supports all three desktop targets. Linux installs `~/.local/bin/diffy`, pointing to the outer AppImage when packaged. Windows copies a managed console launcher to its local app-data directory and shows instructions for adding the directory to your user Path. The Windows installer includes `diffy-cli.exe` beside the GUI executable. Development binaries accept repository paths directly through `cargo run`. Windows/Linux invocations forward requests to the existing app through a bounded per-user local socket; demo mode stays isolated.
## Keyboard shortcuts
+Use **Ctrl** instead of **⌘** on Windows and Linux. Menu and footer hints adapt to the platform.
+
| Shortcut | Action |
|---|---|
| ⌘P | Search projects and commands |
@@ -98,7 +100,7 @@ Unmerged files remain readable with their conflict markers. Resolve and stage th
Git writes affect the selected file's index entries only. Unstaging preserves working-tree contents, including on unborn branches. Rename actions include both paths; copy actions affect the destination only. Errors retain the last good preview.
-Preferences live at `~/Library/Application Support/Diffy/state.json` and contain paths and viewer settings, never patches or source contents. Imported project images live beside it in `project-icons/`, named by their SHA-256 contents so matching filenames cannot overwrite other icons. Back up the folder to keep both preferences and images. Existing image references remain readable; selecting an image again copies it into managed storage. Tests inject temporary storage; `DIFFY_STATE_PATH` can override the path for isolated development sessions and also relocates the icon folder.
+Preferences live at `~/Library/Application Support/Diffy/state.json` on macOS, `$XDG_CONFIG_HOME/diffy/state.json` (default `~/.config/diffy/state.json`) on Linux, and `%LOCALAPPDATA%/hitSlop/Diffy/config/state.json` on Windows and contain paths and viewer settings, never patches or source contents. Imported project images live beside it in `project-icons/`, named by their SHA-256 contents so matching filenames cannot overwrite other icons. Back up the folder to keep both preferences and images. Existing image references remain readable; selecting an image again copies it into managed storage. Tests inject temporary storage; `DIFFY_STATE_PATH` can override the path for isolated development sessions and also relocates the icon folder.
## Build a macOS app
@@ -138,11 +140,11 @@ Built with [GPUI Kit](https://gpui-kit.com/) and [Commit Mono](https://commitmon
## macOS release
-Diffy uses the bundle identifier `com.hitslop.diffy`. The first release targets Apple Silicon Macs running macOS 13 or later. Source code is MIT licensed; dependency and font licenses remain in effect.
+Diffy uses the bundle identifier `com.hitslop.diffy`. The macOS beta targets Apple Silicon Macs running macOS 13 or later. Windows x86-64 NSIS and Linux x86-64 AppImage alphas use the same release version. Linux initially targets Ubuntu 24.04+ with a working Vulkan driver; other distributions and ARM targets are not yet verified. Source code is MIT licensed; dependency and font licenses remain in effect.
Existing preferences are migrated from `~/Library/Application Support/Differ/state.json` only when no Diffy preferences exist. The original file is retained. `DIFFY_STATE_PATH` takes precedence over the legacy `DIFFER_STATE_PATH` override; explicit overrides skip migration.
-Packaged builds check for updates quietly after startup. **Check for Updates…** is also available in the Diffy menu and ⌘P command palette. Updates download and verify only after clicking **Update and Restart**. Git writes finish before installation; projects and preferences survive the restart. Development binaries cannot install updates.
+Packaged builds check for updates quietly after startup. **Check for Updates…** is also available in the Diffy menu and ⌘P command palette. Updates download and verify only after clicking **Update and Restart**. Git writes finish before installation; projects and preferences survive the restart. Development binaries cannot install updates. Windows installs verified NSIS updates and relaunches; Linux replaces and relaunches the outer AppImage. Windows publisher signing is not configured for the alpha, separately from updater payload signatures. Updates require a writable installation location.
See [RELEASING.md](RELEASING.md) for packaging, local signing, key backups, draft releases, and update verification.
@@ -155,3 +157,7 @@ DIFFY_TRACE_PATH="$PWD/target/diffy-trace.json" cargo run --locked -- /path/to/r
```
The benchmark compares eager loading, Git metadata refresh, selected-file parsing, cached navigation, and the complete gix metadata adapter on disposable repositories. `DIFFY_BENCH_ONLY=metadata` (or `eager`, `selected_preview`, `cached_navigation`, `gix_metadata`, `gix_status_prototype`) isolates a case for memory measurements. The gix adapter benchmark fails if it falls back to Git; the earlier filename-only prototype remains a separate comparison. Traces are opt in, stay local, and contain timing spans without paths or source contents. Open them in Perfetto. gix is a runtime dependency; Criterion remains development-only.
+
+## Windows and Linux packages
+
+Native build and package checks run in `.github/workflows/desktop.yml`. See [RELEASING.md](RELEASING.md) for NSIS/AppImage packaging, updater signatures, release assembly, and validation. `directories` supplies native settings locations; `interprocess` and an OS file lock coordinate terminal requests. Windows/Linux are alpha until broader hardware testing is available.
diff --git a/RELEASING.md b/RELEASING.md
index 76664fa..9fbceb2 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -1,6 +1,6 @@
# Releasing Diffy
-The initial release is macOS 13+, Apple Silicon only. App identity: `com.hitslop.diffy`; repository: https://github.com/hitSlop/diffy. The workspace can remain named `differ`.
+The macOS beta supports macOS 13+ on Apple Silicon. Windows x86-64 and Linux x86-64 are early alphas with limited hardware testing. All platforms share the same numeric version and signing trust; Beta/Alpha are maturity labels, not GitHub prerelease flags. App identity: `com.hitslop.diffy`; repository: https://github.com/hitSlop/diffy. The workspace can remain named `differ`.
## Build and validate
@@ -82,3 +82,44 @@ cargo test --locked --test update_archive signed_app_archive_installs_and_relaun
Production checks and downloads require HTTPS. This offline fixture operates exclusively on temporary app copies, verifies the signed archive before installation, and confirms the installed executable launches. It never replaces the user's app or edits their preferences.
The signed-archive exercise is ignored in routine checks and requires its archive and signature when invoked explicitly. The release script invokes it with the exact release version. To exercise a historical signed archive, set `DIFFY_TEST_UPDATE_VERSION` to its expected version; otherwise the current Cargo version is required.
+
+## Windows and Linux native builds
+
+Use Rust 1.92+ and the native toolchain. Windows requires Visual Studio 2022 C++ Build Tools, a Windows SDK, CMake, and the MSVC Rust target. The build script embeds the supplied icon; GPUI supplies the Windows manifest. Windows installers are per-user NSIS packages and are not publisher-signed for the alpha. Updater payload signatures remain required.
+
+On Ubuntu 24.04, install the GPUI toolchain and runtime dependencies:
+
+```sh
+sudo apt-get install -y pkg-config gcc g++ clang cmake libfontconfig-dev libwayland-dev libwebkit2gtk-4.1-dev libxkbcommon-x11-dev libx11-xcb-dev libssl-dev libzstd-dev libvulkan1 mesa-vulkan-drivers xvfb xdg-utils libfuse2t64
+cargo install cargo-packager --version 0.11.8 --locked --root target/tools
+```
+
+Run the corresponding command on each native operating system (Windows uses `python`):
+
+```sh
+python scripts/release-desktop.py --platform windows --unsigned
+python3 scripts/release-desktop.py --platform linux --unsigned
+```
+
+The CI workflow runs native functional tests and Clippy, Linux offscreen renderer/interaction tests, native startup smoke tests, package installation/reinstallation checks, and CLI Git access. The pinned GPUI snapshot has no Windows headless screenshot renderer; the Windows native startup smoke opens a real demo window and draws a frame. This does not replace hardware QA. CI installer checks run only on disposable runners. Windows-safe Unicode/spaced filenames replace Unix-only filename fixtures on Windows.
+
+Unsigned-check artifacts are under `dist/unsigned/windows` and `dist/unsigned/linux`. Windows outputs the versioned NSIS installer plus `Diffy-windows-x86_64-setup.exe`; Linux outputs the versioned AppImage plus `Diffy-linux-x86_64.AppImage`. Each directory includes source-tree/version/checksum metadata. Terminal launchers and dependency notices are packaged alongside the app.
+
+## Assemble a complete release
+
+Build all three platforms from the same clean source tree and version. Download the successful Windows/Linux CI package artifacts, retaining their `build-*.json` files. Build the signed/notarized macOS release locally with the existing script, then assemble:
+
+```sh
+python3 scripts/release-macos.py
+python3 scripts/assemble-release.py --macos dist/release --windows /path/to/windows-artifact --linux /path/to/linux-artifact
+```
+
+Assembly rejects version, target, source-tree, or package-digest mismatches. It preserves the notarized macOS assets, signs the exact Windows installer and raw Linux AppImage with the existing updater key, writes per-platform manifests and one combined `SHA256SUMS`. It does not publish anything. Standalone native releases can omit `--unsigned` when the same signing key/public-key file is available; `DIFFY_UPDATER_KEY` and `DIFFY_UPDATER_PASSWORD` select it without committing secrets.
+
+The current Linux updater installs raw AppImage bytes: do not point its manifest at a `.tar.gz`. AppImage restart and terminal installation use the outer `APPIMAGE` path, never the executable inside its temporary mount. Windows waits for Git writes and flushes preferences before the updater starts its installer and exits; `/R` makes the verified NSIS installer relaunch Diffy. Windows installer failures are handled by the installer, rather than promising the same bundle rollback as macOS.
+
+Create a normal **draft** GitHub release for the next version after the complete source and package checks pass. Include every asset named by the combined checksums file and that file itself. Keep the existing `update-macos-aarch64.json` endpoint and fixed macOS DMG alias. New assets include `update-windows-x86_64.json`, `update-linux-x86_64.json`, and their signed packages. Ordinary published releases preserve `/releases/latest`; draft releases remain invisible to update checks.
+
+Only after publication and download verification, set the site's `DIFFY_RELEASED_PLATFORMS` repository variable to `macos,windows,linux` and rebuild/deploy. Default `macos` keeps the new links unavailable. See `site/README.md` for the $14.99 Dodo product and shared post-purchase delivery setup.
+
+Before promoting Windows/Linux from alpha, collect real hardware checks for rendering, clipboard, dialogs, held-key navigation, editor/terminal launch, window controls, and installation/update/restart. Intel Mac, ARM Windows/Linux, `.deb`, and broader Linux compatibility remain outside this release.
diff --git a/build.rs b/build.rs
new file mode 100644
index 0000000..3b07fa6
--- /dev/null
+++ b/build.rs
@@ -0,0 +1,55 @@
+use std::{fs, path::PathBuf};
+fn main() {
+ println!("cargo:rerun-if-env-changed=DIFFY_PACKAGE_KIND");
+ println!(
+ "cargo:rustc-env=DIFFY_PACKAGE_KIND={}",
+ std::env::var("DIFFY_PACKAGE_KIND").unwrap_or_default()
+ );
+ println!("cargo:rerun-if-changed=build.rs");
+ for size in [16, 32, 128, 256] {
+ println!("cargo:rerun-if-changed=assets/icon/icon.iconset/icon_{size}x{size}.png");
+ }
+ if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
+ return;
+ }
+ if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
+ // GPUI startup overflows MSVC's default stack in debug builds. Zed
+ // uses the same 8 MiB reserve for its Windows GPUI executable.
+ println!("cargo:rustc-link-arg=/STACK:8388608");
+ }
+ let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
+ let sizes = [16u32, 32, 128, 256];
+ let images: Vec<_> = sizes
+ .iter()
+ .map(|size| fs::read(format!("assets/icon/icon.iconset/icon_{size}x{size}.png")).unwrap())
+ .collect();
+ let mut icon = Vec::new();
+ icon.extend_from_slice(&[0, 0, 1, 0, 4, 0]);
+ let mut offset = 6 + sizes.len() as u32 * 16;
+ for (size, image) in sizes.iter().zip(&images) {
+ icon.extend_from_slice(&[*size as u8, *size as u8, 0, 0]);
+ icon.extend_from_slice(&1u16.to_le_bytes());
+ icon.extend_from_slice(&32u16.to_le_bytes());
+ icon.extend_from_slice(&(image.len() as u32).to_le_bytes());
+ icon.extend_from_slice(&offset.to_le_bytes());
+ offset += image.len() as u32;
+ }
+ for image in images {
+ icon.extend_from_slice(&image);
+ }
+ let icon_path = out.join("Diffy.ico");
+ fs::write(&icon_path, icon).unwrap();
+ let rc = out.join("diffy.rc");
+ fs::write(
+ &rc,
+ format!(
+ "1 ICON \"{}\"\n",
+ icon_path.display().to_string().replace('\\', "/")
+ ),
+ )
+ .unwrap();
+ // GPUI supplies the platform manifest; this resource adds only our icon.
+ embed_resource::compile_for(rc, ["diffy"], embed_resource::NONE)
+ .manifest_required()
+ .unwrap();
+}
diff --git a/packaging/release-notes.md b/packaging/release-notes.md
index e550fa0..87e1991 100644
--- a/packaging/release-notes.md
+++ b/packaging/release-notes.md
@@ -1,13 +1,8 @@
-Diffy 0.2.3 brings Git worktrees into one project and adds a little something while you wait for changes.
+Diffy macOS beta, Windows/Linux alpha
-- Switch between the main checkout and linked worktrees using tabs across the top or ⌘← / ⌘→. Projects with one checkout keep the same header layout, with Staged/Changes in the row below.
-- Keep each checkout's selected file, staged/unstaged view, diff mode, and reading position. Project navigation remembers the last checkout you reviewed.
-- Discover new and removed worktrees automatically. Show detached checkouts explicitly, keep long tab lists accessible through the overflow menu, and fall back to an available checkout when one disappears.
-- Group linked checkouts into one project icon with a combined change count. Project appearance is shared, while staging and external-app actions target the selected checkout.
-- Drag the window from the project header and double-click its empty area for the macOS title-bar action.
-- Play Snake in an empty diff pane: Enter starts or pauses, and arrow keys or WASD steer.
-- Update the website with worktrees, project customization, review controls, and the waiting game.
+- Add Windows x86-64 per-user installers and Linux x86-64 AppImages with signed automatic updates.
+- Use native settings directories and forward terminal requests to the running Windows/Linux app.
+- Add platform-aware file-manager, terminal/editor launch, shortcuts, and window controls.
+- Offer one purchase for every platform: $14.99 during beta, regular price $24.99.
-Existing saved projects migrate automatically. Git remains the default status client and handles previews and staging; worktree discovery uses gix with a Git fallback. SSH review and full gix replacement remain development studies.
-
-Install by dragging Diffy to Applications. Requires Git, Apple Silicon, and macOS 13 or later. Windows and Intel Mac builds are deferred.
+Windows and Linux are early alphas with limited hardware testing. Windows installers have no publisher signature. Linux initially targets Ubuntu 24.04+ with Wayland or X11 and a working Vulkan driver. Git is required on every platform.
diff --git a/scripts/assemble-release.py b/scripts/assemble-release.py
new file mode 100644
index 0000000..1b68ee2
--- /dev/null
+++ b/scripts/assemble-release.py
@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+"""Combine validated native packages and sign Windows/Linux updater payloads locally."""
+import argparse
+import datetime
+import hashlib
+import json
+import os
+import shutil
+import subprocess
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+PLATFORMS = {
+ "macos-aarch64": ("aarch64-apple-darwin", "app", "Diffy-macos-aarch64.dmg"),
+ "windows-x86_64": ("x86_64-pc-windows-msvc", "nsis", "Diffy-windows-x86_64-setup.exe"),
+ "linux-x86_64": ("x86_64-unknown-linux-gnu", "appimage", "Diffy-linux-x86_64.AppImage"),
+}
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ for name in ("macos", "windows", "linux"):
+ parser.add_argument(f"--{name}", type=Path, required=True)
+ args = parser.parse_args()
+ version = json.loads(subprocess.check_output(["cargo", "metadata", "--locked", "--no-deps", "--format-version", "1"], cwd=ROOT))["packages"][0]["version"]
+ tree = subprocess.check_output(["git", "rev-parse", "HEAD^{tree}"], cwd=ROOT, text=True).strip()
+ for check in (["git", "diff", "--quiet"], ["git", "diff", "--cached", "--quiet"]):
+ subprocess.run(check, cwd=ROOT, check=True)
+ out = ROOT / "dist/release"
+ out.mkdir(parents=True, exist_ok=True)
+ env = os.environ.copy()
+ key = ROOT / ".release-secrets/updater.key"
+ if Path(str(key) + ".pub").read_text().strip() != (ROOT / "packaging/updater.pub").read_text().strip():
+ raise RuntimeError("The signing key doesn't match Diffy's public key.")
+ env["CARGO_PACKAGER_SIGN_PRIVATE_KEY"] = str(key)
+ env["CARGO_PACKAGER_SIGN_PRIVATE_KEY_PASSWORD"] = (ROOT / ".release-secrets/updater.password").read_text().strip()
+ packager = ROOT / "target/tools/bin/cargo-packager"
+ assets = []
+ for platform, (target, format_name, stable_name) in PLATFORMS.items():
+ directory = getattr(args, platform.split('-')[0]).resolve()
+ info_path = directory / f"build-{platform}.json"
+ info = json.loads(info_path.read_text())
+ if info["version"] != version or info["target"] != target or info["source_tree"] != tree:
+ raise RuntimeError(f"{platform} wasn't built from this version and source tree.")
+ package_name = info["package"]
+ if Path(package_name).name != package_name:
+ raise RuntimeError("Invalid package filename in build metadata.")
+ package = directory / package_name
+ if hashlib.sha256(package.read_bytes()).hexdigest() != info["sha256"]:
+ raise RuntimeError(f"{platform} package checksum mismatch.")
+ if platform != "macos-aarch64" and package.read_bytes() != (directory / stable_name).read_bytes():
+ raise RuntimeError("Stable download must be byte-identical to its versioned package.")
+ for source in [package, directory / stable_name, info_path]:
+ destination = out / source.name
+ if source.resolve() != destination.resolve(): shutil.copyfile(source, destination)
+ assets.append(destination)
+ installed = out / package_name
+ if platform == "macos-aarch64":
+ for source in [Path(str(package) + ".sig"), directory / f"update-{platform}.json"]:
+ destination = out / source.name
+ if source.resolve() != destination.resolve(): shutil.copyfile(source, destination)
+ assets.append(destination)
+ dmg_name = f"Diffy_{version}_aarch64.dmg"
+ dmg = directory / dmg_name
+ if dmg.read_bytes() != (directory / stable_name).read_bytes(): raise RuntimeError("macOS DMG alias mismatch")
+ if dmg.resolve() != (out / dmg_name).resolve(): shutil.copyfile(dmg, out / dmg_name)
+ assets.append(out / dmg_name)
+ else:
+ subprocess.run([str(packager), "signer", "sign", str(installed)], env=env, cwd=ROOT, check=True)
+ signature = Path(str(installed) + ".sig")
+ manifest = out / f"update-{platform}.json"
+ manifest.write_text(json.dumps({"version": version, "platforms": {platform: {
+ "url": f"https://github.com/hitSlop/diffy/releases/download/v{version}/{package_name}",
+ "signature": signature.read_text().strip(), "format": format_name}},
+ "notes": f"Diffy {version}, {platform} alpha.",
+ "pub_date": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")}, indent=2) + "\n")
+ assets.extend([signature, manifest])
+ unique = sorted(set(assets))
+ (out / "SHA256SUMS").write_text("".join(f"{hashlib.sha256(asset.read_bytes()).hexdigest()} {asset.name}\n" for asset in unique))
+ print(f"Complete signed release assets ready in {out}. Publication is a separate step.")
+
+
+if __name__ == "__main__": main()
diff --git a/scripts/check-desktop-package.py b/scripts/check-desktop-package.py
new file mode 100644
index 0000000..36e9fd3
--- /dev/null
+++ b/scripts/check-desktop-package.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+"""Check the installable package on a disposable native CI machine."""
+import argparse
+import os
+import subprocess
+import tempfile
+import time
+from pathlib import Path
+
+parser = argparse.ArgumentParser(description=__doc__)
+parser.add_argument("--platform", choices=["windows", "linux"], required=True)
+args = parser.parse_args()
+root = Path(__file__).resolve().parent.parent
+package = root / "dist/unsigned" / args.platform / ("Diffy-windows-x86_64-setup.exe" if args.platform == "windows" else "Diffy-linux-x86_64.AppImage")
+if not os.environ.get("CI"):
+ raise RuntimeError("Run installer checks only on disposable CI machines.")
+with tempfile.TemporaryDirectory(prefix="diffy-package-") as directory:
+ directory = Path(directory)
+ repo = directory / "项目 with spaces"
+ repo.mkdir()
+ subprocess.run(["git", "init", "-q", str(repo)], check=True)
+ (repo / "new.txt").write_text("new\n")
+ state = directory / "preferences/state.json"
+ state.parent.mkdir()
+ state.write_text('{"version":1,"projects":[],"active":null}')
+ original = state.read_bytes()
+ env = {**os.environ, "DIFFY_STATE_PATH": str(state), "APPIMAGE_EXTRACT_AND_RUN": "1"}
+ if args.platform == "windows":
+ installation = directory / "installed"
+ subprocess.run([str(package), "/S", f"/D={installation}"], check=True, timeout=120)
+ executable = installation / "diffy.exe"
+ command = installation / "diffy-cli.exe"
+ if not executable.exists() or not command.exists():
+ raise RuntimeError("Installer omitted the app or terminal launcher.")
+ subprocess.run([str(command), "--version"], check=True, env=env)
+ result = subprocess.run([str(command), "--check", str(repo)], check=True, env=env, capture_output=True, text=True)
+ # Exercise a second installation over the first, preserving user data.
+ subprocess.run([str(package), "/S", f"/D={installation}"], check=True, timeout=120)
+ else:
+ executable = directory / "Diffy.AppImage"
+ executable.write_bytes(package.read_bytes())
+ executable.chmod(0o755)
+ subprocess.run([str(executable), "--version"], check=True, env=env)
+ result = subprocess.run([str(executable), "--check", str(repo)], check=True, env=env, capture_output=True, text=True)
+ if "0 staged, 1 changes" not in result.stdout:
+ raise RuntimeError(f"Packaged repository check failed: {result.stdout}")
+ if state.read_bytes() != original:
+ raise RuntimeError("Package installation or --check modified preferences.")
+ # Sign the actual package with a throwaway key. This exercises the same
+ # verifier and installer adapter without putting production signing material in CI.
+ key = directory / "fixture.key"
+ packager = root / "target/tools/bin" / ("cargo-packager.exe" if args.platform == "windows" else "cargo-packager")
+ subprocess.run([str(packager), "signer", "generate", "--ci", "--password", "ci-fixture", "--path", str(key)], check=True, capture_output=True)
+ sign_env = {**env, "CARGO_PACKAGER_SIGN_PRIVATE_KEY": str(key), "CARGO_PACKAGER_SIGN_PRIVATE_KEY_PASSWORD": "ci-fixture"}
+ subprocess.run([str(packager), "signer", "sign", str(package)], check=True, env=sign_env, capture_output=True)
+ signature = Path(str(package) + ".sig")
+ marker = directory / "updated-render.txt"
+ update_env = {**env, "DIFFY_CI_UPDATE_PACKAGE": str(package), "DIFFY_CI_UPDATE_SIGNATURE": str(signature),
+ "DIFFY_CI_UPDATE_PUBLIC": str(key) + ".pub", "DIFFY_CI_UPDATE_DESTINATION": str(installation if args.platform == "windows" else executable),
+ "DIFFY_SMOKE_RESULT": str(marker)}
+ if args.platform == "linux":
+ executable.write_bytes(b"old appimage fixture")
+ subprocess.run(["cargo", "test", "--locked", "--test", "update_desktop", "apply_signed_native_package", "--", "--ignored", "--exact"], cwd=root, env=update_env, check=True, timeout=120)
+ if args.platform == "linux":
+ subprocess.run([str(executable), "--smoke-test"], env=update_env, check=True, timeout=45)
+ deadline = time.monotonic() + 60
+ while not marker.exists() and time.monotonic() < deadline:
+ time.sleep(0.1)
+ if not marker.exists() or marker.read_text() != "rendered\n":
+ raise RuntimeError("The updated application did not relaunch and render.")
+ if state.read_bytes() != original:
+ raise RuntimeError("Update/restart changed preferences.")
+print("Installed package, CLI Git access, and preference preservation verified.")
diff --git a/scripts/collect-licenses.py b/scripts/collect-licenses.py
index de0ba7d..9867563 100644
--- a/scripts/collect-licenses.py
+++ b/scripts/collect-licenses.py
@@ -3,11 +3,12 @@
import json
import shutil
import subprocess
+import sys
from pathlib import Path
root = Path(__file__).resolve().parent.parent
metadata = json.loads(subprocess.check_output([
- "cargo", "metadata", "--locked", "--format-version", "1", "--filter-platform", "aarch64-apple-darwin"
+ "cargo", "metadata", "--locked", "--format-version", "1", "--filter-platform", sys.argv[1] if len(sys.argv) > 1 else "aarch64-apple-darwin"
], cwd=root))
output = root / "target/packaging/licenses"
if output.exists():
diff --git a/scripts/release-desktop.py b/scripts/release-desktop.py
new file mode 100644
index 0000000..a9ae4f9
--- /dev/null
+++ b/scripts/release-desktop.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+"""Build Windows/Linux packages and signed updater assets without publishing."""
+import argparse
+import datetime
+import hashlib
+import json
+import os
+import platform
+import shutil
+import subprocess
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+TARGETS = {
+ "windows": ("x86_64-pc-windows-msvc", "nsis", "Diffy-windows-x86_64-setup.exe"),
+ "linux": ("x86_64-unknown-linux-gnu", "appimage", "Diffy-linux-x86_64.AppImage"),
+}
+
+
+def run(args, env, capture=False):
+ result = subprocess.run([str(arg) for arg in args], cwd=ROOT, env=env,
+ check=True, text=True, stdout=subprocess.PIPE if capture else None)
+ return result.stdout if capture else None
+
+
+def package_config(metadata, platform_name, target, package_format, out):
+ config = metadata["metadata"]["packager"].copy()
+ config.pop("macos", None)
+ config.pop("before-packaging-command", None)
+ config.update({
+ "version": metadata["version"], "formats": [package_format], "target-triple": target,
+ "binaries": [{"path": "diffy", "main": True}, {"path": "diffy-cli", "main": False}],
+ "binaries-dir": str(ROOT / "target" / target / "release"), "out-dir": str(out),
+ "icons": ["assets/icon/icon.iconset/icon_32x32.png", "assets/icon/icon.iconset/icon_128x128.png", "assets/icon/icon.iconset/icon_256x256.png"],
+ })
+ if platform_name == "windows":
+ config["nsis"] = {"installMode": "currentUser"}
+ return config
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--platform", choices=TARGETS, required=True)
+ parser.add_argument("--unsigned", action="store_true", help="Build CI packages without updater signing material")
+ args = parser.parse_args()
+ if platform.system().lower() != args.platform:
+ parser.error("Build on the target operating system with its native toolchain.")
+ target, package_format, stable_name = TARGETS[args.platform]
+ for check in (["git", "diff", "--quiet"], ["git", "diff", "--cached", "--quiet"]):
+ subprocess.run(check, cwd=ROOT, check=True)
+ env = {key: value for key, value in os.environ.items() if not key.startswith("CARGO_PACKAGER_")}
+ tools = ROOT / "target/tools/bin"
+ env["PATH"] = str(tools) + os.pathsep + env.get("PATH", "")
+ packager = shutil.which("cargo-packager", path=env["PATH"])
+ if not packager or "0.11.8" not in run([packager, "--version"], env, True):
+ raise RuntimeError("Install cargo-packager 0.11.8 with --locked into target/tools first.")
+ metadata = json.loads(run(["cargo", "metadata", "--locked", "--no-deps", "--format-version", "1"], env, True))["packages"][0]
+ version = metadata["version"]
+ out = ROOT / "dist" / ("unsigned" if args.unsigned else "release") / args.platform
+ config = package_config(metadata, args.platform, target, package_format, out)
+ # An empty format list parses the full config without packaging or needing binaries.
+ # Validate with the pinned packager before spending time on a release build.
+ run([packager, "--config", json.dumps({**config, "formats": []})], env)
+ # Reject stale outputs so the fixed-name link always contains this build.
+ if out.exists():
+ shutil.rmtree(out)
+ out.mkdir(parents=True)
+ env["DIFFY_PACKAGE_KIND"] = package_format
+ run(["cargo", "build", "--locked", "--release", "--bins", "--target", target], env)
+ run(["python3" if args.platform == "linux" else "python", "scripts/collect-licenses.py", target], env)
+ configuration = ROOT / "target/packaging" / f"{args.platform}.json"
+ configuration.parent.mkdir(parents=True, exist_ok=True)
+ configuration.write_text(json.dumps(config, indent=2) + "\n")
+ run([packager, "--release", "--config", json.dumps(config)], env)
+ suffix = ".exe" if args.platform == "windows" else ".AppImage"
+ packages = list(out.glob(f"*{suffix}"))
+ if len(packages) != 1:
+ raise RuntimeError(f"Expected one {suffix} package, found {len(packages)}.")
+ package = packages[0]
+ stable = out / stable_name
+ shutil.copyfile(package, stable)
+ if args.platform == "linux":
+ stable.chmod(package.stat().st_mode)
+ assets = [package, stable]
+ info = out / f"build-{args.platform}-x86_64.json"
+ info.write_text(json.dumps({"version": version, "target": target,
+ "source_tree": run(["git", "rev-parse", "HEAD^{tree}"], env, True).strip(),
+ "package": package.name, "sha256": hashlib.sha256(package.read_bytes()).hexdigest()}, indent=2) + "\n")
+ assets.append(info)
+ if not args.unsigned:
+ key = os.environ.get("DIFFY_UPDATER_KEY", str(ROOT / ".release-secrets/updater.key"))
+ if Path(key + ".pub").read_text().strip() != (ROOT / "packaging/updater.pub").read_text().strip():
+ raise RuntimeError("The signing key doesn't match the public key trusted by Diffy.")
+ password_file = ROOT / ".release-secrets/updater.password"
+ password = os.environ.get("DIFFY_UPDATER_PASSWORD")
+ if password is None:
+ password = password_file.read_text().strip()
+ env["CARGO_PACKAGER_SIGN_PRIVATE_KEY"] = key
+ env["CARGO_PACKAGER_SIGN_PRIVATE_KEY_PASSWORD"] = password
+ run([packager, "signer", "sign", package], env)
+ signature = Path(str(package) + ".sig")
+ manifest = out / f"update-{args.platform}-x86_64.json"
+ manifest.write_text(json.dumps({
+ "version": version, "platforms": {f"{args.platform}-x86_64": {
+ "url": f"https://github.com/hitSlop/diffy/releases/download/v{version}/{package.name}",
+ "signature": signature.read_text().strip(), "format": package_format,
+ }}, "notes": f"Diffy {version} for {args.platform} x86-64 (alpha).",
+ "pub_date": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"),
+ }, indent=2) + "\n")
+ assets.extend([signature, manifest])
+ (out / "SHA256SUMS").write_text("".join(f"{hashlib.sha256(asset.read_bytes()).hexdigest()} {asset.name}\n" for asset in assets))
+ print(f"Packages ready in {out}; no release was published.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/release-macos.py b/scripts/release-macos.py
index ac3d64b..5ad4c56 100644
--- a/scripts/release-macos.py
+++ b/scripts/release-macos.py
@@ -83,6 +83,8 @@ def main():
if args.prepare_dmg:
prepare_dmg(env)
return
+ run(["git", "diff", "--quiet"], env)
+ run(["git", "diff", "--cached", "--quiet"], env)
# Isolate packaging from signing environment inherited from other projects.
env = {k: v for k, v in env.items() if not k.startswith("CARGO_PACKAGER_")}
version = json.loads(run(["cargo", "metadata", "--locked", "--no-deps", "--format-version", "1"], env, True))["packages"][0]["version"]
@@ -158,6 +160,11 @@ def main():
verify_env = {**env, "DIFFY_TEST_UPDATE_ARCHIVE": str(archive), "DIFFY_TEST_UPDATE_SIGNATURE": str(signature), "DIFFY_TEST_UPDATE_VERSION": version}
run(["cargo", "test", "--locked", "--test", "update_archive", "signed_app_archive_installs_and_relaunches_an_isolated_bundle", "--", "--ignored", "--exact"], verify_env)
assets = [dmg, stable_dmg, archive, signature, manifest]
+ info = output / "build-macos-aarch64.json"
+ info.write_text(json.dumps({"version": version, "target": "aarch64-apple-darwin",
+ "source_tree": run(["git", "rev-parse", "HEAD^{tree}"], env, True).strip(),
+ "package": archive.name, "sha256": hashlib.sha256(archive.read_bytes()).hexdigest()}, indent=2) + "\n")
+ assets.append(info)
(output / "SHA256SUMS").write_text("".join(f"{hashlib.sha256(p.read_bytes()).hexdigest()} {p.name}\n" for p in assets))
print(f"Signed, notarized release assets ready in {output}. Publication is not automatic.")
diff --git a/scripts/smoke-desktop.py b/scripts/smoke-desktop.py
new file mode 100644
index 0000000..1808a5e
--- /dev/null
+++ b/scripts/smoke-desktop.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python3
+"""Exercise a native demo window and its renderer without touching preferences."""
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+with tempfile.TemporaryDirectory(prefix="diffy-smoke-") as directory:
+ result = Path(directory) / "render.txt"
+ env = {**os.environ, "RUST_BACKTRACE": "1", "DIFFY_SMOKE_RESULT": str(result), "DIFFY_STATE_PATH": str(Path(directory) / "state.json")}
+ process = subprocess.run([sys.argv[1], "--smoke-test"], env=env, timeout=45,
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+ text=True, encoding="utf-8", errors="replace")
+ if process.stdout:
+ print(process.stdout, end="", flush=True)
+ process.check_returncode()
+ if not result.exists() or result.read_text() != "rendered\n":
+ raise RuntimeError("The native window did not render a frame.")
+ if (Path(directory) / "state.json").exists():
+ raise RuntimeError("The isolated demo unexpectedly saved preferences.")
+print("Native startup and rendering verified.")
diff --git a/site/README.md b/site/README.md
index 00e8fa0..8c70f5d 100644
--- a/site/README.md
+++ b/site/README.md
@@ -14,7 +14,7 @@ npm run preview
Set `DODO_CHECKOUT_URL` to the HTTPS hosted product checkout URL (for example `https://checkout.dodopayments.com/buy/pdt_example`) at build time. Do not set a private API key. Without the URL, the purchase button is disabled and shows “Checkout coming soon.”
-Configure a one-time USD $19.99 product in Dodo. The build sets the checkout return URL to `https://diffy.hitslop.com/thanks`. Put `https://diffy.hitslop.com/download` in the product's delivery instructions/external delivery link as well, so buyers can keep it. Dodo setup remains manual until the product is ready.
+Configure a one-time USD $14.99 product in Dodo. The build sets the checkout return URL to `https://diffy.hitslop.com/thanks`. Put `https://diffy.hitslop.com/downloads` in a Digital Files entitlement's external delivery link and attach it to the product as well, so buyers can keep it. Dodo setup remains manual until the product is ready.
```sh
DODO_CHECKOUT_URL='https://checkout.dodopayments.com/buy/YOUR_PRODUCT_ID' npm run build
@@ -48,8 +48,24 @@ The commands above remain available for manual deployment with your existing loc
Associate `diffy.hitslop.com` in the Pages project's custom domains, then point its DNS CNAME at the project's Pages hostname. Cloudflare must register the custom domain before its DNS redirect can work.
-`public/_redirects` sends `/download` to the fixed-name `Diffy-macos-aarch64.dmg` in GitHub's latest release. The release script creates an identical copy of the signed and notarized versioned DMG. Upload it with every release; never point the website at an unsigned build.
+The post-build `scripts/write-redirects.mjs` generates `dist/_redirects` from the same platform metadata used by the pages. `/download` retains its macOS behavior. `/download/macos`, `/download/windows`, and `/download/linux` use fixed-name assets in GitHub's latest release; unavailable targets redirect to `/downloads` instead. The release script creates an identical copy of the signed and notarized versioned DMG. Upload it with every release; never point the website at an unsigned build.
The bundled web font uses the same Commit Mono v143 font as Diffy, under the included SIL Open Font License. The screenshot is stored losslessly as WebP. The mascot and social preview derive from existing project assets.
The screenshot serves 480/960/1440px variants through `srcset`; the original 1920px image remains the full-resolution fallback and zoom link. Pointer motion is coalesced so the eye controller reads geometry at most once per animation frame.
+
+## Beta pricing and platform availability
+
+The landing page displays regular price $24.99 crossed out and $14.99 during beta. It keeps the purchase CTA and “Or build for free”; platform downloads are offered after purchase on `/thanks` and through Dodo delivery. There is no separate beta announcement line. macOS is Beta; Windows/Linux are Alpha. The purchase supports all platforms. Keep the Dodo product's actual one-time USD price at 1499 cents to match the site.
+
+`src/data/platforms.json` supplies download choices, requirements, asset names, and installation instructions for `/downloads` and `/thanks`. `DIFFY_RELEASED_PLATFORMS` is a comma-separated list of published platforms, default `macos`. After the complete release is verified and published, set the GitHub repository variable to `macos,windows,linux` and rebuild the site. Keep it at `macos` until then; the site never advertises missing assets.
+
+`node scripts/check-build.mjs` checks the built pricing, checkout return URL, availability, and redirect targets. Run it with the same environment used for `npm run build`. CI exercises both checkout configurations and all-platform availability.
+
+## Dodo dashboard setup
+
+No Dodo CLI or product is configured for this repository. Create one **Diffy — all desktop platforms** product with a one-time **USD $14.99** price (1499 cents), covering macOS beta and Windows/Linux alpha. Create a Digital Files entitlement named **Diffy downloads**, set its external link to **https://diffy.hitslop.com/downloads**, and attach it to the product. Suggested delivery instructions:
+
+> Choose your platform on the download page. All versions require Git. macOS is beta; Windows and Linux are early alphas with limited hardware testing. Save the page for future releases.
+
+Copy the hosted `https://checkout.dodopayments.com/buy/PRODUCT_ID` URL into the GitHub `DODO_CHECKOUT_URL` repository variable. The site adds the return URL `/thanks`. Keep checkout disabled until the product exists and its displayed charge matches $14.99. Public downloads and the return page do not verify payment or grant a license.
diff --git a/site/package.json b/site/package.json
index a078c6b..6cdffb7 100644
--- a/site/package.json
+++ b/site/package.json
@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"dev": "astro dev --host 127.0.0.1",
- "build": "astro build",
+ "build": "astro build && node scripts/write-redirects.mjs",
"preview": "astro preview --host 127.0.0.1"
},
"dependencies": {
diff --git a/site/public/_redirects b/site/public/_redirects
deleted file mode 100644
index c8c7e48..0000000
--- a/site/public/_redirects
+++ /dev/null
@@ -1 +0,0 @@
-/download https://github.com/hitSlop/Diffy/releases/latest/download/Diffy-macos-aarch64.dmg 302
diff --git a/site/scripts/check-build.mjs b/site/scripts/check-build.mjs
new file mode 100644
index 0000000..ae0b0ad
--- /dev/null
+++ b/site/scripts/check-build.mjs
@@ -0,0 +1,24 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+const read = path => readFileSync(new URL(`../dist/${path}`, import.meta.url), 'utf8');
+const home = read('index.html');
+assert(home.includes('$14.99') && home.includes('$24.99') && !home.includes('$19.99'));
+assert(home.includes('$24.99') && home.includes('during beta'));
+assert(!home.includes('Downloads & installation') && !home.includes('Diffy is in beta.'));
+const checkout = process.env.DODO_CHECKOUT_URL;
+assert.equal(home.includes('checkout.dodopayments.com/buy/'), !!checkout);
+if (checkout) assert(home.includes('redirect_url=https%3A%2F%2Fdiffy.hitslop.com%2Fthanks'));
+const published = new Set((process.env.DIFFY_RELEASED_PLATFORMS || 'macos').split(','));
+const redirects = read('_redirects');
+for (const page of ['downloads/index.html', 'thanks/index.html']) {
+ const html = read(page);
+ for (const platform of ['macos', 'windows', 'linux']) {
+ assert(html.includes(`id="${platform}"`));
+ assert.equal(html.includes(`href="/download/${platform}"`), published.has(platform));
+ const line = redirects.split('\n').find(line => line.startsWith(`/download/${platform} `));
+ assert(line && line.endsWith(' 302'));
+ assert.equal(line.includes('github.com'), published.has(platform));
+ }
+}
+assert(redirects.includes('/download https://github.com/hitSlop/diffy/releases/latest/download/Diffy-macos-aarch64.dmg 302'));
+console.log('Pricing, checkout, platform availability, and redirects verified.');
diff --git a/site/scripts/write-redirects.mjs b/site/scripts/write-redirects.mjs
new file mode 100644
index 0000000..fa19be4
--- /dev/null
+++ b/site/scripts/write-redirects.mjs
@@ -0,0 +1,9 @@
+import { readFileSync, writeFileSync } from 'node:fs';
+const platforms = JSON.parse(readFileSync(new URL('../src/data/platforms.json', import.meta.url), 'utf8'));
+const published = new Set((process.env.DIFFY_RELEASED_PLATFORMS || 'macos').split(',').map(value => value.trim()));
+const base = 'https://github.com/hitSlop/diffy/releases/latest/download/';
+const lines = [`/download ${base}Diffy-macos-aarch64.dmg 302`];
+for (const platform of platforms) {
+ lines.push(`/download/${platform.id} ${published.has(platform.id) ? base + platform.asset : '/downloads#' + platform.id} 302`);
+}
+writeFileSync(new URL('../dist/_redirects', import.meta.url), lines.join('\n') + '\n');
diff --git a/site/src/components/Downloads.astro b/site/src/components/Downloads.astro
new file mode 100644
index 0000000..8a50ac6
--- /dev/null
+++ b/site/src/components/Downloads.astro
@@ -0,0 +1,16 @@
+---
+import { platforms } from '../data/releases';
+---
+