diff --git a/.github/workflows/desktop-test.yml b/.github/workflows/desktop-test.yml new file mode 100644 index 0000000000..84f52ce15f --- /dev/null +++ b/.github/workflows/desktop-test.yml @@ -0,0 +1,50 @@ +name: desktop-test + +on: + push: + branches: + - main + - desktop-experimental + paths: + - "apps/examples/desktop-app/package.json" + - "apps/examples/desktop-app/scripts/dmg-background.ts" + - "apps/examples/desktop-app/scripts/dmg-background.test.ts" + - "apps/examples/desktop-app/src-tauri/dmg/background.png" + - "apps/examples/desktop-app/src-tauri/dmg/background@2x.png" + - ".github/workflows/desktop-test.yml" + pull_request: + branches: + - main + - desktop-experimental + paths: + - "apps/examples/desktop-app/package.json" + - "apps/examples/desktop-app/scripts/dmg-background.ts" + - "apps/examples/desktop-app/scripts/dmg-background.test.ts" + - "apps/examples/desktop-app/src-tauri/dmg/background.png" + - "apps/examples/desktop-app/src-tauri/dmg/background@2x.png" + - ".github/workflows/desktop-test.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + dmg-background: + name: Test DMG background tooling + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/examples/desktop-app + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.13" + + # The suite only uses Bun/Node built-ins and committed artwork, so it does + # not need a workspace dependency install or macOS runner. + - name: Test DMG background tooling + run: bun run test:dmg-background diff --git a/.gitignore b/.gitignore index e2daf230c8..1962454e11 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,7 @@ apps/vscode/tsconfig.test.generated.json .next/dev/static **/src-tauri/target/debug/.fingerprint apps/examples/desktop-app/src-tauri/target +apps/examples/desktop-app/src-tauri/dmg/background.gen.tiff apps/examples/desktop-app/webview/.next # Next.js generated type shim (churns between dev and build) diff --git a/apps/examples/desktop-app/README.md b/apps/examples/desktop-app/README.md index 0673714bee..578c238709 100644 --- a/apps/examples/desktop-app/README.md +++ b/apps/examples/desktop-app/README.md @@ -17,6 +17,38 @@ From `apps/examples/desktop-app/`: - `bun run package:desktop` - package the current OS desktop app into `dist/desktop/` - `bun run typecheck` - TypeScript check +## Customizing the macOS Install Window + +The drag-to-Applications window is configured by `bundle.macOS.dmg` in +[`src-tauri/tauri.conf.json`](./src-tauri/tauri.conf.json). Its artwork comes +from the PNG sources in [`src-tauri/dmg/`](./src-tauri/dmg/); the +`background.gen.tiff` Finder actually renders is a gitignored build artifact +regenerated from them on every build. + +1. The current source artwork is `640x400`. Export `background.png` at 1x and + `background@2x.png` at 2x. +2. Currently the app icons are centered at `(140, 200)` and + the Applications folder centered at `(500, 200)`. If updating artwork, update `appPosition` + and `applicationFolderPosition` to reposition the app icons. +3. Build with `bun run build:binary`. Before compiling, the build validates + both PNG dimensions, combines them with `tiffutil` into the Retina-aware + `src-tauri/dmg/background.gen.tiff`, and verifies the TIFF contains the + expected 1x and 2x representations. Run `bun run dmg:background` to do just + that step, e.g. to sanity-check new artwork without a full build. The DMG + is written beneath `src-tauri/target/release/bundle/dmg/`. + +Run `bun run test:dmg-background` for the cross-platform checks covering the +committed PNG dimensions and TIFF validation logic. + +The configured `640x432` Finder window is intentionally 32 points taller than +the `640x400` background. That extra height matches the Finder chrome in the +currently verified packaged layout; re-check it after material macOS or Finder +changes. The project deliberately uses a multi-resolution TIFF even though +Tauri's documented background formats are PNG, JPG, and GIF: Finder renders +both the 1x and 2x representations from a single background file. Re-check the +packaged DMG after upgrading Tauri in case its background validation changes. + + ## Login Shell PATH Resolution Apps launched from Finder/the Dock inherit launchd's minimal `PATH` diff --git a/apps/examples/desktop-app/package.json b/apps/examples/desktop-app/package.json index cfbf2803cc..e5bdef2567 100644 --- a/apps/examples/desktop-app/package.json +++ b/apps/examples/desktop-app/package.json @@ -15,6 +15,8 @@ "build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun", "build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts", "build:binary": "tauri build", + "dmg:background": "bun run scripts/dmg-background.ts", + "test:dmg-background": "bun test scripts/dmg-background.test.ts", "package": "bun run package:desktop", "package:desktop": "bun run scripts/package-desktop.ts", "package:desktop:mac": "bun run scripts/package-desktop.ts --platform mac", diff --git a/apps/examples/desktop-app/scripts/dmg-background.test.ts b/apps/examples/desktop-app/scripts/dmg-background.test.ts new file mode 100644 index 0000000000..38099192e9 --- /dev/null +++ b/apps/examples/desktop-app/scripts/dmg-background.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; +import path from "node:path"; +import { + parseTiffInfo, + readPngDimensions, + validateTiffRepresentations, +} from "./dmg-background"; + +const DMG_ROOT = path.resolve(import.meta.dir, "..", "src-tauri", "dmg"); + +const EXPECTED_REPRESENTATIONS = [ + { width: 640, height: 400, dpiX: 72, dpiY: 72 }, + { width: 1280, height: 800, dpiX: 144, dpiY: 144 }, +]; + +const TIFF_INFO = `Directory at 0x1 + Image Width: 640 Image Length: 400 + Resolution: 72, 72 + Resolution Unit: pixels/inch +Directory at 0x2 + Image Width: 1280 Image Length: 800 + Resolution: 144, 144 + Resolution Unit: pixels/inch +`; + +describe("parseTiffInfo", () => { + test("reads the dimensions and DPI of every TIFF representation", () => { + expect(parseTiffInfo(TIFF_INFO)).toEqual(EXPECTED_REPRESENTATIONS); + }); + + test("rejects representations without pixel-per-inch resolution", () => { + expect(() => + parseTiffInfo(TIFF_INFO.replace("pixels/inch", "pixels/cm")), + ).toThrow(/could not parse TIFF representation/); + }); +}); + +describe("DMG source artwork", () => { + test("has the expected 1x and 2x dimensions", async () => { + const [dimensions1x, dimensions2x] = await Promise.all([ + readPngDimensions(path.join(DMG_ROOT, "background.png")), + readPngDimensions(path.join(DMG_ROOT, "background@2x.png")), + ]); + + expect(dimensions1x).toEqual({ width: 640, height: 400 }); + expect(dimensions2x).toEqual({ width: 1280, height: 800 }); + }); +}); + +describe("validateTiffRepresentations", () => { + test("accepts the expected representations", () => { + expect(() => + validateTiffRepresentations(EXPECTED_REPRESENTATIONS), + ).not.toThrow(); + }); + + test("rejects the wrong number of representations", () => { + expect(() => + validateTiffRepresentations(EXPECTED_REPRESENTATIONS.slice(0, 1)), + ).toThrow(/exactly two image representations/); + }); + + test("rejects incorrect representation dimensions", () => { + expect(() => + validateTiffRepresentations([ + EXPECTED_REPRESENTATIONS[0], + { ...EXPECTED_REPRESENTATIONS[1], width: 1279 }, + ]), + ).toThrow(/must be 1280x800/); + }); + + test("rejects incorrect representation DPI", () => { + expect(() => + validateTiffRepresentations([ + { ...EXPECTED_REPRESENTATIONS[0], dpiX: 73 }, + EXPECTED_REPRESENTATIONS[1], + ]), + ).toThrow(/must be 72x72 DPI/); + }); +}); diff --git a/apps/examples/desktop-app/scripts/dmg-background.ts b/apps/examples/desktop-app/scripts/dmg-background.ts new file mode 100644 index 0000000000..ccacd1ac99 --- /dev/null +++ b/apps/examples/desktop-app/scripts/dmg-background.ts @@ -0,0 +1,175 @@ +import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { $ } from "bun"; + +type Dimensions = { + width: number; + height: number; +}; + +type TiffRepresentation = Dimensions & { + dpiX: number; + dpiY: number; +}; + +const APP_ROOT = path.resolve(import.meta.dir, ".."); +const DMG_ROOT = path.join(APP_ROOT, "src-tauri", "dmg"); +const BACKGROUND_1X = path.join(DMG_ROOT, "background.png"); +const BACKGROUND_2X = path.join(DMG_ROOT, "background@2x.png"); +// Gitignored build artifact; only the PNG sources are committed. +const BACKGROUND_TIFF = path.join(DMG_ROOT, "background.gen.tiff"); + +const EXPECTED_1X = { width: 640, height: 400 }; +const EXPECTED_2X = { width: 1280, height: 800 }; +const EXPECTED_TIFF_REPRESENTATIONS: TiffRepresentation[] = [ + { ...EXPECTED_1X, dpiX: 72, dpiY: 72 }, + { ...EXPECTED_2X, dpiX: 144, dpiY: 144 }, +]; + +const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]; + +// PNG stores its big-endian width and height in the fixed IHDR fields at +// byte offsets 16 and 20, so dimensions can be checked without an image library. +export const readPngDimensions = async ( + filePath: string, +): Promise => { + const contents = await readFile(filePath); + const hasPngSignature = PNG_SIGNATURE.every( + (byte, index) => contents[index] === byte, + ); + if ( + contents.length < 24 || + !hasPngSignature || + contents.toString("ascii", 12, 16) !== "IHDR" + ) { + throw new Error(`${filePath} is not a valid PNG with an IHDR header`); + } + + return { + width: contents.readUInt32BE(16), + height: contents.readUInt32BE(20), + }; +}; + +const assertDimensions = ( + label: string, + actual: Dimensions, + expected: Dimensions, +): void => { + if (actual.width !== expected.width || actual.height !== expected.height) { + throw new Error( + `${label} must be ${expected.width}x${expected.height}, got ${actual.width}x${actual.height}`, + ); + } +}; + +// tiffutil prints one "Directory at ..." block for each image representation +// embedded in the TIFF. +export const parseTiffInfo = (output: string): TiffRepresentation[] => + output + .split(/(?=Directory at )/) + .filter((block) => block.startsWith("Directory at ")) + .map((block) => { + const dimensions = block.match( + /Image Width:\s*(\d+)\s+Image Length:\s*(\d+)/, + ); + const resolution = block.match(/Resolution:\s*([\d.]+),\s*([\d.]+)/); + if ( + !dimensions || + !resolution || + !block.includes("Resolution Unit: pixels/inch") + ) { + throw new Error(`could not parse TIFF representation:\n${block}`); + } + + return { + width: Number(dimensions[1]), + height: Number(dimensions[2]), + dpiX: Number(resolution[1]), + dpiY: Number(resolution[2]), + }; + }); + +export const validateTiffRepresentations = ( + representations: TiffRepresentation[], + label = "TIFF", +): void => { + const sortedRepresentations = [...representations].sort( + (left, right) => left.width - right.width, + ); + + if (sortedRepresentations.length !== EXPECTED_TIFF_REPRESENTATIONS.length) { + throw new Error( + `${label} must contain exactly two image representations, got ${sortedRepresentations.length}`, + ); + } + + for (const [index, expected] of EXPECTED_TIFF_REPRESENTATIONS.entries()) { + const actual = sortedRepresentations[index]; + assertDimensions(`${label} representation ${index + 1}`, actual, expected); + if (actual.dpiX !== expected.dpiX || actual.dpiY !== expected.dpiY) { + throw new Error( + `${label} representation ${index + 1} must be ${expected.dpiX}x${expected.dpiY} DPI, got ${actual.dpiX}x${actual.dpiY} DPI`, + ); + } + } +}; + +const assertTiffRepresentations = async (filePath: string): Promise => { + const representations = parseTiffInfo( + await $`tiffutil -info ${filePath}`.quiet().text(), + ); + validateTiffRepresentations(representations, filePath); +}; + +const assertSourceDimensions = async (): Promise => { + const [dimensions1x, dimensions2x] = await Promise.all([ + readPngDimensions(BACKGROUND_1X), + readPngDimensions(BACKGROUND_2X), + ]); + assertDimensions("background.png", dimensions1x, EXPECTED_1X); + assertDimensions("background@2x.png", dimensions2x, EXPECTED_2X); +}; + +const generateTiff = async (outputPath: string): Promise => { + // Finder's .DS_Store references one background file. A multi-representation + // TIFF lets AppKit select the 1x or 2x bitmap without relying on it to discover + // a separate @2x companion beside that referenced file. + await $`tiffutil -cathidpicheck ${BACKGROUND_1X} ${BACKGROUND_2X} -out ${outputPath}`.quiet(); + await assertTiffRepresentations(outputPath); +}; + +const main = async (): Promise => { + if (process.argv.length > 2) { + throw new Error("usage: bun run dmg:background"); + } + if (process.platform !== "darwin") { + // Runs from beforeBuildCommand on every platform, but only macOS builds + // bundle a DMG and only macOS ships tiffutil. + console.log("Skipping DMG background generation on non-macOS host."); + return; + } + + await assertSourceDimensions(); + // Generate and validate in scratch space so the configured build artifact is + // replaced only after tiffutil has produced a complete, verified TIFF. + const scratchRoot = await mkdtemp( + path.join(tmpdir(), "cline-dmg-background-"), + ); + const generatedTiff = path.join(scratchRoot, "background.tiff"); + try { + await generateTiff(generatedTiff); + await copyFile(generatedTiff, BACKGROUND_TIFF); + console.log(`Generated ${path.relative(APP_ROOT, BACKGROUND_TIFF)}.`); + } finally { + await rm(scratchRoot, { force: true, recursive: true }); + } +}; + +if (import.meta.main) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/apps/examples/desktop-app/src-tauri/dmg/background.png b/apps/examples/desktop-app/src-tauri/dmg/background.png new file mode 100644 index 0000000000..4b1e690b03 Binary files /dev/null and b/apps/examples/desktop-app/src-tauri/dmg/background.png differ diff --git a/apps/examples/desktop-app/src-tauri/dmg/background@2x.png b/apps/examples/desktop-app/src-tauri/dmg/background@2x.png new file mode 100644 index 0000000000..dfd56f7c8c Binary files /dev/null and b/apps/examples/desktop-app/src-tauri/dmg/background@2x.png differ diff --git a/apps/examples/desktop-app/src-tauri/tauri.conf.json b/apps/examples/desktop-app/src-tauri/tauri.conf.json index eef52c482f..6c258bdd20 100644 --- a/apps/examples/desktop-app/src-tauri/tauri.conf.json +++ b/apps/examples/desktop-app/src-tauri/tauri.conf.json @@ -6,7 +6,7 @@ "build": { "beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web", "devUrl": "http://localhost:3125", - "beforeBuildCommand": "bun run build", + "beforeBuildCommand": "bun run dmg:background && bun run build", "frontendDist": "../webview/out" }, "plugins": { @@ -48,7 +48,22 @@ ], "macOS": { "entitlements": "entitlements.plist", - "hardenedRuntime": true + "hardenedRuntime": true, + "dmg": { + "background": "dmg/background.gen.tiff", + "windowSize": { + "width": 640, + "height": 432 + }, + "appPosition": { + "x": 140, + "y": 200 + }, + "applicationFolderPosition": { + "x": 500, + "y": 200 + } + } } } }