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 chameleon 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** ![Diffy on macOS: a split Rust diff, project change badges, a searchable file list grouped by directory, and project import](assets/screenshot.png) -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'; +--- +
+ {platforms.map(platform => ( +
+

{platform.name}

{platform.stage}
+

{platform.requirements}

+ {platform.available + ? Download for {platform.name} + : } + {!platform.available &&

The download will be available after release checks pass.

} +
Installation & requirements
    {platform.steps.map(step =>
  1. {step}
  2. )}

{platform.note}

+
+ ))} +
diff --git a/site/src/data/platforms.json b/site/src/data/platforms.json new file mode 100644 index 0000000..063f177 --- /dev/null +++ b/site/src/data/platforms.json @@ -0,0 +1,41 @@ +[ + { + "id": "macos", + "name": "macOS", + "stage": "Beta", + "requirements": "Apple Silicon · macOS 13+", + "asset": "Diffy-macos-aarch64.dmg", + "steps": [ + "Open the downloaded DMG.", + "Drag Diffy into Applications.", + "Open Diffy and choose a Git project." + ], + "note": "Choose File → Install terminal command to use diffy . from your terminal." + }, + { + "id": "windows", + "name": "Windows", + "stage": "Alpha", + "requirements": "64-bit Intel / AMD · Windows 10+", + "asset": "Diffy-windows-x86_64-setup.exe", + "steps": [ + "Run the downloaded installer.", + "Install for your user; administrator access is not required.", + "Open Diffy and choose a Git project." + ], + "note": "This alpha has no Windows publisher signature. Windows may show an unknown-publisher warning. Only install a download you trust." + }, + { + "id": "linux", + "name": "Linux", + "stage": "Alpha", + "requirements": "x86-64 · Ubuntu 24.04+ · Wayland or X11 · Vulkan driver", + "asset": "Diffy-linux-x86_64.AppImage", + "steps": [ + "Keep the AppImage in a writable folder.", + "Make it executable: chmod +x Diffy-linux-x86_64.AppImage", + "Run the AppImage and choose a Git project." + ], + "note": "If FUSE is unavailable, run the AppImage with --appimage-extract-and-run. Other distributions are not yet verified." + } +] diff --git a/site/src/data/releases.ts b/site/src/data/releases.ts new file mode 100644 index 0000000..02fa957 --- /dev/null +++ b/site/src/data/releases.ts @@ -0,0 +1,4 @@ +import definitions from './platforms.json'; +const published = new Set((import.meta.env.DIFFY_RELEASED_PLATFORMS || 'macos').split(',').map((value: string) => value.trim())); +export const platforms = definitions.map(platform => ({ ...platform, available: published.has(platform.id), href: `/download/${platform.id}` })); +export const price = { regular: '$24.99', beta: '$14.99' }; diff --git a/site/src/layouts/Page.astro b/site/src/layouts/Page.astro index 1f773d6..2a3b7e4 100644 --- a/site/src/layouts/Page.astro +++ b/site/src/layouts/Page.astro @@ -1,6 +1,6 @@ --- import '../styles/site.css'; -const { title = 'Diffy — a native Git diff viewer', description = 'Review live Git diffs and switch worktrees in one native Mac app, built in Rust. Buy once for $19.99, or build for free.' } = Astro.props; +const { title = 'Diffy — a native Git diff viewer, in beta', description = 'Review live Git diffs in a native app built in Rust. macOS beta; Windows and Linux alpha. $14.99 during beta, regular price $24.99. One purchase supports all platforms.' } = Astro.props; const canonical = new URL(Astro.url.pathname, Astro.site); --- diff --git a/site/src/pages/downloads.astro b/site/src/pages/downloads.astro new file mode 100644 index 0000000..c728693 --- /dev/null +++ b/site/src/pages/downloads.astro @@ -0,0 +1,13 @@ +--- +import Page from '../layouts/Page.astro'; +import Downloads from '../components/Downloads.astro'; +--- + +
+ Diffy’s chameleon +

Diffy, on your desktop.

+

Choose your platform. Every version requires Git.

+ +

Windows and Linux are early alphas with limited hardware testing.
Save this page for future releases. Report a problem ↗

+
+
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index 0da7bd2..b448e6a 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -1,6 +1,7 @@ --- import Page from '../layouts/Page.astro'; import Mascot from '../components/Mascot.astro'; +import { platforms, price } from '../data/releases'; import { getImage } from 'astro:assets'; import screenshot from '../../public/diffy.webp'; const variants = await Promise.all([480, 960, 1440].map(width => getImage({ src: screenshot, width, format: 'webp', quality: 90 }))); @@ -23,12 +24,13 @@ if (checkout) {

A native Git diff viewer, built in Rust.

Keep up with your agents’ changes.

-

Apple Silicon macOS 13+ Requires Git

+

{platforms.map((platform, index) => <>{index > 0 && }{platform.name} {platform.stage.toLowerCase()})} Requires Git

- {purchaseUrl ? Buy once $19.99 : } +

Regular price {price.regular} {price.beta} during beta

+ {purchaseUrl ? Buy once {price.beta} : } {!purchaseUrl &&

Checkout coming soon.

} Or build for free -

Your purchase supports Diffy.

+

One purchase. All platforms. Your purchase supports Diffy.

@@ -52,7 +54,7 @@ if (checkout) {

Jump between files and projects. Find a command without hunting for a button.

⌘P Commands⌘↑⌘↓ Files⇧⌘↑⇧⌘↓ Projects
-

Install the terminal command, then start with diffy .

+

Use ⌘ on macOS and Ctrl on Windows/Linux. Install the terminal command, then start with diffy .

@@ -69,7 +71,7 @@ if (checkout) {
Make projects feel familiar.
-

Give each project a name, color, emoji, or image. Right-click its icon to open the current checkout in Finder, your terminal, or your editor.

+

Give each project a name, color, emoji, or image. Right-click its icon to open the current checkout in your file manager, terminal, or editor.

Read at your own pace.
diff --git a/site/src/pages/thanks.astro b/site/src/pages/thanks.astro index d64a51a..ad92bde 100644 --- a/site/src/pages/thanks.astro +++ b/site/src/pages/thanks.astro @@ -1,17 +1,14 @@ --- import Page from '../layouts/Page.astro'; +import Downloads from '../components/Downloads.astro'; --- - +
Diffy’s chameleon

Hey. Here’s Diffy.

Thanks for being here.

- Download for macOS -

Apple Silicon · macOS 13+ · Requires Git

-
-

Make yourself at home.

-
  1. Open the downloaded DMG.
  2. Drag Diffy into Applications.
  3. Open Diffy and choose a Git project.
-

Save diffy.hitslop.com/download.
It always downloads the latest version.

-
+

One purchase supports all platforms. Requires Git.

+ +

Save diffy.hitslop.com/downloads for future releases.

diff --git a/site/src/styles/site.css b/site/src/styles/site.css index bb8ad10..f0e6520 100644 --- a/site/src/styles/site.css +++ b/site/src/styles/site.css @@ -19,6 +19,28 @@ --muted: #929297; --quiet: #737379; } +.beta-price { font-size: 14px; margin: 0 0 16px; color: var(--muted); } +.beta-price s { margin-right: 10px; } +.beta-price strong { font-size: 22px; font-weight: 500; color: #fff; } +.platform-downloads { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 36px; margin: 48px 0; text-align: left; } +.platform-download { min-width: 0; padding-top: 20px; border-top: 1px solid #303033; scroll-margin-top: 24px; } +.platform-heading { display: flex; align-items: center; gap: 12px; } +.download-page .platform-heading h2 { font-size: 19px; font-weight: 500; margin: 0; } +.release-badge { color: #e4ed64; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; } +.release-badge.alpha { color: #ffb47a; } +.platform-download .compatibility { min-height: 42px; font-size: 11px; line-height: 1.8; margin: 14px 0 22px; } +.platform-download .button { font-size: 12px; width: 100%; justify-content: center; } +.download-note, .install-details { font-size: 12px; color: var(--muted); line-height: 1.9; } +.download-note { margin: 20px 0 40px; } +.platform-download .download-note { margin: 12px 0 0; font-size: 11px; } +.install-details { margin-top: 22px; overflow-wrap: anywhere; } +.install-details summary { cursor: pointer; color: #fff; } +.install-details summary:focus-visible { outline: 2px solid #e4ed64; outline-offset: 5px; } +.install-details ol { padding-left: 20px; } +@media (max-width: 850px) { + .platform-downloads { grid-template-columns: 1fr; gap: 28px; max-width: 520px; margin-inline: auto; } + .platform-download .compatibility { min-height: 0; } +} * { box-sizing: border-box; } diff --git a/src/app/files.rs b/src/app/files.rs index 356a2fc..d7cae54 100644 --- a/src/app/files.rs +++ b/src/app/files.rs @@ -493,8 +493,26 @@ impl Diffy { } pub(super) fn stop_file_repeat(&mut self) { self.repeat_direction = None; + self.native_repeat_at = None; + self.native_has_repeated = false; self.file_repeat = None; } + pub(super) fn native_file_key(&mut self, direction: isize, held: bool, cx: &mut Context) { + let now = std::time::Instant::now(); + let delay = Duration::from_millis(if self.native_has_repeated { 200 } else { 320 }); + if held + && self.repeat_direction == Some(direction) + && self + .native_repeat_at + .is_some_and(|last| now.duration_since(last) < delay) + { + return; + } + self.native_has_repeated = held && self.repeat_direction == Some(direction); + self.repeat_direction = Some(direction); + self.native_repeat_at = Some(now); + self.move_file(direction, cx); + } /// Capture runs before the diff list applies the wheel, so stopping the /// event here keeps Command+scroll on the file list. pub(super) fn file_scroll_layer(&self, cx: &mut Context) -> AnyElement { diff --git a/src/app/mod.rs b/src/app/mod.rs index 05dd252..23142a5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -128,8 +128,11 @@ pub fn init(cx: &mut App) { gpui_kit::MenuItem::action("About Diffy", About), gpui_kit::MenuItem::action("Check for Updates…", CheckForUpdates), gpui_kit::MenuItem::Separator, + #[cfg(target_os = "macos")] gpui_kit::MenuItem::action("Hide Diffy", Hide), + #[cfg(target_os = "macos")] gpui_kit::MenuItem::action("Hide Others", HideOthers), + #[cfg(target_os = "macos")] gpui_kit::MenuItem::action("Show All", ShowAll), gpui_kit::MenuItem::Separator, gpui_kit::MenuItem::action("Quit Diffy", Quit), @@ -147,13 +150,14 @@ pub fn init(cx: &mut App) { gpui_kit::Menu::new("File").items([ gpui_kit::MenuItem::action("Open Repository…", OpenProject), gpui_kit::MenuItem::action("Import Projects…", ImportProjects), - #[cfg(target_os = "macos")] gpui_kit::MenuItem::action("Install terminal command…", InstallCli), gpui_kit::MenuItem::action("Refresh Repository", Refresh), ]), ]); cx.bind_keys([ + #[cfg(target_os = "macos")] KeyBinding::new("secondary-h", Hide, Some("Diffy")), + #[cfg(target_os = "macos")] KeyBinding::new("secondary-alt-h", HideOthers, Some("Diffy")), KeyBinding::new("secondary-p", OpenCommandPalette, Some("Diffy")), KeyBinding::new("secondary-o", OpenProject, Some("Diffy")), @@ -204,6 +208,8 @@ pub struct Diffy { project_apps: project_menu::Runtime, repeat_direction: Option, file_repeat: Option>, + native_repeat_at: Option, + native_has_repeated: bool, file_key_probe: fn(isize) -> bool, file_scroll_remainder: f32, file_scroll_axis: Option, @@ -306,6 +312,15 @@ impl Render for Diffy { .font_features(fonts::features()) .text_size(px(13.)) .text_color(cx.theme().foreground) + .capture_key_down(cx.listener(|this, event: &gpui_kit::KeyDownEvent, _, cx| { + if cfg!(target_os = "macos") || this.palette_open || this.settings.is_some() || this.import.picker.is_some() { return; } + let modifiers = event.keystroke.modifiers; + if !modifiers.secondary() || modifiers.shift || modifiers.alt { return; } + let direction = match event.keystroke.key.as_str() { "up" => -1, "down" => 1, _ => return }; + if (this.file_key_probe)(direction) { return; } + cx.stop_propagation(); + this.native_file_key(direction, event.is_held, cx); + })) .on_key_up(cx.listener(|this, _, _, _| this.stop_file_repeat())) .on_modifiers_changed(cx.listener(|this, event: &gpui_kit::ModifiersChangedEvent, _, _| { this.reset_file_scroll_unless_command(event); @@ -373,6 +388,9 @@ impl Render for Diffy { if this.updates.blocks_mutations() { return; } this.exit(None, cx); })) + .when(!cfg!(target_os = "macos") && matches!(window.window_decorations(), gpui_kit::Decorations::Client { .. }), |root| { + root.child(gpui_kit::component::TitleBar::new()) + }) .child(self.file_scroll_layer(cx)) .children(self.state_error.as_ref().map(|error| { div() diff --git a/src/app/notice.rs b/src/app/notice.rs index 1d28c6c..f0808cd 100644 --- a/src/app/notice.rs +++ b/src/app/notice.rs @@ -141,6 +141,10 @@ impl Diffy { let _ = owner.update(cx, |this, cx| match result { Ok(()) => { this.persist(cx); + #[cfg(target_os = "linux")] + if let Ok(path) = updates::launch_executable() { + cx.set_restart_path(path); + } cx.restart(); } Err(error) => { diff --git a/src/app/palette.rs b/src/app/palette.rs index 82f4d99..10fd6ba 100644 --- a/src/app/palette.rs +++ b/src/app/palette.rs @@ -132,11 +132,7 @@ impl Diffy { Box::new(ImportProjects), false, ), - item( - "Install terminal command", - Box::new(InstallCli), - !cfg!(target_os = "macos"), - ), + item("Install terminal command", Box::new(InstallCli), false), item( "Refresh repository", Box::new(Refresh), @@ -206,7 +202,7 @@ impl Diffy { !p.is_some_and(|p| p.path.is_some() && p.view().selected_entry().is_some()), ), item( - "Reveal in Finder", + crate::project_apps::reveal_label(), Box::new(super::RevealFile), !p.is_some_and(|p| p.path.is_some() && p.view().selected_entry().is_some()), ), diff --git a/src/app/project_menu.rs b/src/app/project_menu.rs index 94b78e3..f91d032 100644 --- a/src/app/project_menu.rs +++ b/src/app/project_menu.rs @@ -54,12 +54,12 @@ pub(super) fn project_menu( } menu.separator() .menu_with_disabled( - "Open in Finder", + crate::project_apps::folder_label(), Box::new(OpenProjectIn { id, app: ProjectApp::Finder, }), - !local || !cfg!(target_os = "macos"), + !local, ) .item( PopupMenuItem::new("Remove Project") diff --git a/src/app/reader.rs b/src/app/reader.rs index 6fbd86a..f7eefb0 100644 --- a/src/app/reader.rs +++ b/src/app/reader.rs @@ -134,7 +134,7 @@ pub(super) fn file_menu( !local, ) .menu_with_disabled( - "Reveal in Finder", + crate::project_apps::reveal_label(), Box::new(super::RevealPath { id, path: path.into(), diff --git a/src/app/session.rs b/src/app/session.rs index 4331a92..7ada5bc 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -152,6 +152,8 @@ impl Diffy { project_apps: Default::default(), repeat_direction: None, file_repeat: None, + native_repeat_at: None, + native_has_repeated: false, file_key_probe: keyboard::file_shortcut_pressed, file_scroll_remainder: 0., file_scroll_axis: None, @@ -188,7 +190,10 @@ impl Diffy { "down" => Some(1), _ => None, }; - if let Some(direction) = direction { + if let Some(direction) = direction + && (cfg!(target_os = "macos") + || (this.file_key_probe)(direction)) + { cx.stop_propagation(); this.start_file_repeat(direction, w, cx); } diff --git a/src/app/settings.rs b/src/app/settings.rs index 52ea4d1..8fcc18d 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -342,7 +342,13 @@ impl Diffy { div() .text_size(px(12.)) .text_color(cx.theme().muted_foreground) - .child("Use ⌃⌘Space for the macOS emoji picker."), + .child(if cfg!(target_os = "macos") { + "Use ⌃⌘Space for the emoji picker." + } else if cfg!(windows) { + "Use Win+. for the emoji picker." + } else { + "Enter or paste an emoji." + }), ) }) .when_some(image_path, |box_, path| { diff --git a/src/cli.rs b/src/cli.rs index d85112c..ff2ff67 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,6 +2,21 @@ use std::path::{Path, PathBuf}; use url::Url; +#[derive(serde::Serialize, serde::Deserialize)] +struct LaunchConfig { + managed_by: String, + executable: PathBuf, +} +pub fn launcher_target(config: &Path) -> Result { + let saved: LaunchConfig = + serde_json::from_slice(&std::fs::read(config).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + if saved.managed_by != "com.hitslop.diffy" || !saved.executable.is_absolute() { + return Err("This configuration isn't a Diffy launcher.".into()); + } + Ok(saved.executable) +} + #[cfg(unix)] const MARKER: &str = "# Managed by com.hitslop.diffy\n"; @@ -72,28 +87,32 @@ pub fn launch(paths: &[PathBuf]) -> Result<(), String> { #[cfg(not(target_os = "macos"))] pub fn launch(_: &[PathBuf]) -> Result<(), String> { - Err("The terminal launcher currently supports macOS.".into()) + Err("Run the Diffy executable with repository paths to open projects.".into()) } pub fn install_current() -> Result { - if !cfg!(target_os = "macos") { - return Err("The terminal launcher currently supports macOS.".into()); - } - let executable = std::env::current_exe().map_err(|e| e.to_string())?; - if crate::updates::bundle_path(&executable).is_none() { + let executable = crate::updates::launch_executable()?; + if cfg!(target_os = "macos") && crate::updates::bundle_path(&executable).is_none() { return Err("Install the terminal command from a packaged Diffy app.".into()); } - let home = std::env::var_os("HOME").ok_or("Home directory unavailable")?; - let directory = PathBuf::from(home).join(".local/bin"); - install_at(&directory.join("diffy"), &executable)?; + let home = directories::BaseDirs::new().ok_or("Home directory unavailable")?; + #[cfg(unix)] + let directory = home.home_dir().join(".local/bin"); + #[cfg(windows)] + let directory = home.data_local_dir().join("hitSlop/Diffy/bin"); + let name = if cfg!(windows) { "diffy.exe" } else { "diffy" }; + install_at(&directory.join(name), &executable)?; let on_path = std::env::var_os("PATH") .is_some_and(|path| std::env::split_paths(&path).any(|p| p == directory)); let mut report = format!( - "Installed {}\n\nRun diffy . to open a repository in this window.\nReinstall this command if you move Diffy.app.", - directory.join("diffy").display() + "Installed {}\n\nRun diffy . to open a repository in this window.\nReinstall this command if you move Diffy.", + directory.join(name).display() ); if !on_path { + #[cfg(unix)] report.push_str("\n\nIf your terminal cannot find diffy, add this line to your shell configuration and open a new terminal:\n\nexport PATH=\"$HOME/.local/bin:$PATH\""); + #[cfg(windows)] + report.push_str(&format!("\n\nAdd {} to your user Path in Windows Environment Variables, then open a new terminal.", directory.display())); } Ok(report) } @@ -148,6 +167,7 @@ fn install_at(destination: &Path, executable: &Path) -> Result<(), String> { file.write_all(script.as_bytes())?; file.set_permissions(fs::Permissions::from_mode(0o755))?; file.sync_all()?; + drop(file); if replacing { fs::rename(&temporary, destination) } else { @@ -158,12 +178,43 @@ fn install_at(destination: &Path, executable: &Path) -> Result<(), String> { result.map_err(|e| format!("Couldn't install terminal command: {e}")) } -#[cfg(not(unix))] -fn install_at(_: &Path, _: &Path) -> Result<(), String> { - Err("macOS is required.".into()) +#[cfg(windows)] +fn install_at(destination: &Path, executable: &Path) -> Result<(), String> { + let parent = destination.parent().ok_or("Invalid launcher directory")?; + let config = parent.join("diffy-launch.json"); + if destination.exists() && launcher_target(&config).is_err() { + return Err(format!( + "{} already exists and isn't a Diffy launcher.", + destination.display() + )); + } + let source = executable + .parent() + .ok_or("Invalid application directory")? + .join("diffy-cli.exe"); + if !source.is_file() { + return Err("Install the terminal command from a packaged Diffy app.".into()); + } + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + use std::io::Write as _; + temporary + .write_all( + &serde_json::to_vec(&LaunchConfig { + managed_by: "com.hitslop.diffy".into(), + executable: executable.to_path_buf(), + }) + .map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + temporary.persist(&config).map_err(|e| e.to_string())?; + let shim = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + std::fs::copy(source, shim.path()).map_err(|e| e.to_string())?; + shim.persist(destination).map_err(|e| e.to_string())?; + Ok(()) } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; #[test] @@ -183,22 +234,36 @@ mod tests { assert!(paths_from_url(bad).is_err(), "{bad}"); } } - #[cfg(unix)] #[test] fn installer_preserves_conflicts_and_quotes_arguments() { use std::fs; - let root = std::env::temp_dir().join(format!("diffy-cli-{}", std::process::id())); - fs::create_dir_all(&root).unwrap(); + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); let destination = root.join("bin/diffy"); let executable = root.join("app with ' quotes"); fs::write(&executable, "#!/bin/sh\nprintf '%s\\n' \"$@\"\n").unwrap(); use std::os::unix::fs::PermissionsExt as _; fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); install_at(&destination, &executable).unwrap(); - let output = std::process::Command::new(&destination) - .arg("space 日本語 $(nope)") - .output() - .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + let output = loop { + match std::process::Command::new(&destination) + .arg("space 日本語 $(nope)") + .output() + { + // Concurrent tests spawn processes while this executable is + // written. A forked child can briefly inherit its write fd, + // even after this thread closes it (Linux ETXTBSY). + Err(error) + if cfg!(target_os = "linux") + && error.raw_os_error() == Some(26) + && std::time::Instant::now() < deadline => + { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + result => break result.unwrap(), + } + }; assert!(output.status.success()); assert_eq!( String::from_utf8(output.stdout).unwrap(), @@ -214,6 +279,5 @@ mod tests { fs::remove_file(&destination).unwrap(); std::os::unix::fs::symlink(&executable, &destination).unwrap(); assert!(install_at(&destination, &executable).is_err()); - fs::remove_dir_all(root).unwrap(); } } diff --git a/src/discovery.rs b/src/discovery.rs index 45c4e5f..3c2ef2f 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -389,7 +389,10 @@ impl Scanner { break; } // Windows named pipes do not appear in the filesystem. - let live = (cfg!(windows) || socket.exists()) && Instant::now() < deadline; + let named_pipe = cfg!(windows) + && (socket.to_string_lossy().starts_with(r"\\.\pipe\") + || locations.socket.as_ref() == Some(&socket)); + let live = (named_pipe || socket.exists()) && Instant::now() < deadline; let result = if live { read_herdr_socket( &socket, @@ -652,6 +655,7 @@ fn read_herdr_socket( } else { let mut chunk = [0; 8192]; match stream.read(&mut chunk) { + Ok(0) if cfg!(windows) => {} Ok(0) => return Err(io::ErrorKind::UnexpectedEof.into()), Ok(n) => { reply.extend_from_slice(&chunk[..n]); diff --git a/src/instance.rs b/src/instance.rs new file mode 100644 index 0000000..62ee1f5 --- /dev/null +++ b/src/instance.rs @@ -0,0 +1,325 @@ +//! Per-user, bounded local requests. The lock outlives the listener so stale +//! Unix sockets can only be removed by the next instance that owns the lock. +use fs2::FileExt as _; +use interprocess::local_socket::{ListenerNonblockingMode, ListenerOptions, Stream, prelude::*}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, File, OpenOptions}, + io::{self, Read, Write}, + path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant}, +}; + +const LIMIT: usize = 64 * 1024; +const TIMEOUT: Duration = Duration::from_secs(3); + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Request { + version: u8, + paths: Vec, +} + +pub enum Start { + Forwarded, + Primary(Instance), +} +pub struct Instance { + lock: File, + stop: Arc, + worker: Option>, +} +impl Drop for Instance { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + // The listener has stopped and reclaimed its name. Release the lock + // even if a forked child still holds an inherited file description. + let _ = fs2::FileExt::unlock(&self.lock); + } +} + +fn endpoint(directory: &Path) -> io::Result> { + #[cfg(windows)] + { + let hash = Sha256::digest(directory.as_os_str().to_string_lossy().as_bytes()); + format!("diffy-{:x}", hash) + .to_ns_name::() + .map(|name| name.into_owned()) + } + #[cfg(not(windows))] + { + directory + .join("open.sock") + .to_fs_name::() + .map(|name| name.into_owned()) + } +} +fn connect(directory: &Path, deadline: Instant) -> io::Result { + #[cfg(windows)] + { + interprocess::local_socket::ConnectOptions::new() + .name(endpoint(directory)?) + .wait_mode(interprocess::ConnectWaitMode::Timeout( + deadline.saturating_duration_since(Instant::now()), + )) + .connect_sync() + } + #[cfg(not(windows))] + { + let _ = deadline; + Stream::connect(endpoint(directory)?) + } +} + +fn runtime_directory(state: &Path) -> io::Result { + let parent = state.parent().ok_or(io::ErrorKind::InvalidInput)?; + #[cfg(windows)] + { + Ok(parent.join("runtime")) + } + #[cfg(not(windows))] + { + // Unix socket names have a small fixed limit. Hash the full state path, + // preserving isolation for overrides without putting it in the socket name. + let hash = Sha256::digest(state.as_os_str().to_string_lossy().as_bytes()); + let base = directories::ProjectDirs::from("com", "hitSlop", "Diffy") + .and_then(|dirs| dirs.runtime_dir().map(Path::to_path_buf)) + .unwrap_or_else(|| PathBuf::from("/tmp")); + let _ = parent; + Ok(base.join(format!("diffy-{}", &format!("{hash:x}")[..24]))) + } +} + +pub fn start( + state: &Path, + paths: Vec, + sender: async_channel::Sender>, +) -> Result { + start_in( + &runtime_directory(state).map_err(|e| e.to_string())?, + paths, + sender, + ) + .map_err(|e| format!("Couldn't contact Diffy: {e}")) +} + +fn start_in( + directory: &Path, + paths: Vec, + sender: async_channel::Sender>, +) -> io::Result { + if paths.iter().any(|path| !path.is_absolute()) { + return Err(io::ErrorKind::InvalidInput.into()); + } + fs::create_dir_all(directory)?; + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let metadata = fs::symlink_metadata(directory)?; + let home = directories::BaseDirs::new().ok_or(io::ErrorKind::NotFound)?; + if metadata.file_type().is_symlink() + || metadata.uid() != fs::metadata(home.home_dir())?.uid() + { + return Err(io::ErrorKind::PermissionDenied.into()); + } + fs::set_permissions(directory, fs::Permissions::from_mode(0o700))?; + } + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(directory.join("instance.lock"))?; + match lock.try_lock_exclusive() { + Ok(()) => {} + Err(error) if error.raw_os_error() == fs2::lock_contended_error().raw_os_error() => { + let deadline = Instant::now() + TIMEOUT; + loop { + match connect(directory, deadline) { + Ok(mut stream) => { + #[cfg(windows)] + if let Ok(peer) = stream.peer_creds() + && let Some(pid) = peer.pid() + { + // Transfer foreground activation permission only to + // the app process at the connected per-user endpoint. + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::AllowSetForegroundWindow(pid); + } + } + stream.set_nonblocking(true)?; + let mut request = serde_json::to_vec(&Request { version: 1, paths }) + .map_err(io::Error::other)?; + request.push(b'\n'); + if request.len() > LIMIT { + return Err(io::ErrorKind::InvalidInput.into()); + } + send(&mut stream, &request, deadline)?; + let reply = receive(&mut stream, deadline)?; + send(&mut stream, b"ack\n", deadline)?; + return if reply == b"ok" { + Ok(Start::Forwarded) + } else { + Err(io::Error::other("the running app rejected the request")) + }; + } + Err(_) if Instant::now() < deadline => thread::sleep(Duration::from_millis(20)), + Err(error) => return Err(error), + } + } + } + Err(error) => return Err(error), + } + #[cfg(not(windows))] + match fs::remove_file(directory.join("open.sock")) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + let listener = ListenerOptions::new() + .name(endpoint(directory)?) + .nonblocking(ListenerNonblockingMode::Both) + .create_sync()?; + let stop = Arc::new(AtomicBool::new(false)); + let stopped = stop.clone(); + let worker = thread::spawn(move || { + while !stopped.load(Ordering::Relaxed) { + match listener.accept() { + Ok(mut stream) => { + let deadline = Instant::now() + TIMEOUT; + let result = receive(&mut stream, deadline).and_then(|bytes| { + let request: Request = + serde_json::from_slice(&bytes).map_err(io::Error::other)?; + if request.version != 1 || request.paths.iter().any(|p| !p.is_absolute()) { + return Err(io::ErrorKind::InvalidInput.into()); + } + let url = crate::cli::open_url(&request.paths).map_err(io::Error::other)?; + sender.try_send(vec![url]).map_err(io::Error::other) + }); + if send( + &mut stream, + if result.is_ok() { b"ok\n" } else { b"error\n" }, + deadline, + ) + .is_ok() + { + // Keep the Windows pipe alive until the client consumed + // the reply. Disconnecting first can discard buffered bytes. + let _ = receive(&mut stream, deadline); + } + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)) + } + Err(_) => break, + } + } + drop(listener); + }); + Ok(Start::Primary(Instance { + lock, + stop, + worker: Some(worker), + })) +} + +fn send(stream: &mut Stream, mut bytes: &[u8], deadline: Instant) -> io::Result<()> { + while !bytes.is_empty() { + if Instant::now() >= deadline { + return Err(io::ErrorKind::TimedOut.into()); + } + match stream.write(bytes) { + Ok(0) if cfg!(windows) => thread::sleep(Duration::from_millis(5)), + Ok(0) => return Err(io::ErrorKind::WriteZero.into()), + Ok(count) => bytes = &bytes[count..], + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(5)) + } + Err(error) => return Err(error), + } + } + Ok(()) +} +fn receive(stream: &mut Stream, deadline: Instant) -> io::Result> { + let mut bytes = Vec::new(); + loop { + if Instant::now() >= deadline { + return Err(io::ErrorKind::TimedOut.into()); + } + let mut chunk = [0; 4096]; + match stream.read(&mut chunk) { + // Windows PIPE_NOWAIT can report an empty pipe as a zero-byte read + // with this interprocess snapshot. Treat it as pending until the deadline. + Ok(0) if cfg!(windows) => thread::sleep(Duration::from_millis(5)), + Ok(0) => return Err(io::ErrorKind::UnexpectedEof.into()), + Ok(count) => { + bytes.extend_from_slice(&chunk[..count]); + if bytes.len() > LIMIT { + return Err(io::ErrorKind::InvalidData.into()); + } + if let Some(end) = bytes.iter().position(|b| *b == b'\n') { + bytes.truncate(end); + return Ok(bytes); + } + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(5)) + } + Err(error) => return Err(error), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn requests_forward_and_released_instances_can_restart() { + let temp = tempfile::tempdir().unwrap(); + let (sender, receiver) = async_channel::unbounded(); + let primary = match start_in(temp.path(), vec![], sender.clone()).unwrap() { + Start::Primary(primary) => primary, + _ => panic!("first instance"), + }; + let path = temp.path().join("日本語 space #?%&"); + assert!(matches!( + start_in(temp.path(), vec![path.clone()], sender.clone()).unwrap(), + Start::Forwarded + )); + assert_eq!( + crate::cli::paths_from_url(&receiver.recv_blocking().unwrap()[0]).unwrap(), + vec![path] + ); + // A forked child may retain the same file description during exec. + // Closing our handle alone must not prevent immediate restart. + #[cfg(unix)] + let _inherited_lock = primary.lock.try_clone().unwrap(); + drop(primary); + assert!(matches!( + start_in(temp.path(), vec![], sender).unwrap(), + Start::Primary(_) + )); + } + #[test] + fn invalid_requests_do_not_open_projects() { + let temp = tempfile::tempdir().unwrap(); + let (sender, receiver) = async_channel::unbounded(); + let _primary = start_in(temp.path(), vec![], sender).unwrap(); + let mut stream = Stream::connect(endpoint(temp.path()).unwrap()).unwrap(); + stream.set_nonblocking(true).unwrap(); + let deadline = Instant::now() + TIMEOUT; + send(&mut stream, b"{\"version\":2,\"paths\":[]}\n", deadline).unwrap(); + assert_eq!(receive(&mut stream, deadline).unwrap(), b"error"); + send(&mut stream, b"ack\n", deadline).unwrap(); + assert!(receiver.try_recv().is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 62b0013..d112c74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod cli; pub mod demo; pub mod discovery; pub mod fonts; +pub mod instance; mod keyboard; pub mod profiling; pub mod project_apps; diff --git a/src/main.rs b/src/main.rs index 082f64c..1384938 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +#![cfg_attr(target_os = "windows", windows_subsystem = "windows")] use diffy::{app, demo, repository}; use std::path::PathBuf; @@ -9,8 +10,9 @@ fn main() { let _trace = diffy::profiling::init(); let mut paths = Vec::new(); let mut check = false; - let mut demo_mode = false; + let mut demo_mode = std::env::var_os("DIFFY_SMOKE_RESULT").is_some(); let mut cli = false; + let mut smoke = demo_mode; let mut literal = false; for argument in std::env::args_os().skip(1) { if literal { @@ -21,6 +23,9 @@ fn main() { literal = true; } else if argument == "--cli" { cli = true; + } else if argument == "--smoke-test" { + smoke = true; + demo_mode = true; } else if argument == "--version" { println!("Diffy {}", env!("CARGO_PKG_VERSION")); return; @@ -86,7 +91,7 @@ fn main() { } return; } - if cli { + if cli && cfg!(target_os = "macos") { if let Err(error) = diffy::cli::launch(&paths) { eprintln!("{error}"); std::process::exit(1); @@ -94,6 +99,31 @@ fn main() { return; } let (requests, receiver) = async_channel::unbounded(); + #[cfg(not(target_os = "macos"))] + let _instance = if demo_mode { + None + } else { + let resolved = paths + .iter() + .map(|path| repository::resolve_root(path)) + .collect::, _>>(); + let state = diffy::state::default_path(); + match resolved.and_then(|roots| { + paths = roots; + diffy::instance::start( + &state.ok_or("User configuration directory unavailable")?, + paths.clone(), + requests.clone(), + ) + }) { + Ok(diffy::instance::Start::Forwarded) => return, + Ok(diffy::instance::Start::Primary(instance)) => Some(instance), + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + } + }; let reopen = requests.clone(); let application = gpui_kit::application().with_assets(gpui_kit::assets::Assets); application.on_open_urls(move |urls| { @@ -113,6 +143,22 @@ fn main() { return; } }; + if smoke { + let output = std::env::var_os("DIFFY_SMOKE_RESULT") + .expect("DIFFY_SMOKE_RESULT is required for --smoke-test"); + let _ = current.0.update(cx, |_, window, _| { + window.on_next_frame(move |window, _| { + window.refresh(); + // The native frame loop draws and presents after this + // callback. Report success on the following frame. + window.on_next_frame(move |_, cx| { + std::fs::write(output, "rendered\n").expect("write smoke result"); + cx.quit(); + }); + }); + window.refresh(); + }); + } // The receiver is registered before launching so cold-start URL events cannot be lost. cx.spawn(async move |cx| { while let Ok(urls) = receiver.recv().await { @@ -134,6 +180,7 @@ fn main() { } } let _ = current.0.update(cx, |_, window, cx| { + window.activate_window(); current .1 .update(cx, |view, cx| view.open_paths(paths, window, cx)); @@ -151,12 +198,23 @@ fn open_window( demo: bool, cx: &mut gpui_kit::App, ) -> Result<(gpui_kit::AnyWindowHandle, gpui_kit::Entity), String> { - let options = WindowOptions { + let mut options = WindowOptions { window_bounds: Some(WindowBounds::centered(size(px(1440.), px(900.)), cx)), window_min_size: Some(size(px(920.), px(560.))), app_id: Some("com.hitslop.diffy".into()), ..TitleBar::window_options() }; + if !cfg!(target_os = "macos") { + options.app_owns_titlebar_drag = false; + // Keep the OS/window-manager decorations: the existing app layout does + // not render GPUI Component's custom minimize/maximize/close controls. + options.titlebar = Some(gpui_kit::TitlebarOptions { + title: Some("Diffy".into()), + appears_transparent: false, + ..Default::default() + }); + options.window_decorations = Some(gpui_kit::WindowDecorations::Server); + } gpui_kit::open_window(options, cx, |window, cx| { cx.new(|cx| app::Diffy::with_options(paths, demo, diffy::state::default_path(), window, cx)) }) diff --git a/src/project_apps.rs b/src/project_apps.rs index eee6e8e..fa5579b 100644 --- a/src/project_apps.rs +++ b/src/project_apps.rs @@ -7,6 +7,9 @@ pub enum ProjectApp { Warp, Ghostty, Terminal, + WindowsTerminal, + PowerShell, + Alacritty, VSCode, VSCodeInsiders, Cursor, @@ -15,7 +18,17 @@ pub enum ProjectApp { } impl ProjectApp { + #[cfg(target_os = "macos")] pub const TERMINALS: [Self; 4] = [Self::ITerm, Self::Warp, Self::Ghostty, Self::Terminal]; + #[cfg(windows)] + pub const TERMINALS: [Self; 4] = [ + Self::WindowsTerminal, + Self::PowerShell, + Self::Warp, + Self::Ghostty, + ]; + #[cfg(all(unix, not(target_os = "macos")))] + pub const TERMINALS: [Self; 4] = [Self::Terminal, Self::Alacritty, Self::Warp, Self::Ghostty]; pub const EDITORS: [Self; 4] = [Self::VSCode, Self::VSCodeInsiders, Self::Cursor, Self::Zed]; pub fn label(self) -> &'static str { @@ -24,11 +37,14 @@ impl ProjectApp { Self::Warp => "Open in Warp", Self::Ghostty => "Open in Ghostty", Self::Terminal => "Open in Terminal", + Self::WindowsTerminal => "Open in Windows Terminal", + Self::PowerShell => "Open in PowerShell", + Self::Alacritty => "Open in Alacritty", Self::VSCode => "Open in VS Code", Self::VSCodeInsiders => "Open in VS Code Insiders", Self::Cursor => "Open in Cursor", Self::Zed => "Open in Zed", - Self::Finder => "Open in Finder", + Self::Finder => folder_label(), } } @@ -39,6 +55,7 @@ impl ProjectApp { Self::Warp => "dev.warp.Warp-Stable", Self::Ghostty => "com.mitchellh.ghostty", Self::Terminal => "com.apple.Terminal", + Self::WindowsTerminal | Self::PowerShell | Self::Alacritty => "", Self::VSCode => "com.microsoft.VSCode", Self::VSCodeInsiders => "com.microsoft.VSCodeInsiders", Self::Cursor => "com.todesktop.230313mzl4w4u92", @@ -76,21 +93,35 @@ impl Launcher for SystemLauncher { app.label().trim_start_matches("Open in ") ) })?; - let output = open_command(&application, root) - .output() - .map_err(|e| format!("Couldn't {}: {e}", app.label().to_lowercase()))?; - if output.status.success() { + let mut command = project_command(app, &application, root); + #[cfg(not(target_os = "macos"))] + { + // Terminals can remain attached for the life of the window. Spawning + // avoids holding a background worker until the user closes them. + command + .spawn() + .map_err(|e| format!("Couldn't {}: {e}", app.label().to_lowercase()))?; Ok(()) - } else { - Err(format!( - "Couldn't {}: {}", - app.label().to_lowercase(), - String::from_utf8_lossy(&output.stderr).trim() - )) + } + #[cfg(target_os = "macos")] + { + let output = command + .output() + .map_err(|e| format!("Couldn't {}: {e}", app.label().to_lowercase()))?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "Couldn't {}: {}", + app.label().to_lowercase(), + String::from_utf8_lossy(&output.stderr).trim() + )) + } } } } +#[cfg(target_os = "macos")] fn open_command(application: &Path, root: &Path) -> std::process::Command { let mut command = std::process::Command::new("/usr/bin/open"); command @@ -101,6 +132,102 @@ fn open_command(application: &Path, root: &Path) -> std::process::Command { command } +pub fn folder_label() -> &'static str { + if cfg!(target_os = "macos") { + "Open in Finder" + } else if cfg!(windows) { + "Open in Explorer" + } else { + "Open in File Manager" + } +} +pub fn reveal_label() -> &'static str { + if cfg!(target_os = "macos") { + "Reveal in Finder" + } else if cfg!(windows) { + "Reveal in Explorer" + } else { + "Reveal in File Manager" + } +} +fn project_command(app: ProjectApp, application: &Path, root: &Path) -> std::process::Command { + #[cfg(target_os = "macos")] + { + let _ = app; + open_command(application, root) + } + #[cfg(not(target_os = "macos"))] + { + if app == ProjectApp::Warp { + let mut url = url::Url::parse("warp://action/new_window").expect("static Warp URL"); + url.query_pairs_mut() + .append_pair("path", &root.to_string_lossy()); + #[cfg(target_os = "linux")] + { + let mut command = std::process::Command::new("xdg-open"); + command.arg(url.as_str()); + return command; + } + #[cfg(windows)] + { + use base64::Engine as _; + use std::os::windows::process::CommandExt as _; + let script = format!("Start-Process '{}'", url.as_str().replace('\'', "''")); + let bytes: Vec = script.encode_utf16().flat_map(u16::to_le_bytes).collect(); + let mut command = std::process::Command::new("powershell.exe"); + command + .args(["-NoProfile", "-EncodedCommand"]) + .arg(base64::engine::general_purpose::STANDARD.encode(bytes)) + .creation_flags(0x08000000); + return command; + } + } + let mut command = std::process::Command::new(application); + command.current_dir(root); + match app { + ProjectApp::WindowsTerminal => { + command.arg("-d").arg(root); + } + ProjectApp::PowerShell => { + use base64::Engine as _; + let script = format!( + "Set-Location -LiteralPath '{}'", + root.to_string_lossy().replace('\'', "''") + ); + let encoded: Vec = script.encode_utf16().flat_map(u16::to_le_bytes).collect(); + command + .args(["-NoExit", "-EncodedCommand"]) + .arg(base64::engine::general_purpose::STANDARD.encode(encoded)); + } + ProjectApp::Ghostty => { + command.arg(format!("--working-directory={}", root.display())); + } + ProjectApp::Alacritty => { + command.arg("--working-directory").arg(root); + } + ProjectApp::Terminal => { + match application + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default() + { + "konsole" => { + command.arg("--workdir").arg(root); + } + "gnome-terminal" | "xfce4-terminal" => { + command.arg("--working-directory").arg(root); + } + _ => {} + } + } + _ => { + command.arg(root); + } + } + command + } +} + #[cfg(target_os = "macos")] fn application_path(app: ProjectApp) -> Option { // LaunchServices also knows about apps outside /Applications. Querying @@ -114,15 +241,71 @@ fn application_path(app: ProjectApp) -> Option { }) } -#[cfg(not(target_os = "macos"))] -fn application_path(_: ProjectApp) -> Option { - None +#[cfg(target_os = "linux")] +fn application_path(app: ProjectApp) -> Option { + let names: &[&str] = match app { + ProjectApp::Terminal => &[ + "gnome-terminal", + "konsole", + "xfce4-terminal", + "x-terminal-emulator", + "xterm", + ], + ProjectApp::Warp => &["warp-terminal"], + ProjectApp::Ghostty => &["ghostty"], + ProjectApp::Alacritty => &["alacritty"], + ProjectApp::VSCode => &["code"], + ProjectApp::VSCodeInsiders => &["code-insiders"], + ProjectApp::Cursor => &["cursor"], + ProjectApp::Zed => &["zed", "zeditor"], + ProjectApp::Finder => &["xdg-open"], + _ => &[], + }; + names.iter().find_map(|name| which::which(name).ok()) +} +#[cfg(windows)] +fn application_path(app: ProjectApp) -> Option { + let names: &[&str] = match app { + ProjectApp::WindowsTerminal => &["wt.exe"], + ProjectApp::PowerShell => &["pwsh.exe", "powershell.exe"], + ProjectApp::Warp => &["warp.exe"], + ProjectApp::VSCode => &["Code.exe"], + ProjectApp::VSCodeInsiders => &["Code - Insiders.exe"], + ProjectApp::Cursor => &["Cursor.exe"], + ProjectApp::Zed => &["zed.exe"], + ProjectApp::Finder => &["explorer.exe"], + _ => &[], + }; + if let Some(path) = names.iter().find_map(|name| which::which(name).ok()) { + return Some(path); + } + let suffixes: &[&str] = match app { + ProjectApp::VSCode => &["Microsoft VS Code/Code.exe"], + ProjectApp::VSCodeInsiders => &["Microsoft VS Code Insiders/Code - Insiders.exe"], + ProjectApp::Cursor => &["cursor/Cursor.exe"], + ProjectApp::Zed => &["Zed/zed.exe"], + ProjectApp::Warp => &["Warp/warp.exe"], + _ => &[], + }; + let mut bases: Vec = ["ProgramFiles", "ProgramFiles(x86)"] + .into_iter() + .filter_map(std::env::var_os) + .map(PathBuf::from) + .collect(); + if let Some(dirs) = directories::BaseDirs::new() { + bases.push(dirs.data_local_dir().join("Programs")); + } + bases + .iter() + .flat_map(|base| suffixes.iter().map(move |suffix| base.join(suffix))) + .find(|path| path.is_file()) } #[cfg(test)] mod tests { use super::*; + #[cfg(target_os = "macos")] #[test] fn launching_uses_literal_arguments_and_a_safe_working_directory() { let app = Path::new("/Applications/Visual Studio Code.app"); diff --git a/src/repository.rs b/src/repository.rs index b809c30..b7b2bce 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -119,6 +119,18 @@ fn git_launch() -> Result<&'static GitLaunch, String> { ); #[cfg(all(unix, not(target_os = "macos")))] paths.extend(["/usr/local/bin", "/usr/bin", "/bin"].map(PathBuf::from)); + #[cfg(windows)] + { + paths.extend( + ["ProgramFiles", "ProgramFiles(x86)"] + .into_iter() + .filter_map(std::env::var_os) + .map(|root| PathBuf::from(root).join("Git/cmd")), + ); + if let Some(dirs) = directories::BaseDirs::new() { + paths.push(dirs.data_local_dir().join("Programs/Git/cmd")); + } + } let name = if cfg!(windows) { "git.exe" } else { "git" }; let program = paths .iter() @@ -151,6 +163,11 @@ fn git_launch() -> Result<&'static GitLaunch, String> { fn git_command() -> Result { let launch = git_launch()?; let mut command = Command::new(&launch.program); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + command.creation_flags(0x08000000); // CREATE_NO_WINDOW: background Git reads must not open consoles. + } if let Some(path) = &launch.path { command.env("PATH", path); } @@ -523,13 +540,11 @@ mod tests { impl TestRepository { fn new() -> Self { - let suffix = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let path = tempfile::Builder::new() + .prefix("diffy-repository-test-") + .tempdir() .unwrap() - .as_nanos(); - let path = - std::env::temp_dir().join(format!("differ-test-{}-{suffix}", std::process::id())); - std::fs::create_dir(&path).unwrap(); + .keep(); git(&path, &["init", "--initial-branch=main"]).unwrap(); Self(path) } @@ -654,7 +669,10 @@ mod tests { #[test] fn literal_pathspecs_and_unborn_unstage() { let repo = TestRepository::new(); + #[cfg(not(windows))] let name = ":(glob)* [é]\t\".rs"; + #[cfg(windows)] + let name = "[é] 日本語 space.rs"; repo.write(name, "literal\n"); repo.write("other.rs", "other\n"); let snapshot = load(&repo.0).unwrap(); @@ -685,7 +703,14 @@ mod tests { repo.write("one.rs", "index\n"); git(&repo.0, &["add", "one.rs"]).unwrap(); repo.write("one.rs", "latest\n"); - repo.write(":(glob)*.rs", "literal\n"); + repo.write( + if cfg!(windows) { + "[é] space.rs" + } else { + ":(glob)*.rs" + }, + "literal\n", + ); repo.write("ignored.rs", "ignored\n"); repo.write(".git/info/exclude", "ignored.rs\n"); std::fs::remove_file(repo.0.join("delete.rs")).unwrap(); @@ -872,7 +897,14 @@ mod tests { git(&repo.0, &["add", "one.rs"]).unwrap(); repo.write("one.rs", "working\n"); git(&repo.0, &["mv", "old.rs", "new.rs"]).unwrap(); - repo.write(":(glob)* unusual\nname.rs", "new\n"); + repo.write( + if cfg!(windows) { + "[é] unusual name.rs" + } else { + ":(glob)* unusual\nname.rs" + }, + "new\n", + ); std::fs::write(repo.0.join("binary.dat"), b"\0binary").unwrap(); let before = std::fs::read(repo.0.join(".git/index")).unwrap(); let listing = load_metadata(&repo.0).unwrap(); diff --git a/src/repository/metadata/native.rs b/src/repository/metadata/native.rs index 02dac49..fb1dcdf 100644 --- a/src/repository/metadata/native.rs +++ b/src/repository/metadata/native.rs @@ -61,6 +61,17 @@ pub(super) fn load(path: &Path) -> Result { // Unsupported index states go to Git before starting the expensive scan. let index = repo.index_or_empty().map_err(|_| "native index read")?; let config = repo.config_snapshot(); + // Windows canonical paths can use extended/long names while Git's + // conditional include matcher sees DOS aliases. The pinned gix release + // resolves these differently; retain Git's ignore/config decisions. + if cfg!(windows) + && config + .plumbing() + .sections() + .any(|section| section.header().name().eq_ignore_ascii_case(b"includeIf")) + { + return Err("conditional Git configuration on Windows"); + } if config.string("core.autocrlf").is_some() && config.boolean("core.autocrlf") != Some(false) { return Err("worktree content conversion"); } diff --git a/src/repository/worktrees.rs b/src/repository/worktrees.rs index 8a3a994..2a26424 100644 --- a/src/repository/worktrees.rs +++ b/src/repository/worktrees.rs @@ -197,7 +197,11 @@ mod tests { fn main_linked_locked_missing_and_literal_paths_match_git() { let f = fixture::Fixture::new("worktrees"); f.git(&["commit", "--allow-empty", "-m", "initial"]); - let linked = f.0.join("checkout with\nquote'ü"); + let linked = f.0.join(if cfg!(windows) { + "checkout with quote'ü" + } else { + "checkout with\nquote'ü" + }); f.git(&["worktree", "add", "-b", "topic", linked.to_str().unwrap()]); f.git(&[ "worktree", diff --git a/src/state.rs b/src/state.rs index bc2b999..84707cf 100644 --- a/src/state.rs +++ b/src/state.rs @@ -95,7 +95,7 @@ pub fn default_path() -> Option { preference_path( std::env::var_os("DIFFY_STATE_PATH"), std::env::var_os("DIFFER_STATE_PATH"), - std::env::var_os("HOME"), + directories::BaseDirs::new().map(|dirs| dirs.home_dir().as_os_str().to_owned()), ) } fn preference_path( @@ -104,11 +104,23 @@ fn preference_path( home: Option, ) -> Option { current.or(legacy).map(PathBuf::from).or_else(|| { - home.map(|home| PathBuf::from(home).join("Library/Application Support/Diffy/state.json")) + #[cfg(target_os = "macos")] + { + home.map(|home| { + PathBuf::from(home).join("Library/Application Support/Diffy/state.json") + }) + } + #[cfg(not(target_os = "macos"))] + { + let _ = home; + directories::ProjectDirs::from("com", "hitSlop", "Diffy") + .map(|dirs| dirs.config_local_dir().join("state.json")) + } }) } pub fn load(path: &Path) -> Result { - let migration_allowed = std::env::var_os("DIFFY_STATE_PATH").is_none() + let migration_allowed = cfg!(target_os = "macos") + && std::env::var_os("DIFFY_STATE_PATH").is_none() && std::env::var_os("DIFFER_STATE_PATH").is_none() && default_path().as_deref() == Some(path); let legacy = migration_allowed @@ -124,7 +136,8 @@ pub fn load(path: &Path) -> Result { /// Restore on the UI thread without performing a migration write. The application's /// background writer creates the current preferences file after restoration. pub fn load_for_restore(path: &Path) -> Result { - if path.exists() + if !cfg!(target_os = "macos") + || path.exists() || std::env::var_os("DIFFY_STATE_PATH").is_some() || std::env::var_os("DIFFER_STATE_PATH").is_some() || default_path().as_deref() != Some(path) @@ -239,13 +252,21 @@ mod tests { preference_path(None, Some("/old".into()), Some("/home".into())), Some(PathBuf::from("/old")) ); + #[cfg(target_os = "macos")] assert_eq!( preference_path(None, None, Some("/home".into())), Some(PathBuf::from( "/home/Library/Application Support/Diffy/state.json" )) ); + #[cfg(target_os = "macos")] assert_eq!(preference_path(None, None, None), None); + #[cfg(not(target_os = "macos"))] + assert_eq!( + preference_path(None, None, None), + directories::ProjectDirs::from("com", "hitSlop", "Diffy") + .map(|dirs| dirs.config_local_dir().join("state.json")) + ); } #[test] fn preferences_round_trip_and_removal_only_updates_state() { diff --git a/src/state/writer.rs b/src/state/writer.rs index 35a84c0..96647f1 100644 --- a/src/state/writer.rs +++ b/src/state/writer.rs @@ -113,11 +113,8 @@ mod tests { use super::*; #[test] fn newest_save_wins_and_flush_observes_errors() { - let root = std::env::temp_dir().join(format!( - "diffy-writer-{}-{:?}", - std::process::id(), - std::time::SystemTime::now() - )); + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); let path = root.join("state.json"); let writer = Writer::new(path.clone()).unwrap(); for width in 240..460 { diff --git a/src/terminal.rs b/src/terminal.rs new file mode 100644 index 0000000..02a46d7 --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,73 @@ +//! Console launcher, also used as the installed Windows command shim. +fn main() { + let result = run(); + if let Err(error) = result { + eprintln!("{error}"); + std::process::exit(1); + } +} +fn run() -> Result<(), String> { + let args: Vec<_> = std::env::args_os().skip(1).collect(); + let current = std::env::current_exe().map_err(|e| e.to_string())?; + let parent = current.parent().ok_or("Couldn't locate the launcher")?; + let config = parent.join("diffy-launch.json"); + if config.exists() { + let gui = diffy::cli::launcher_target(&config)?; + let console = gui + .parent() + .ok_or("Invalid application directory")? + .join("diffy-cli.exe"); + if !console.is_file() || console == current { + return Err("Reinstall the terminal command from Diffy.".into()); + } + // Delegate even --version and --check so an installed shim always uses + // the console binary shipped by the latest application update. + let status = std::process::Command::new(console) + .args(args) + .status() + .map_err(|e| e.to_string())?; + return if status.success() { + Ok(()) + } else { + Err("The Diffy command failed.".into()) + }; + } + if args.iter().any(|arg| arg == "--version") { + println!("Diffy {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + if args.iter().any(|arg| arg == "--help") { + println!( + "Usage: diffy [--check] [repository ...]\nOpen repositories in Diffy. --check validates repositories without opening a window." + ); + return Ok(()); + } + let check = args.first().is_some_and(|arg| arg == "--check"); + let paths = args + .into_iter() + .skip(usize::from(check)) + .map(std::path::PathBuf::from) + .map(|path| diffy::repository::resolve_root(&path)) + .collect::, _>>()?; + if check { + for path in paths { + let snapshot = diffy::repository::load_metadata(&path)?; + println!( + "{}: {} staged, {} changes", + snapshot.root.display(), + snapshot.groups[0].len(), + snapshot.groups[1].len() + ); + } + return Ok(()); + } + let executable = parent.join(if cfg!(windows) { "diffy.exe" } else { "diffy" }); + if !executable.is_file() || executable == current { + return Err("Reinstall the terminal command from Diffy.".into()); + } + std::process::Command::new(executable) + .args(paths) + .spawn() + .map_err(|e| format!("Couldn't open Diffy: {e}"))?; + Ok(()) +} diff --git a/src/updates.rs b/src/updates.rs index 64090e5..949891f 100644 --- a/src/updates.rs +++ b/src/updates.rs @@ -12,6 +12,70 @@ pub const ENDPOINT: &str = "https://github.com/hitSlop/diffy/releases/latest/download/update-macos-aarch64.json"; pub const PUBLIC_KEY: &str = include_str!("../packaging/updater.pub"); +#[derive(Clone, Copy, Debug)] +pub struct Target { + pub id: &'static str, + pub endpoint: &'static str, + pub format: UpdateFormat, +} +impl Target { + pub fn for_platform(os: &str, arch: &str) -> Option { + match (os, arch) { + ("macos", "aarch64") => Some(Self { + id: "macos-aarch64", + endpoint: ENDPOINT, + format: UpdateFormat::App, + }), + ("windows", "x86_64") => Some(Self { + id: "windows-x86_64", + endpoint: "https://github.com/hitSlop/diffy/releases/latest/download/update-windows-x86_64.json", + format: UpdateFormat::Nsis, + }), + ("linux", "x86_64") => Some(Self { + id: "linux-x86_64", + endpoint: "https://github.com/hitSlop/diffy/releases/latest/download/update-linux-x86_64.json", + format: UpdateFormat::AppImage, + }), + _ => None, + } + } + pub fn current() -> Option { + Self::for_platform(std::env::consts::OS, std::env::consts::ARCH) + } +} + +pub fn launch_executable() -> Result { + #[cfg(target_os = "linux")] + if let Some(path) = std::env::var_os("APPIMAGE") { + return Ok(PathBuf::from(path)); + } + std::env::current_exe().map_err(|e| e.to_string()) +} +fn installation_path() -> Result { + let executable = launch_executable()?; + #[cfg(target_os = "macos")] + return bundle_path(&executable) + .ok_or("Install Diffy.app in Applications to use automatic updates.".into()); + #[cfg(target_os = "windows")] + { + if !matches!(env!("DIFFY_PACKAGE_KIND"), "nsis") + || !executable + .parent() + .is_some_and(|parent| parent.join("diffy-cli.exe").is_file()) + { + return Err( + "Install Diffy using its Windows installer to use automatic updates.".into(), + ); + } + } + #[cfg(target_os = "linux")] + if std::env::var_os("APPIMAGE").is_none() { + return Err("Use the packaged Diffy AppImage to install updates.".into()); + } + #[cfg(not(target_os = "macos"))] + Ok(executable) +} + pub trait Package: Send + Sync { fn download(&self, progress: &dyn Fn(usize, Option)) -> Result, String>; fn install(&self, bytes: Vec) -> Result<(), String>; @@ -108,14 +172,16 @@ pub fn bundle_path(executable: &Path) -> Option { .then(|| bundle.to_path_buf()) } pub fn installation_problem() -> Option { - if !cfg!(all(target_os = "macos", target_arch = "aarch64")) { - return Some("Updates currently support Apple Silicon Macs only.".into()); + if Target::current().is_none() { + return Some("Updates aren't available for this platform or architecture.".into()); } - let bundle = std::env::current_exe().ok().and_then(|p| bundle_path(&p)); - let Some(bundle) = bundle else { - return Some("Install Diffy.app in Applications to use automatic updates.".into()); + let bundle = match installation_path() { + Ok(path) => path, + Err(error) => return Some(error), }; - if bundle.to_string_lossy().contains("AppTranslocation") || bundle.starts_with("/Volumes") { + if cfg!(target_os = "macos") + && (bundle.to_string_lossy().contains("AppTranslocation") || bundle.starts_with("/Volumes")) + { return Some("Move Diffy.app to Applications and reopen it before updating.".into()); } let Some(parent) = bundle.parent() else { @@ -125,17 +191,15 @@ pub fn installation_problem() -> Option { let probe = parent.join(format!(".diffy-write-check-{}", std::process::id())); match std::fs::OpenOptions::new().write(true).create_new(true).open(&probe) { Ok(_) => { let _ = std::fs::remove_file(probe); None } - Err(_) => Some("Diffy can't update this read-only location. Install it in a writable Applications folder.".into()), + Err(_) => Some("Diffy can't update this read-only location. Move it to a writable installation location.".into()), } } struct GithubBackend; impl Backend for GithubBackend { fn check(&self) -> Result, String> { - let bundle = std::env::current_exe() - .ok() - .and_then(|p| bundle_path(&p)) - .ok_or("Install Diffy.app in Applications to check for updates.")?; + let bundle = installation_path()?; + let target = Target::current().ok_or("Unsupported update target")?; let _ = rustls::crypto::ring::default_provider().install_default(); let response = reqwest::blocking::Client::builder() .https_only(true) @@ -143,7 +207,7 @@ impl Backend for GithubBackend { .timeout(Duration::from_secs(15)) .build() .map_err(|e| e.to_string())? - .get(ENDPOINT) + .get(target.endpoint) .header("Accept", "application/json") .header("User-Agent", concat!("Diffy/", env!("CARGO_PKG_VERSION"))) .send() @@ -163,10 +227,14 @@ impl Backend for GithubBackend { if bytes.len() > 1_048_576 { return Err("Update manifest is too large.".into()); } - release_from_manifest(&bytes, bundle) + release_for_target(&bytes, bundle, target) } } -fn release_from_manifest(bytes: &[u8], bundle: PathBuf) -> Result, String> { +fn release_for_target( + bytes: &[u8], + bundle: PathBuf, + target: Target, +) -> Result, String> { let release: RemoteRelease = serde_json::from_slice(bytes).map_err(|e| format!("Invalid update manifest: {e}"))?; if release.version @@ -176,38 +244,42 @@ fn release_from_manifest(bytes: &[u8], bundle: PathBuf) -> Result Result, String> { + release_for_target( + bytes, + bundle, + Target::for_platform("macos", "aarch64").unwrap(), + ) + } + #[test] + fn platform_packages_are_selected_and_mismatches_rejected() { + for (os, arch, format) in [ + ("macos", "aarch64", "app"), + ("windows", "x86_64", "nsis"), + ("linux", "x86_64", "appimage"), + ] { + let target = Target::for_platform(os, arch).unwrap(); + let manifest = serde_json::to_vec(&serde_json::json!({"version":"99.0.0", "platforms":{target.id:{"url":"https://example.com/update", "format":format, "signature":"fixture"}}})).unwrap(); + assert!( + release_for_target(&manifest, PathBuf::from("fixture"), target) + .unwrap() + .is_some() + ); + let wrong = + Target::for_platform(if os == "linux" { "windows" } else { "linux" }, "x86_64") + .unwrap(); + assert!(release_for_target(&manifest, PathBuf::from("fixture"), wrong).is_err()); + } + assert!(Target::for_platform("windows", "aarch64").is_none()); + } struct FakePackage; impl Package for FakePackage { fn download(&self, progress: &dyn Fn(usize, Option)) -> Result, String> { diff --git a/tests/gix_comparison.rs b/tests/gix_comparison.rs index 8f3bd68..2080e86 100644 --- a/tests/gix_comparison.rs +++ b/tests/gix_comparison.rs @@ -44,6 +44,16 @@ fn commit(f: &fixture::Fixture) { f.git(&["commit", "-m", "fixture"]); } +fn git_directory_pattern(path: &Path) -> String { + let path = path.to_string_lossy(); + #[cfg(windows)] + let path = path + .strip_prefix(r"\\?\") + .unwrap_or(&path) + .replace('\\', "/"); + format!("{path}/.git") +} + #[test] fn production_adapter_matches_complete_metadata_for_both_workloads() { for mixed in [false, true] { @@ -83,7 +93,14 @@ fn exact_rename_literal_paths_preview_and_writes_work_across_backends() { commit(&f); f.git(&["mv", "old.rs", "new.rs"]); f.write("new.rs", "working change after rename\n"); - f.write(":(glob)*\n雪.rs", "literal file\n"); + f.write( + if cfg!(windows) { + "[é] 雪 space.rs" + } else { + ":(glob)*\n雪.rs" + }, + "literal file\n", + ); let listing = parity(&f.0, MetadataBackend::Gix); assert_eq!(listing.groups[0][0].original.as_deref(), Some("old.rs")); for group in [ChangeGroup::Staged, ChangeGroup::Changes] { @@ -143,12 +160,23 @@ fn ignore_rules_conditional_includes_and_status_overrides() { let excludes = f.0.join(".git/extra-excludes"); fs::write(&excludes, "config-only.txt\n").unwrap(); let included = f.0.join(".git/included-config"); - fs::write(&included, format!("[core]\nexcludesFile = {}\n[status]\nshowUntrackedFiles = no\nrenames = false\n[diff]\nrenames = copies\n", excludes.display())).unwrap(); + fs::write( + &included, + "[status]\nshowUntrackedFiles = no\nrenames = false\n[diff]\nrenames = copies\n", + ) + .unwrap(); + f.git(&[ + "config", + "--file", + included.to_str().unwrap(), + "core.excludesFile", + excludes.to_str().unwrap(), + ]); f.git(&[ "config", &format!( - "includeIf.gitdir:{}/.git.path", - fs::canonicalize(&f.0).unwrap().display() + "includeIf.gitdir:{}.path", + git_directory_pattern(&fs::canonicalize(&f.0).unwrap()) ), included.to_str().unwrap(), ]); @@ -165,7 +193,20 @@ fn ignore_rules_conditional_includes_and_status_overrides() { f.write(name, "new\n"); } fs::create_dir(f.0.join("empty-dir")).unwrap(); - let listing = parity(&f.0, MetadataBackend::Gix); + let listing = parity( + &f.0, + if cfg!(windows) { + MetadataBackend::Git + } else { + MetadataBackend::Gix + }, + ); + if cfg!(windows) { + assert_eq!( + listing.fallback_reason, + Some("conditional Git configuration on Windows") + ); + } assert!( listing.groups[1] .iter() @@ -478,11 +519,13 @@ fn alternate_index_and_global_config_are_preserved_via_fallback() { let global = f.0.join(".git/global-config"); let excludes = f.0.join(".git/global-excludes"); fs::write(&excludes, "global-only.txt\n").unwrap(); - fs::write( - &global, - format!("[core]\nexcludesFile = {}\n", excludes.display()), - ) - .unwrap(); + f.git(&[ + "config", + "--file", + global.to_str().unwrap(), + "core.excludesFile", + excludes.to_str().unwrap(), + ]); f.write("global-only.txt", "ignored\n"); let original = fs::read(f.0.join(".git/index")).unwrap(); let alt_before = fs::read(&alternate).unwrap(); @@ -525,7 +568,14 @@ fn gix_handles_literal_names_renames_and_linked_worktrees() { fixture.git(&["commit", "-m", "base"]); fixture.git(&["mv", "old.rs", "new.rs"]); fixture.write("new.rs", "unstaged content\n"); - fixture.write(":(glob)*\n雪.rs", "literal new file\n"); + fixture.write( + if cfg!(windows) { + "[é] 雪 space.rs" + } else { + ":(glob)*\n雪.rs" + }, + "literal new file\n", + ); let metadata = diffy::repository::load_metadata(&fixture.0).unwrap(); let expected = metadata.groups.map(|files| { files diff --git a/tests/performance_ui.rs b/tests/performance_ui.rs index 7cc3994..3ce8f95 100644 --- a/tests/performance_ui.rs +++ b/tests/performance_ui.rs @@ -120,11 +120,11 @@ fn main() { "Row utilities do not select another file" ); cx.update_window(handle, |_, window, cx| { - window.press("cmd-=", cx); + window.press("secondary-=", cx); assert_eq!(view.read(cx).reader_settings().font_size, 14.); - window.press("cmd-0", cx); + window.press("secondary-0", cx); assert_eq!(view.read(cx).reader_settings().font_size, 13.); - window.press("cmd-shift-=", cx); + window.press("secondary-shift-=", cx); assert_eq!(view.read(cx).reader_settings().font_size, 14.); }) .unwrap(); diff --git a/tests/project_menu_ui.rs b/tests/project_menu_ui.rs index 2f1003c..61c8652 100644 --- a/tests/project_menu_ui.rs +++ b/tests/project_menu_ui.rs @@ -116,8 +116,11 @@ fn main() { } state::save(&prefs, &initial).unwrap(); let launcher = Arc::new(MockLauncher::default()); - *launcher.installed.lock().unwrap() = - vec![ProjectApp::Cursor, ProjectApp::ITerm, ProjectApp::Cursor]; + *launcher.installed.lock().unwrap() = vec![ + ProjectApp::Cursor, + ProjectApp::TERMINALS[0], + ProjectApp::Cursor, + ]; let mut cx = HeadlessAppContext::with_platform( gpui_kit::platform::current_platform(true).text_system(), Arc::new(Assets), @@ -183,9 +186,9 @@ fn main() { labels(window), [ "Edit Project…", - "Open in iTerm", + ProjectApp::TERMINALS[0].label(), "Open in Cursor", - "Open in Finder", + diffy::project_apps::folder_label(), "Remove Project" ] ); @@ -211,7 +214,11 @@ fn main() { window.render_frame(cx); }) .unwrap(); - for app in [ProjectApp::ITerm, ProjectApp::Cursor, ProjectApp::Finder] { + for app in [ + ProjectApp::TERMINALS[0], + ProjectApp::Cursor, + ProjectApp::Finder, + ] { open_menu!(); cx.update_window(handle, |_, window, cx| click_menu(window, app.label(), cx)) .unwrap(); @@ -268,7 +275,11 @@ fn main() { cx.update_window(handle, |_, window, cx| { assert_eq!( labels(window), - ["Edit Project…", "Open in Finder", "Remove Project"] + [ + "Edit Project…", + diffy::project_apps::folder_label(), + "Remove Project" + ] ); click_menu(window, "Remove Project", cx); }) diff --git a/tests/ui.rs b/tests/ui.rs index 6062e1f..61be167 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -99,7 +99,7 @@ fn main() { ); w.render_frame(cx); }; - let cmd = Modifiers::command(); + let cmd = Modifiers::secondary_key(); scroll( w, ScrollDelta::Pixels(point(px(0.), px(-39.))), @@ -172,7 +172,7 @@ fn main() { Some("src/duration.rs"), "One mouse-wheel notch with Command moves one file" ); - w.press("cmd-p", cx); + w.press("secondary-p", cx); w.render_frame(cx); assert!(view.read(cx).palette_is_open()); scroll( @@ -190,7 +190,7 @@ fn main() { w.press("escape", cx); w.render_frame(cx); assert!(!view.read(cx).palette_is_open()); - w.press("cmd-up", cx); + w.press("secondary-up", cx); w.render_frame(cx); assert_eq!( view.read(cx).selected_file().map(|p| p.as_str()), @@ -202,23 +202,23 @@ fn main() { // Quick taps never throttle, and holding repeats even without native repeat/key-up events. cx.update_window(handle, |_, w, cx| { view.update(cx, |v, _| v.set_file_key_probe(file_keys_held)); - w.press("cmd-down", cx); + w.press("secondary-down", cx); w.render_frame(cx); - w.press("cmd-down", cx); + w.press("secondary-down", cx); w.render_frame(cx); assert_eq!( view.read(cx).selected_file().map(|p| p.as_str()), Some("tests/timeout_test.rs") ); - w.press("cmd-up", cx); + w.press("secondary-up", cx); w.render_frame(cx); - w.press("cmd-up", cx); + w.press("secondary-up", cx); w.render_frame(cx); FILE_KEYS_HELD.store(true, Ordering::SeqCst); use gpui_kit::InputEvent as _; w.dispatch_event( gpui_kit::KeyDownEvent { - keystroke: gpui_kit::Keystroke::parse("cmd-down").unwrap(), + keystroke: gpui_kit::Keystroke::parse("secondary-down").unwrap(), is_held: false, prefer_character_input: false, } @@ -267,7 +267,7 @@ fn main() { for _ in 0..2 { w.dispatch_event( gpui_kit::KeyDownEvent { - keystroke: gpui_kit::Keystroke::parse("cmd-up").unwrap(), + keystroke: gpui_kit::Keystroke::parse("secondary-up").unwrap(), is_held: false, prefer_character_input: false, } @@ -285,8 +285,8 @@ fn main() { .unwrap(); cx.update_window(handle, |_, window, cx| { - window.press("cmd-enter", cx); - window.press("cmd-shift-down", cx); + window.press("secondary-enter", cx); + window.press("secondary-shift-down", cx); }) .unwrap(); cx.run_until_parked(); @@ -321,15 +321,15 @@ fn main() { view.read(cx).selected_file().map(|p| p.as_str()), Some("src/duration.rs") ); - window.press("cmd-down", cx); + window.press("secondary-down", cx); window.render_frame(cx); assert_eq!( view.read(cx).selected_file().map(|p| p.as_str()), Some("tests/timeout_test.rs") ); - window.press("cmd-up", cx); + window.press("secondary-up", cx); window.render_frame(cx); - window.press("cmd-shift-s", cx); + window.press("secondary-shift-s", cx); window.render_frame(cx); assert_eq!( view.read(cx).selected_group(), @@ -345,35 +345,35 @@ fn main() { ); window.click("src/duration.rs", cx); window.render_frame(cx); - window.press("cmd-s", cx); + window.press("secondary-s", cx); window.render_frame(cx); assert_eq!( view.read(cx).selected_file().map(|p| p.as_str()), Some("src/duration.rs"), "Demo mutation disabled" ); - window.press("cmd-shift-s", cx); + window.press("secondary-shift-s", cx); window.render_frame(cx); assert_eq!( view.read(cx).selected_file().map(|p| p.as_str()), Some("src/duration.rs"), "Scope selection retained" ); - window.press("cmd-shift-s", cx); + window.press("secondary-shift-s", cx); window.render_frame(cx); - window.press("cmd-shift-down", cx); + window.press("secondary-shift-down", cx); window.render_frame(cx); assert_eq!( view.read(cx).selected_group(), diffy::repository::ChangeGroup::Changes ); - window.press("cmd-shift-s", cx); + window.press("secondary-shift-s", cx); window.render_frame(cx); assert!( view.read(cx).selected_file().is_none(), "Slide staged scope empty" ); - window.press("cmd-shift-up", cx); + window.press("secondary-shift-up", cx); window.render_frame(cx); assert_eq!( view.read(cx).selected_group(), @@ -444,7 +444,7 @@ fn main() { macro_rules! palette_confirm { ($query:expr) => {{ cx.update_window(handle, |_, w, cx| { - w.press("cmd-p", cx); + w.press("secondary-p", cx); w.render_frame(cx); assert!(view.read(cx).palette_is_open()); w.input($query, cx); @@ -490,7 +490,7 @@ fn main() { w.press("escape", cx); w.render_frame(cx); assert!(!view.read(cx).palette_is_open()); - w.press("cmd-p", cx); + w.press("secondary-p", cx); w.render_frame(cx); }) .unwrap(); @@ -629,7 +629,7 @@ fn main() { settle!(); cx.update_window(real_handle, |_, w, cx| { assert!(real_view.read(cx).selected_file().is_none()); - w.press("cmd-shift-s", cx); + w.press("secondary-shift-s", cx); w.render_frame(cx); w.click("stage-all", cx); }) @@ -637,7 +637,7 @@ fn main() { settle!(); cx.update_window(real_handle, |_, w, cx| { assert!(real_view.read(cx).selected_file().is_none()); - w.press("cmd-shift-s", cx); + w.press("secondary-shift-s", cx); w.render_frame(cx); }) .unwrap(); @@ -658,14 +658,14 @@ fn main() { Some("a.rs"), "Staging a different row preserves selection" ); - w.press("cmd-s", cx); - w.press("cmd-s", cx); + w.press("secondary-s", cx); + w.press("secondary-s", cx); }) .unwrap(); settle!(); cx.update_window(real_handle, |_, w, cx| { assert!(real_view.read(cx).selected_file().is_none()); - w.press("cmd-shift-s", cx); + w.press("secondary-shift-s", cx); w.render_frame(cx); assert_eq!( real_view.read(cx).selected_file().map(|p| p.as_str()), @@ -684,7 +684,7 @@ fn main() { real_view.read(cx).selected_file().map(|p| p.as_str()), Some("b.rs") ); - w.press("cmd-shift-s", cx); + w.press("secondary-shift-s", cx); w.render_frame(cx); assert_eq!( real_view.read(cx).selected_file().map(|p| p.as_str()), @@ -693,7 +693,7 @@ fn main() { }) .unwrap(); std::fs::write(root.join(".git/index.lock"), b"ui test lock").unwrap(); - cx.update_window(real_handle, |_, w, cx| w.press("cmd-s", cx)) + cx.update_window(real_handle, |_, w, cx| w.press("secondary-s", cx)) .unwrap(); settle!(); cx.update_window(real_handle, |_, w, cx| { @@ -708,7 +708,7 @@ fn main() { }) .unwrap(); std::fs::remove_file(root.join(".git/index.lock")).unwrap(); - cx.update_window(real_handle, |_, w, cx| w.press("cmd-s", cx)) + cx.update_window(real_handle, |_, w, cx| w.press("secondary-s", cx)) .unwrap(); settle!(); cx.update_window(real_handle, |_, _, cx| { @@ -718,7 +718,7 @@ fn main() { assert_eq!(std::fs::read_to_string(root.join("a.rs")).unwrap(), "one\n"); assert_eq!(std::fs::read_to_string(root.join("b.rs")).unwrap(), "two\n"); cx.update_window(real_handle, |_, w, cx| { - w.press("cmd-shift-s", cx); + w.press("secondary-shift-s", cx); w.render_frame(cx); w.click("b.rs", cx); w.dispatch_action(Box::new(app::SplitMode), cx); @@ -739,6 +739,9 @@ fn main() { .unwrap(); cx.advance_clock(std::time::Duration::from_millis(300)); cx.run_until_parked(); + cx.update(|cx| real_view.read(cx).preferences_flush().unwrap()) + .wait() + .unwrap(); let saved_width = diffy::state::load(&prefs).unwrap().sidebar_width.unwrap(); assert!(saved_width > 320.); // Right-click opens a menu first; Edit opens settings. @@ -771,7 +774,7 @@ fn main() { cx.update_window(real_handle, |_, w, cx| { w.render_frame(cx); assert!(w.try_find("project-settings").is_some()); - w.press("cmd-a", cx); + w.press("secondary-a", cx); w.input("Temporary name", cx); w.press("escape", cx); w.render_frame(cx); @@ -787,7 +790,7 @@ fn main() { cx.run_until_parked(); cx.update_window(real_handle, |_, w, cx| { w.render_frame(cx); - w.press("cmd-a", cx); + w.press("secondary-a", cx); w.input("Test garden", cx); w.click("project-emoji", cx); w.input("🪴", cx); @@ -814,7 +817,7 @@ fn main() { let hex = color.read(cx).hex_input().clone(); hex.read(cx).focus_handle(cx).focus(w, cx); w.render_frame(cx); - w.press("cmd-a", cx); + w.press("secondary-a", cx); w.input("#FF8175", cx); }) .unwrap(); @@ -836,6 +839,9 @@ fn main() { w.render_frame(cx); }) .unwrap(); + cx.update(|cx| real_view.read(cx).preferences_flush().unwrap()) + .wait() + .unwrap(); let saved = diffy::state::load(&prefs).unwrap(); assert_eq!(saved.projects.len(), 1); assert!(saved.projects[0].staged); @@ -993,12 +999,15 @@ fn main() { w.render_frame(cx); assert_eq!(restored_view.read(cx).project_count(), 0); assert!(w.try_find("open-empty").is_some()); - w.press("cmd-down", cx); - w.press("cmd-shift-down", cx); - w.press("cmd-shift-s", cx); + w.press("secondary-down", cx); + w.press("secondary-shift-down", cx); + w.press("secondary-shift-s", cx); w.render_frame(cx); }) .unwrap(); + cx.update(|cx| restored_view.read(cx).preferences_flush().unwrap()) + .wait() + .unwrap(); assert!(diffy::state::load(&prefs).unwrap().projects.is_empty()); assert_eq!(std::fs::read_to_string(root.join("a.rs")).unwrap(), "one\n"); // Resolve nested folders to one repository and keep missing roots recoverable. @@ -1158,7 +1167,7 @@ fn main() { .any(|row| row.label() == Some("TAIL_3000")), "Wheel scrolling reaches the final line despite horizontal overflow" ); - w.press("cmd-enter", cx); + w.press("secondary-enter", cx); w.press("home", cx); w.render_frame(cx); w.press("end", cx); @@ -1171,7 +1180,7 @@ fn main() { ); w.click("split", cx); w.render_frame(cx); - w.press("cmd-enter", cx); + w.press("secondary-enter", cx); w.press("end", cx); w.render_frame(cx); assert!( @@ -1321,7 +1330,7 @@ fn main() { large_view.read(cx).selected_file().map(|p| p.as_str()), Some("new.txt") ); - w.press("cmd-shift-s", cx); + w.press("secondary-shift-s", cx); w.render_frame(cx); assert_eq!( large_view.read(cx).selected_file().map(|p| p.as_str()), @@ -1352,7 +1361,7 @@ fn main() { std::fs::create_dir_all(large_root.join(long_path).parent().unwrap()).unwrap(); std::fs::write(large_root.join(long_path), "export const ready = true;\n").unwrap(); cx.update_window(large_handle, |_, w, cx| { - w.press("cmd-shift-s", cx); + w.press("secondary-shift-s", cx); w.render_frame(cx); }) .unwrap(); @@ -1460,7 +1469,7 @@ fn main() { assert_eq!(second.origin.y - first.origin.y, px(30.)); assert_eq!(cli.origin.y - second.origin.y, px(58.)); assert_eq!(first.size.height, px(30.)); - w.press("cmd-f", cx); + w.press("secondary-f", cx); w.input("cli.rs", cx); }) .unwrap(); @@ -1473,7 +1482,7 @@ fn main() { assert!(w.try_find("src/cli.rs").is_some()); assert!(w.try_find("folder:src").is_some()); assert_ne!(w.find("stage-all").disabled(), Some(true)); - w.press("cmd-a", cx); + w.press("secondary-a", cx); w.input("zzzz-no-such-file", cx); }) .unwrap(); @@ -1483,7 +1492,7 @@ fn main() { assert!(w.try_find("src/cli.rs").is_none()); assert!(w.try_find("file-search-empty").is_some()); assert_ne!(w.find("stage-all").disabled(), Some(true)); - w.press("cmd-a", cx); + w.press("secondary-a", cx); w.press("backspace", cx); }) .unwrap(); diff --git a/tests/update_archive.rs b/tests/update_archive.rs index e34e0eb..c6e32fa 100644 --- a/tests/update_archive.rs +++ b/tests/update_archive.rs @@ -1,6 +1,7 @@ //! Offline verification and installation exercises never modify the user's app. #![cfg(debug_assertions)] use cargo_packager_updater::{Config, Update, UpdateFormat}; +#[allow(unused_imports)] use std::{ fs, path::{Path, PathBuf}, @@ -42,6 +43,34 @@ fn real_signatures_accept_exact_bytes_and_reject_corruption() { assert!(diffy::updates::Package::install(&invalid, corrupted).is_err()); } +#[cfg(target_os = "linux")] +#[test] +fn linux_signed_raw_payload_replaces_outer_file_and_preserves_permissions() { + use std::os::unix::fs::PermissionsExt as _; + let fixture = tempfile::tempdir().unwrap(); + let destination = fixture.path().join("Diffy.AppImage"); + fs::write(&destination, b"old executable").unwrap(); + fs::set_permissions(&destination, fs::Permissions::from_mode(0o755)).unwrap(); + let bytes = include_bytes!("fixtures/update/payload.txt").to_vec(); + let mut package = update( + include_str!("fixtures/update/public.key"), + include_str!("fixtures/update/payload.txt.sig"), + destination.clone(), + ); + package.target = "linux-x86_64".into(); + package.format = UpdateFormat::AppImage; + let mut corrupted = bytes.clone(); + corrupted[0] ^= 1; + assert!(diffy::updates::Package::install(&package, corrupted).is_err()); + assert_eq!(fs::read(&destination).unwrap(), b"old executable"); + diffy::updates::Package::install(&package, bytes.clone()).unwrap(); + assert_eq!(fs::read(&destination).unwrap(), bytes); + assert_eq!( + fs::metadata(&destination).unwrap().permissions().mode() & 0o777, + 0o755 + ); +} + #[test] #[cfg(target_os = "macos")] #[ignore = "requires a signed release archive; run explicitly during release verification"] diff --git a/tests/update_desktop.rs b/tests/update_desktop.rs new file mode 100644 index 0000000..8c84960 --- /dev/null +++ b/tests/update_desktop.rs @@ -0,0 +1,45 @@ +//! Native CI uses an ephemeral signing key and disposable installations. +//! The shipping binary always trusts packaging/updater.pub. +#[cfg(any(target_os = "linux", windows))] +#[test] +#[ignore = "requires a CI-only signed native package and an isolated destination"] +fn apply_signed_native_package() { + use cargo_packager_updater::{Config, Update, UpdateFormat, WindowsConfig}; + use std::{fs, path::PathBuf, time::Duration}; + assert!(std::env::var_os("CI").is_some()); + let package = PathBuf::from(std::env::var_os("DIFFY_CI_UPDATE_PACKAGE").unwrap()); + let signature = + fs::read_to_string(std::env::var_os("DIFFY_CI_UPDATE_SIGNATURE").unwrap()).unwrap(); + let public = fs::read_to_string(std::env::var_os("DIFFY_CI_UPDATE_PUBLIC").unwrap()).unwrap(); + let destination = PathBuf::from(std::env::var_os("DIFFY_CI_UPDATE_DESTINATION").unwrap()); + let update = Update { + config: Config { + pubkey: public, + windows: Some(WindowsConfig { + installer_args: Some(vec!["/R".into(), format!("/D={}", destination.display())]), + ..Default::default() + }), + ..Default::default() + }, + body: None, + current_version: "0.0.0".into(), + version: diffy::updates::VERSION.into(), + date: None, + target: diffy::updates::Target::current().unwrap().id.into(), + extract_path: destination, + download_url: "https://example.com/unused-ci-fixture".parse().unwrap(), + signature, + timeout: Some(Duration::from_secs(15)), + headers: Default::default(), + format: if cfg!(windows) { + UpdateFormat::Nsis + } else { + UpdateFormat::AppImage + }, + }; + let bytes = fs::read(package).unwrap(); + let mut corrupted = bytes.clone(); + corrupted[0] ^= 1; + assert!(diffy::updates::Package::install(&update, corrupted).is_err()); + diffy::updates::Package::install(&update, bytes).expect("install verified CI package"); +} diff --git a/tests/worktrees_ui.rs b/tests/worktrees_ui.rs index 1c57031..23323fa 100644 --- a/tests/worktrees_ui.rs +++ b/tests/worktrees_ui.rs @@ -152,19 +152,19 @@ fn main() { dispatch(&mut cx, Box::new(app::PreviousProject)); wait(&mut cx); let main_id = cx.update(|cx| view.read(cx).active_project_id().unwrap()); - cx.update_window(handle, |_, window, cx| window.press("cmd-right", cx)) + cx.update_window(handle, |_, window, cx| window.press("secondary-right", cx)) .unwrap(); wait(&mut cx); let linked_id = cx.update(|cx| view.read(cx).active_project_id().unwrap()); assert_ne!(main_id, linked_id); - cx.update_window(handle, |_, window, cx| window.press("cmd-left", cx)) + cx.update_window(handle, |_, window, cx| window.press("secondary-left", cx)) .unwrap(); wait(&mut cx); assert_eq!( cx.update(|cx| view.read(cx).active_project_id()), Some(main_id) ); - cx.update_window(handle, |_, window, cx| window.press("cmd-left", cx)) + cx.update_window(handle, |_, window, cx| window.press("secondary-left", cx)) .unwrap(); wait(&mut cx); assert_eq!( @@ -173,14 +173,14 @@ fn main() { "Previous worktree wraps from the main checkout" ); cx.update_window(handle, |_, window, cx| { - window.press("cmd-f", cx); + window.press("secondary-f", cx); }) .unwrap(); cx.run_until_parked(); cx.update_window(handle, |_, window, cx| { window.render_frame(cx); window.input("selected", cx); - window.press("cmd-left", cx); + window.press("secondary-left", cx); }) .unwrap(); cx.run_until_parked(); @@ -190,7 +190,7 @@ fn main() { "Command-left moves the cursor in a text input" ); cx.update_window(handle, |_, window, cx| { - window.press("cmd-right", cx); + window.press("secondary-right", cx); }) .unwrap(); cx.run_until_parked(); @@ -206,7 +206,7 @@ fn main() { cx.run_until_parked(); cx.update_window(handle, |_, window, cx| { window.render_frame(cx); - window.press("cmd-right", cx); + window.press("secondary-right", cx); }) .unwrap(); wait(&mut cx); @@ -215,7 +215,7 @@ fn main() { Some(main_id), "Next worktree wraps while the diff has focus" ); - cx.update_window(handle, |_, window, cx| window.press("cmd-right", cx)) + cx.update_window(handle, |_, window, cx| window.press("secondary-right", cx)) .unwrap(); wait(&mut cx); dispatch(&mut cx, Box::new(app::StageAll)); @@ -366,9 +366,9 @@ fn main() { .any(|s| s.label() == Some("Project") && s.selected() == Some(true)), "Main project selection survives restart" ); - window.press("cmd-right", cx); + window.press("secondary-right", cx); window.render_frame(cx); - window.press("cmd-right", cx); + window.press("secondary-right", cx); window.render_frame(cx); }) .unwrap();