From fa0638e6e89cc9598277c970d4c7d0409a215bbf Mon Sep 17 00:00:00 2001 From: Jordan Howlett Date: Wed, 14 Oct 2026 15:46:00 -0600 Subject: [PATCH 01/13] Add Windows and Linux alpha release support and beta download site --- .github/workflows/desktop.yml | 75 ++++++++ .github/workflows/site.yml | 6 + Cargo.lock | 43 ++++- Cargo.toml | 20 +- build.rs | 50 +++++ scripts/check-desktop-package.py | 48 +++++ scripts/collect-licenses.py | 3 +- scripts/release-desktop.py | 99 ++++++++++ scripts/smoke-desktop.py | 17 ++ site/package.json | 2 +- site/public/_redirects | 1 - site/scripts/check-build.mjs | 23 +++ site/scripts/write-redirects.mjs | 9 + site/src/components/Downloads.astro | 16 ++ site/src/data/platforms.json | 41 ++++ site/src/data/releases.ts | 4 + site/src/layouts/Page.astro | 2 +- site/src/pages/downloads.astro | 13 ++ site/src/pages/index.astro | 14 +- site/src/pages/thanks.astro | 13 +- site/src/styles/site.css | 23 +++ src/app/files.rs | 14 ++ src/app/mod.rs | 7 +- src/app/notice.rs | 4 + src/app/palette.rs | 8 +- src/app/project_menu.rs | 4 +- src/app/reader.rs | 2 +- src/app/session.rs | 1 + src/app/settings.rs | 8 +- src/cli.rs | 79 ++++++-- src/instance.rs | 279 ++++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 57 +++++- src/project_apps.rs | 187 +++++++++++++++++-- src/repository.rs | 26 ++- src/state.rs | 29 ++- src/terminal.rs | 56 ++++++ src/updates.rs | 144 +++++++++++--- tests/gix_comparison.rs | 18 +- tests/update_archive.rs | 29 +++ 40 files changed, 1379 insertions(+), 96 deletions(-) create mode 100644 .github/workflows/desktop.yml create mode 100644 build.rs create mode 100644 scripts/check-desktop-package.py create mode 100644 scripts/release-desktop.py create mode 100644 scripts/smoke-desktop.py delete mode 100644 site/public/_redirects create mode 100644 site/scripts/check-build.mjs create mode 100644 site/scripts/write-redirects.mjs create mode 100644 site/src/components/Downloads.astro create mode 100644 site/src/data/platforms.json create mode 100644 site/src/data/releases.ts create mode 100644 site/src/pages/downloads.astro create mode 100644 src/instance.rs create mode 100644 src/terminal.rs diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml new file mode 100644 index 0000000..4976c7f --- /dev/null +++ b/.github/workflows/desktop.yml @@ -0,0 +1,75 @@ +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: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - 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 + run: python scripts/check-desktop-package.py --platform ${{ matrix.platform }} + - 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..0d6466a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1528,6 +1528,9 @@ dependencies = [ "base64 0.22.1", "cargo-packager-updater", "criterion", + "directories", + "embed-resource", + "fs2", "gix", "gpui-kit", "ignore", @@ -1544,10 +1547,12 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "tempfile", "tracing", "tracing-chrome", "tracing-subscriber", "url", + "which", ] [[package]] @@ -1572,6 +1577,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 +1727,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 +2133,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 +9805,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 +10065,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..0b099c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ version = "0.2.3" 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" @@ -37,12 +43,20 @@ objc2 = "=0.6.5" objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSWorkspace"] } objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSString", "NSURL"] } -[target.'cfg(windows)'.dependencies] -interprocess = "=2.4.4" - [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/build.rs b/build.rs new file mode 100644 index 0000000..086193b --- /dev/null +++ b/build.rs @@ -0,0 +1,50 @@ +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; + } + 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/scripts/check-desktop-package.py b/scripts/check-desktop-package.py new file mode 100644 index 0000000..12fa77d --- /dev/null +++ b/scripts/check-desktop-package.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Check the installable package on a disposable native CI machine.""" +import argparse +import os +import subprocess +import tempfile +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.") +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..7f76d8b --- /dev/null +++ b/scripts/release-desktop.py @@ -0,0 +1,99 @@ +#!/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 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] + 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 + # 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) + config = metadata["metadata"]["packager"].copy() + config.pop("macos", None) + config.pop("before-packaging-command", None) + config.update({ + "version": 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 args.platform == "windows": + config["nsis"] = {"install-mode": "currentUser"} + 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", configuration], 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] + if not args.unsigned: + key = os.environ.get("DIFFY_UPDATER_KEY", str(ROOT / ".release-secrets/updater.key")) + 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/smoke-desktop.py b/scripts/smoke-desktop.py new file mode 100644 index 0000000..13c054f --- /dev/null +++ b/scripts/smoke-desktop.py @@ -0,0 +1,17 @@ +#!/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, "DIFFY_SMOKE_RESULT": str(result), "DIFFY_STATE_PATH": str(Path(directory) / "state.json")} + subprocess.run([sys.argv[1], "--smoke-test"], env=env, timeout=45, check=True) + 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/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..c1caee5 --- /dev/null +++ b/site/scripts/check-build.mjs @@ -0,0 +1,23 @@ +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('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..199cfa3 --- /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 +

A little less hunting.
A lot more reading.

+

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..f542a5c 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 }))); @@ -22,13 +23,16 @@ if (checkout) {

Diffy — i literally only show you diffs

A native Git diff viewer, built in Rust.

+

Diffy is in beta.

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.

+ Downloads & installation
@@ -52,7 +56,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 +73,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..0e0fbea 100644 --- a/site/src/styles/site.css +++ b/site/src/styles/site.css @@ -19,6 +19,29 @@ --muted: #929297; --quiet: #737379; } +.beta-label { color: #e4ed64; font-size: 12px; margin: 12px 0; } +.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..9598220 100644 --- a/src/app/files.rs +++ b/src/app/files.rs @@ -444,6 +444,19 @@ impl Diffy { window: &mut Window, cx: &mut Context, ) { + if !cfg!(target_os = "macos") && !(self.file_key_probe)(direction) { + let now = std::time::Instant::now(); + if self.repeat_direction != Some(direction) + || self + .native_repeat_at + .is_none_or(|last| now.duration_since(last) >= Duration::from_millis(200)) + { + self.move_file(direction, cx); + self.repeat_direction = Some(direction); + self.native_repeat_at = Some(now); + } + return; + } if self.repeat_direction == Some(direction) && (self.file_key_probe)(direction) { return; // The timer owns repeats; ignore native autorepeat duplicates. } @@ -493,6 +506,7 @@ impl Diffy { } pub(super) fn stop_file_repeat(&mut self) { self.repeat_direction = None; + self.native_repeat_at = None; self.file_repeat = None; } /// Capture runs before the diff list applies the wheel, so stopping the diff --git a/src/app/mod.rs b/src/app/mod.rs index 05dd252..cd0a39d 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,7 @@ pub struct Diffy { project_apps: project_menu::Runtime, repeat_direction: Option, file_repeat: Option>, + native_repeat_at: Option, file_key_probe: fn(isize) -> bool, file_scroll_remainder: f32, file_scroll_axis: Option, 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..2be0129 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -152,6 +152,7 @@ impl Diffy { project_apps: Default::default(), repeat_direction: None, file_repeat: None, + native_repeat_at: None, file_key_probe: keyboard::file_shortcut_pressed, file_scroll_remainder: 0., file_scroll_axis: None, 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..616dd70 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) } @@ -158,14 +177,46 @@ 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)] mod tests { use super::*; + #[cfg(unix)] #[test] fn open_requests_round_trip_without_shell_interpolation() { let paths = vec![ diff --git a/src/instance.rs b/src/instance.rs new file mode 100644 index 0000000..42c19d7 --- /dev/null +++ b/src/instance.rs @@ -0,0 +1,279 @@ +//! 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 { + 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(); + } + } +} + +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 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.kind() == io::ErrorKind::WouldBlock => { + let deadline = Instant::now() + TIMEOUT; + loop { + match Stream::connect(endpoint(directory)?) { + Ok(mut stream) => { + 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)?; + 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 || { + let _lock: File = lock; + 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) + }); + let _ = send( + &mut stream, + if result.is_ok() { b"ok\n" } else { b"error\n" }, + deadline, + ); + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)) + } + Err(_) => break, + } + } + drop(listener); + }); + Ok(Start::Primary(Instance { + 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) => 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) { + 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] + ); + 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"); + 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..4d65bad 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; @@ -11,6 +12,7 @@ fn main() { let mut check = false; let mut demo_mode = false; let mut cli = false; + let mut smoke = false; 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,18 @@ 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, cx| { + let _ = window.draw(cx); + std::fs::write(output, "rendered\n").expect("write smoke result"); + cx.quit(); + }); + window.request_animation_frame(); + }); + } // 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 +176,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 +194,22 @@ 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") { + // 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..f592e7d 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,78 @@ 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"))] + { + 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 +217,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..4224cdf 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -151,6 +151,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); } @@ -654,7 +659,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 +693,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 +887,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/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/terminal.rs b/src/terminal.rs new file mode 100644 index 0000000..89e85aa --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,56 @@ +//! 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(); + 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 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"); + let executable: std::path::PathBuf = if config.exists() { + diffy::cli::launcher_target(&config)? + } else { + 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..bd4d2a1 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 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..ee7a5a7 100644 --- a/tests/gix_comparison.rs +++ b/tests/gix_comparison.rs @@ -83,7 +83,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] { @@ -525,7 +532,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/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"] From 5c94e2fa0c74ea0d8dfb0f405700d4690aec3608 Mon Sep 17 00:00:00 2001 From: Jordan Howlett Date: Wed, 14 Oct 2026 15:55:34 -0600 Subject: [PATCH 02/13] Verify native updates and adapt platform fixtures and release documentation --- .github/workflows/desktop.yml | 10 +++- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 20 +++++--- RELEASING.md | 43 +++++++++++++++- packaging/release-notes.md | 17 +++--- scripts/assemble-release.py | 83 ++++++++++++++++++++++++++++++ scripts/check-desktop-package.py | 25 +++++++++ scripts/release-desktop.py | 7 +++ scripts/release-macos.py | 5 ++ site/README.md | 20 +++++++- site/scripts/check-build.mjs | 3 +- site/src/pages/downloads.astro | 2 +- site/src/pages/index.astro | 2 - site/src/styles/site.css | 1 - src/discovery.rs | 5 +- src/instance.rs | 14 +++-- src/main.rs | 4 +- src/repository/worktrees.rs | 6 ++- src/state/writer.rs | 7 +-- tests/performance_ui.rs | 6 +-- tests/project_menu_ui.rs | 23 ++++++--- tests/ui.rs | 88 ++++++++++++++++---------------- tests/update_desktop.rs | 45 ++++++++++++++++ tests/worktrees_ui.rs | 20 ++++---- 25 files changed, 356 insertions(+), 104 deletions(-) create mode 100644 scripts/assemble-release.py create mode 100644 tests/update_desktop.rs diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 4976c7f..b441429 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -34,6 +34,8 @@ jobs: with: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true - uses: actions/setup-python@v5 with: python-version: '3.12' @@ -44,6 +46,8 @@ jobs: 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: Keep Git fixtures independent of runner defaults + run: git config --global core.autocrlf false - name: Functional tests run: cargo test --locked - name: Clippy @@ -64,7 +68,11 @@ jobs: - name: Package alpha run: python scripts/release-desktop.py --platform ${{ matrix.platform }} --unsigned - name: Validate packaged installation - run: python scripts/check-desktop-package.py --platform ${{ matrix.platform }} + 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: diff --git a/Cargo.lock b/Cargo.lock index 0d6466a..3d5d652 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1521,7 +1521,7 @@ dependencies = [ [[package]] name = "diffy" -version = "0.2.3" +version = "0.2.4" dependencies = [ "agent-sessions", "async-channel", diff --git a/Cargo.toml b/Cargo.toml index 0b099c8..919027d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "diffy" -version = "0.2.3" +version = "0.2.4" edition = "2024" description = "A small desktop Git diff viewer" publish = 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/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 index 12fa77d..36e9fd3 100644 --- a/scripts/check-desktop-package.py +++ b/scripts/check-desktop-package.py @@ -4,6 +4,7 @@ import os import subprocess import tempfile +import time from pathlib import Path parser = argparse.ArgumentParser(description=__doc__) @@ -45,4 +46,28 @@ 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/release-desktop.py b/scripts/release-desktop.py index 7f76d8b..f2644c6 100644 --- a/scripts/release-desktop.py +++ b/scripts/release-desktop.py @@ -72,8 +72,15 @@ def main(): 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: diff --git a/scripts/release-macos.py b/scripts/release-macos.py index ac3d64b..884c407 100644 --- a/scripts/release-macos.py +++ b/scripts/release-macos.py @@ -158,6 +158,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/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/scripts/check-build.mjs b/site/scripts/check-build.mjs index c1caee5..ae0b0ad 100644 --- a/site/scripts/check-build.mjs +++ b/site/scripts/check-build.mjs @@ -3,7 +3,8 @@ 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('Diffy is in beta.')); +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')); diff --git a/site/src/pages/downloads.astro b/site/src/pages/downloads.astro index 199cfa3..c728693 100644 --- a/site/src/pages/downloads.astro +++ b/site/src/pages/downloads.astro @@ -5,7 +5,7 @@ import Downloads from '../components/Downloads.astro';
Diffy’s chameleon -

A little less hunting.
A lot more reading.

+

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 f542a5c..b448e6a 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -23,7 +23,6 @@ if (checkout) {

Diffy — i literally only show you diffs

A native Git diff viewer, built in Rust.

-

Diffy is in beta.

Keep up with your agents’ changes.

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

@@ -32,7 +31,6 @@ if (checkout) { {!purchaseUrl &&

Checkout coming soon.

} Or build for free

One purchase. All platforms. Your purchase supports Diffy.

- Downloads & installation
diff --git a/site/src/styles/site.css b/site/src/styles/site.css index 0e0fbea..f0e6520 100644 --- a/site/src/styles/site.css +++ b/site/src/styles/site.css @@ -19,7 +19,6 @@ --muted: #929297; --quiet: #737379; } -.beta-label { color: #e4ed64; font-size: 12px; margin: 12px 0; } .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; } diff --git a/src/discovery.rs b/src/discovery.rs index 45c4e5f..b4e5840 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, diff --git a/src/instance.rs b/src/instance.rs index 42c19d7..7b2534b 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -121,7 +121,7 @@ fn start_in( .open(directory.join("instance.lock"))?; match lock.try_lock_exclusive() { Ok(()) => {} - Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + Err(error) if error.raw_os_error() == fs2::lock_contended_error().raw_os_error() => { let deadline = Instant::now() + TIMEOUT; loop { match Stream::connect(endpoint(directory)?) { @@ -135,6 +135,7 @@ fn start_in( } 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 { @@ -175,11 +176,17 @@ fn start_in( let url = crate::cli::open_url(&request.paths).map_err(io::Error::other)?; sender.try_send(vec![url]).map_err(io::Error::other) }); - let _ = send( + 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)) @@ -274,6 +281,7 @@ mod tests { 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/main.rs b/src/main.rs index 4d65bad..f25de83 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,9 +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 = false; + let mut smoke = demo_mode; let mut literal = false; for argument in std::env::args_os().skip(1) { if literal { 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/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/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..3f98be0 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); @@ -771,7 +771,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 +787,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 +814,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(); @@ -993,9 +993,9 @@ 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(); @@ -1158,7 +1158,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 +1171,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 +1321,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 +1352,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 +1460,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 +1473,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 +1483,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_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(); From f30b2670b4cdd84857160db22ce5b9ef08b5065a Mon Sep 17 00:00:00 2001 From: Jordan Howlett Date: Wed, 14 Oct 2026 16:04:10 -0600 Subject: [PATCH 03/13] Fix native pipe polling, key repeat, and installer restart handling --- scripts/release-desktop.py | 2 ++ scripts/release-macos.py | 2 ++ src/app/files.rs | 30 +++++++++++++++++------------- src/app/mod.rs | 12 ++++++++++++ src/app/session.rs | 6 +++++- src/discovery.rs | 1 + src/instance.rs | 22 +++++++++++++++++++++- src/main.rs | 1 + src/terminal.rs | 33 +++++++++++++++++++++++++-------- src/updates.rs | 34 +++++++++++++++++++++++++++++++++- 10 files changed, 119 insertions(+), 24 deletions(-) diff --git a/scripts/release-desktop.py b/scripts/release-desktop.py index f2644c6..a131013 100644 --- a/scripts/release-desktop.py +++ b/scripts/release-desktop.py @@ -31,6 +31,8 @@ def main(): 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", "") diff --git a/scripts/release-macos.py b/scripts/release-macos.py index 884c407..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"] diff --git a/src/app/files.rs b/src/app/files.rs index 9598220..d7cae54 100644 --- a/src/app/files.rs +++ b/src/app/files.rs @@ -444,19 +444,6 @@ impl Diffy { window: &mut Window, cx: &mut Context, ) { - if !cfg!(target_os = "macos") && !(self.file_key_probe)(direction) { - let now = std::time::Instant::now(); - if self.repeat_direction != Some(direction) - || self - .native_repeat_at - .is_none_or(|last| now.duration_since(last) >= Duration::from_millis(200)) - { - self.move_file(direction, cx); - self.repeat_direction = Some(direction); - self.native_repeat_at = Some(now); - } - return; - } if self.repeat_direction == Some(direction) && (self.file_key_probe)(direction) { return; // The timer owns repeats; ignore native autorepeat duplicates. } @@ -507,8 +494,25 @@ 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 cd0a39d..8d017d2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -209,6 +209,7 @@ pub struct Diffy { 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, @@ -311,6 +312,14 @@ 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 }; + 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); @@ -378,6 +387,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/session.rs b/src/app/session.rs index 2be0129..7ada5bc 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -153,6 +153,7 @@ impl Diffy { 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, @@ -189,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/discovery.rs b/src/discovery.rs index b4e5840..3c2ef2f 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -655,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 index 7b2534b..d615784 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -59,6 +59,22 @@ fn endpoint(directory: &Path) -> io::Result 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)?; @@ -124,7 +140,7 @@ fn start_in( Err(error) if error.raw_os_error() == fs2::lock_contended_error().raw_os_error() => { let deadline = Instant::now() + TIMEOUT; loop { - match Stream::connect(endpoint(directory)?) { + match connect(directory, deadline) { Ok(mut stream) => { stream.set_nonblocking(true)?; let mut request = serde_json::to_vec(&Request { version: 1, paths }) @@ -208,6 +224,7 @@ fn send(stream: &mut Stream, mut bytes: &[u8], deadline: Instant) -> io::Result< 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 => { @@ -226,6 +243,9 @@ fn receive(stream: &mut Stream, deadline: Instant) -> io::Result> { } 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]); diff --git a/src/main.rs b/src/main.rs index f25de83..341812b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -201,6 +201,7 @@ fn open_window( ..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 { diff --git a/src/terminal.rs b/src/terminal.rs index 89e85aa..02a46d7 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -8,6 +8,30 @@ fn main() { } 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(()); @@ -37,14 +61,7 @@ fn run() -> Result<(), String> { } return Ok(()); } - 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"); - let executable: std::path::PathBuf = if config.exists() { - diffy::cli::launcher_target(&config)? - } else { - parent.join(if cfg!(windows) { "diffy.exe" } else { "diffy" }) - }; + 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()); } diff --git a/src/updates.rs b/src/updates.rs index bd4d2a1..6ef217c 100644 --- a/src/updates.rs +++ b/src/updates.rs @@ -362,7 +362,39 @@ impl Package for Update { } } } - #[cfg(not(target_os = "macos"))] + #[cfg(windows)] + { + // Spawn the verified installer directly. The dependency's Windows + // adapter routes through PowerShell and panics on spawn failure; + // this boundary preserves a recoverable error and literal arguments. + use std::{io::Write as _, os::windows::process::CommandExt as _}; + let mut file = tempfile::Builder::new() + .suffix(".exe") + .tempfile() + .map_err(|e| e.to_string())?; + file.write_all(&bytes).map_err(|e| e.to_string())?; + file.sync_all().map_err(|e| e.to_string())?; + let (handle, path) = file.keep().map_err(|e| e.to_string())?; + drop(handle); + let arguments = self + .config + .windows + .as_ref() + .and_then(|windows| windows.installer_args.as_ref()); + let result = std::process::Command::new(&path) + .arg("/P") + .args(arguments.into_iter().flatten()) + .creation_flags(0x08000000) + .spawn(); + if let Err(error) = result { + let _ = std::fs::remove_file(path); + return Err(format!("Couldn't start the update installer: {error}")); + } + // The installer replaces the running image and relaunches with /R. + // The UI has flushed settings and waited for Git writes before here. + std::process::exit(0); + } + #[cfg(all(not(target_os = "macos"), not(windows)))] Update::install(self, bytes).map_err(|e| format!("Couldn't install the update: {e}")) } } From fb30239a5e28d3e26cde1df89d1b86bc64a05481 Mon Sep 17 00:00:00 2001 From: Jordan Howlett Date: Wed, 14 Oct 2026 16:08:21 -0600 Subject: [PATCH 04/13] Finish native key dispatch and Windows installer compilation fixes --- src/app/mod.rs | 1 + src/project_apps.rs | 24 ++++++++++++++++++++++++ src/repository.rs | 12 ++++++++++++ src/updates.rs | 2 +- 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 8d017d2..23142a5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -317,6 +317,7 @@ impl Render for Diffy { 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); })) diff --git a/src/project_apps.rs b/src/project_apps.rs index f592e7d..fa5579b 100644 --- a/src/project_apps.rs +++ b/src/project_apps.rs @@ -158,6 +158,30 @@ fn project_command(app: ProjectApp, application: &Path, root: &Path) -> std::pro } #[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 { diff --git a/src/repository.rs b/src/repository.rs index 4224cdf..3bcdf7d 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() diff --git a/src/updates.rs b/src/updates.rs index 6ef217c..f2f9cac 100644 --- a/src/updates.rs +++ b/src/updates.rs @@ -373,7 +373,7 @@ impl Package for Update { .tempfile() .map_err(|e| e.to_string())?; file.write_all(&bytes).map_err(|e| e.to_string())?; - file.sync_all().map_err(|e| e.to_string())?; + file.as_file().sync_all().map_err(|e| e.to_string())?; let (handle, path) = file.keep().map_err(|e| e.to_string())?; drop(handle); let arguments = self From 6fcbe87c1a9b34f706d8d7c4a3286491539ac0fa Mon Sep 17 00:00:00 2001 From: Jordan Howlett Date: Wed, 14 Oct 2026 16:18:17 -0600 Subject: [PATCH 05/13] Fix Windows Git config fixtures and await UI preference saves --- Cargo.lock | 1 + Cargo.toml | 3 +++ src/instance.rs | 10 ++++++++++ tests/gix_comparison.rs | 39 +++++++++++++++++++++++++++++++-------- tests/ui.rs | 9 +++++++++ 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d5d652..1e93edc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1553,6 +1553,7 @@ dependencies = [ "tracing-subscriber", "url", "which", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 919027d..81104c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,9 @@ objc2 = "=0.6.5" objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSWorkspace"] } objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSString", "NSURL"] } +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "=0.61.2", features = ["Win32_UI_WindowsAndMessaging"] } + [dev-dependencies] criterion = { version = "0.8", default-features = false } diff --git a/src/instance.rs b/src/instance.rs index d615784..fab097c 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -142,6 +142,16 @@ fn start_in( 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)?; diff --git a/tests/gix_comparison.rs b/tests/gix_comparison.rs index ee7a5a7..45dddc0 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] { @@ -150,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(), ]); @@ -485,11 +506,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(); diff --git a/tests/ui.rs b/tests/ui.rs index 3f98be0..61be167 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -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. @@ -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); @@ -999,6 +1005,9 @@ fn main() { 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. From c4cf86cc1bd97904c880cc9f5dc7ad7b64a1a2f3 Mon Sep 17 00:00:00 2001 From: Jordan Howlett Date: Wed, 14 Oct 2026 16:22:48 -0600 Subject: [PATCH 06/13] Close Unix launcher writes before publication and tolerate transient exec contention --- src/cli.rs | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 616dd70..a48cd2d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -167,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 { @@ -238,18 +239,33 @@ mod tests { #[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(), @@ -265,6 +281,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(); } } From 355e3646fc99478c541d2af88432e8073c74c14e Mon Sep 17 00:00:00 2001 From: Jordan Howlett Date: Wed, 14 Oct 2026 16:25:36 -0600 Subject: [PATCH 07/13] Use Git for Windows conditional config to preserve ignored files --- src/repository/metadata/native.rs | 11 +++++++++++ tests/gix_comparison.rs | 15 ++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) 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/tests/gix_comparison.rs b/tests/gix_comparison.rs index 45dddc0..2080e86 100644 --- a/tests/gix_comparison.rs +++ b/tests/gix_comparison.rs @@ -193,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() From 4a9a00785d80fad035535be2f7fdde9dd91fa6d5 Mon Sep 17 00:00:00 2001 From: Jordan Howlett Date: Wed, 14 Oct 2026 16:31:01 -0600 Subject: [PATCH 08/13] Preserve signed fixture bytes and refresh native smoke windows correctly --- .gitattributes | 2 ++ scripts/smoke-desktop.py | 2 +- src/main.rs | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) 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/scripts/smoke-desktop.py b/scripts/smoke-desktop.py index 13c054f..fe13979 100644 --- a/scripts/smoke-desktop.py +++ b/scripts/smoke-desktop.py @@ -8,7 +8,7 @@ with tempfile.TemporaryDirectory(prefix="diffy-smoke-") as directory: result = Path(directory) / "render.txt" - env = {**os.environ, "DIFFY_SMOKE_RESULT": str(result), "DIFFY_STATE_PATH": str(Path(directory) / "state.json")} + env = {**os.environ, "RUST_BACKTRACE": "1", "DIFFY_SMOKE_RESULT": str(result), "DIFFY_STATE_PATH": str(Path(directory) / "state.json")} subprocess.run([sys.argv[1], "--smoke-test"], env=env, timeout=45, check=True) if not result.exists() or result.read_text() != "rendered\n": raise RuntimeError("The native window did not render a frame.") diff --git a/src/main.rs b/src/main.rs index 341812b..1b6eceb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -152,7 +152,7 @@ fn main() { std::fs::write(output, "rendered\n").expect("write smoke result"); cx.quit(); }); - window.request_animation_frame(); + window.refresh(); }); } // The receiver is registered before launching so cold-start URL events cannot be lost. From 35529ab4a5460d6518332947d2acac454976b577 Mon Sep 17 00:00:00 2001 From: Jordan Howlett Date: Wed, 14 Oct 2026 16:35:35 -0600 Subject: [PATCH 09/13] Wait for native frame presentation before smoke-test success --- src/main.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1b6eceb..1384938 100644 --- a/src/main.rs +++ b/src/main.rs @@ -147,10 +147,14 @@ fn main() { 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, cx| { - let _ = window.draw(cx); - std::fs::write(output, "rendered\n").expect("write smoke result"); - cx.quit(); + 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(); }); From d24c0f1e51f587dbcdf44fd3f1ef9d806b3cf317 Mon Sep 17 00:00:00 2001 From: Jordan Howlett Date: Wed, 14 Oct 2026 16:44:42 -0600 Subject: [PATCH 10/13] Fix Windows-only Clippy imports and package-kind detection --- src/cli.rs | 4 +--- src/updates.rs | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index a48cd2d..ff2ff67 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -214,10 +214,9 @@ fn install_at(destination: &Path, executable: &Path) -> Result<(), String> { Ok(()) } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; - #[cfg(unix)] #[test] fn open_requests_round_trip_without_shell_interpolation() { let paths = vec![ @@ -235,7 +234,6 @@ mod tests { assert!(paths_from_url(bad).is_err(), "{bad}"); } } - #[cfg(unix)] #[test] fn installer_preserves_conflicts_and_quotes_arguments() { use std::fs; diff --git a/src/updates.rs b/src/updates.rs index f2f9cac..949891f 100644 --- a/src/updates.rs +++ b/src/updates.rs @@ -58,7 +58,7 @@ fn installation_path() -> Result { .ok_or("Install Diffy.app in Applications to use automatic updates.".into()); #[cfg(target_os = "windows")] { - if env!("DIFFY_PACKAGE_KIND") != "nsis" + if !matches!(env!("DIFFY_PACKAGE_KIND"), "nsis") || !executable .parent() .is_some_and(|parent| parent.join("diffy-cli.exe").is_file()) From 209daacd67240326a847fa79c2906931ff6bc9b6 Mon Sep 17 00:00:00 2001 From: Jordan Howlett Date: Wed, 14 Oct 2026 16:55:56 -0600 Subject: [PATCH 11/13] Reserve Windows GUI stack and pass native packager configuration as JSON --- build.rs | 5 +++++ scripts/release-desktop.py | 2 +- scripts/smoke-desktop.py | 7 ++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/build.rs b/build.rs index 086193b..3b07fa6 100644 --- a/build.rs +++ b/build.rs @@ -12,6 +12,11 @@ fn main() { 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 diff --git a/scripts/release-desktop.py b/scripts/release-desktop.py index a131013..6d059ff 100644 --- a/scripts/release-desktop.py +++ b/scripts/release-desktop.py @@ -63,7 +63,7 @@ def main(): 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", configuration], env) + 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: diff --git a/scripts/smoke-desktop.py b/scripts/smoke-desktop.py index fe13979..1808a5e 100644 --- a/scripts/smoke-desktop.py +++ b/scripts/smoke-desktop.py @@ -9,7 +9,12 @@ 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")} - subprocess.run([sys.argv[1], "--smoke-test"], env=env, timeout=45, check=True) + 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(): From 4f53ca54737b725c6eea740a8c9d8fda1f6f7e5e Mon Sep 17 00:00:00 2001 From: Jordan Howlett Date: Wed, 14 Oct 2026 17:13:59 -0600 Subject: [PATCH 12/13] Release instance locks explicitly and keep native CI checkouts clean --- .github/workflows/desktop.yml | 4 ++-- src/instance.rs | 10 +++++++++- src/repository.rs | 10 ++++------ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index b441429..9ca2a92 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -29,6 +29,8 @@ jobs: 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: @@ -46,8 +48,6 @@ jobs: 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: Keep Git fixtures independent of runner defaults - run: git config --global core.autocrlf false - name: Functional tests run: cargo test --locked - name: Clippy diff --git a/src/instance.rs b/src/instance.rs index fab097c..62ee1f5 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -31,6 +31,7 @@ pub enum Start { Primary(Instance), } pub struct Instance { + lock: File, stop: Arc, worker: Option>, } @@ -40,6 +41,9 @@ impl Drop for Instance { 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); } } @@ -188,7 +192,6 @@ fn start_in( let stop = Arc::new(AtomicBool::new(false)); let stopped = stop.clone(); let worker = thread::spawn(move || { - let _lock: File = lock; while !stopped.load(Ordering::Relaxed) { match listener.accept() { Ok(mut stream) => { @@ -223,6 +226,7 @@ fn start_in( drop(listener); }); Ok(Start::Primary(Instance { + lock, stop, worker: Some(worker), })) @@ -295,6 +299,10 @@ mod tests { 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(), diff --git a/src/repository.rs b/src/repository.rs index 3bcdf7d..b7b2bce 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -540,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) } From 43cffbd2a50464f57e7d30aa4dad8e6dab464fa9 Mon Sep 17 00:00:00 2001 From: Jordan Howlett Date: Wed, 14 Oct 2026 17:34:59 -0600 Subject: [PATCH 13/13] Validate native package configuration before building --- scripts/release-desktop.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/scripts/release-desktop.py b/scripts/release-desktop.py index 6d059ff..a9ae4f9 100644 --- a/scripts/release-desktop.py +++ b/scripts/release-desktop.py @@ -23,6 +23,21 @@ def run(args, env, capture=False): 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) @@ -42,6 +57,10 @@ def main(): 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) @@ -49,17 +68,6 @@ def main(): 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) - config = metadata["metadata"]["packager"].copy() - config.pop("macos", None) - config.pop("before-packaging-command", None) - config.update({ - "version": 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 args.platform == "windows": - config["nsis"] = {"install-mode": "currentUser"} 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")