authorgravatar for rodrigobrechard@gmail.comRodrigo Brechard <rodrigobrechard@gmail.com> 2026-08-15 16:13:01+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 16:13:01+02:00
logad117235b544e23545fe39143812db2ddd41af1f
tree72ec6e2078215f2104c63667f488e359aa7ced55
parent20a70420a85aecab0bde1a58921197f423e401cc
signature Signed by PGP key B5690EEEBB952194

feat(desktop): add signal macOS DMG installer background (#6201)

Co-authored-by: Rodrigo Brechard <rodrigo@clubtidy.fr> Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

13 files changed, 384 insertions(+), 25 deletions(-)

apps/desktop/resources/dmg/dmg-background-latest.svg created+33
......@@ -0,0 +1,33 @@
1<svg width="1080" height="760" viewBox="0 0 540 380" fill="none" xmlns="http://www.w3.org/2000/svg">
2 <defs>
3 <linearGradient id="latest-surface" x1="0" y1="0" x2="540" y2="380" gradientUnits="userSpaceOnUse">
4 <stop stop-color="#FAFAF9"/>
5 <stop offset="1" stop-color="#F5F5F4"/>
6 </linearGradient>
7 <radialGradient id="latest-light" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(430 0) rotate(152) scale(320 150)">
8 <stop stop-color="#FFFFFF"/>
9 <stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
10 </radialGradient>
11 </defs>
12
13 <rect width="540" height="380" fill="url(#latest-surface)"/>
14 <rect width="540" height="112" fill="#EFEFED"/>
15 <rect width="540" height="112" fill="url(#latest-light)"/>
16 <path d="M0 111.5H540" stroke="#18181B" stroke-opacity="0.08"/>
17 <path d="M32 28H64" stroke="#18181B" stroke-width="2"/>
18
19 <g font-family="-apple-system, BlinkMacSystemFont, sans-serif" fill="#18181B">
20 <text x="32" y="50" font-size="12" font-weight="600" letter-spacing="1.4">T3 CODE</text>
21 <text x="32" y="82" font-size="22" font-weight="600" letter-spacing="-0.45">Desktop</text>
22 <text x="508" y="50" text-anchor="end" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="8.5" font-weight="600" letter-spacing="1.2" fill="#71717A">LATEST RELEASE</text>
23 </g>
24
25 <path d="M196 224C242 239 307 201 362 220" stroke="#71717A" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="1 7" opacity="0.42"/>
26 <circle cx="362" cy="220" r="2" fill="#71717A" opacity="0.55"/>
27 <path d="M176 204V227L182 221L187.5 232L192 229.8L186.5 219H194L176 204Z" fill="#71717A" stroke="#F7F7F5" stroke-width="1.5" stroke-linejoin="round"/>
28
29 <g font-family="-apple-system, BlinkMacSystemFont, sans-serif" text-anchor="middle">
30 <text x="270" y="322" font-size="13" font-weight="600" letter-spacing="-0.1" fill="#18181B">Drag T3 Code to Applications</text>
31 <text x="270" y="345" font-size="9.5" fill="#71717A">Open it from Applications when the copy finishes.</text>
32 </g>
33</svg>
apps/desktop/resources/dmg/dmg-background-nightly.svg created+64
......@@ -0,0 +1,64 @@
1<svg width="1080" height="760" viewBox="0 0 540 380" fill="none" xmlns="http://www.w3.org/2000/svg">
2 <defs>
3 <linearGradient id="night-sky" x1="28" y1="0" x2="510" y2="112" gradientUnits="userSpaceOnUse">
4 <stop stop-color="#07152F"/>
5 <stop offset="0.52" stop-color="#151443"/>
6 <stop offset="1" stop-color="#32155B"/>
7 </linearGradient>
8 <radialGradient id="night-glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(420 18) rotate(157) scale(260 126)">
9 <stop stop-color="#5165D8" stop-opacity="0.42"/>
10 <stop offset="0.55" stop-color="#283075" stop-opacity="0.15"/>
11 <stop offset="1" stop-color="#111635" stop-opacity="0"/>
12 </radialGradient>
13 <linearGradient id="night-cloud" x1="18" y1="62" x2="260" y2="112" gradientUnits="userSpaceOnUse">
14 <stop stop-color="#4EA4FF" stop-opacity="0.5"/>
15 <stop offset="0.52" stop-color="#696FEA" stop-opacity="0.62"/>
16 <stop offset="1" stop-color="#A85BEA" stop-opacity="0.5"/>
17 </linearGradient>
18 <filter id="night-soft" x="-24" y="35" width="588" height="101" filterUnits="userSpaceOnUse">
19 <feGaussianBlur stdDeviation="4"/>
20 </filter>
21 </defs>
22
23 <rect width="540" height="380" fill="#F7F7F5"/>
24 <rect width="540" height="112" fill="url(#night-sky)"/>
25 <rect width="540" height="112" fill="url(#night-glow)"/>
26
27 <g fill="#E4EAFF">
28 <circle cx="20" cy="18" r="0.8" opacity="0.82"/>
29 <circle cx="76" cy="27" r="0.55" opacity="0.55"/>
30 <circle cx="133" cy="13" r="0.65" opacity="0.7"/>
31 <circle cx="198" cy="28" r="0.5" opacity="0.5"/>
32 <circle cx="258" cy="15" r="0.75" opacity="0.8"/>
33 <circle cx="326" cy="29" r="0.5" opacity="0.55"/>
34 <circle cx="384" cy="12" r="0.65" opacity="0.72"/>
35 <circle cx="455" cy="26" r="0.55" opacity="0.58"/>
36 <circle cx="517" cy="15" r="0.75" opacity="0.78"/>
37 </g>
38 <g stroke="#EEF2FF" stroke-linecap="round" stroke-width="0.65" opacity="0.68">
39 <path d="M104 30V34M102 32H106"/>
40 <path d="M448 24V28M446 26H450"/>
41 </g>
42
43 <g filter="url(#night-soft)">
44 <path d="M-12 112V101C-12 87 0 76 14 76C18 63 30 54 44 54C58 54 70 62 74 75C79 70 86 67 94 67C110 67 123 79 124 95C132 96 138 101 141 112H-12Z" fill="url(#night-cloud)"/>
45 <path d="M374 112C375 98 387 87 401 87C405 72 418 62 434 62C450 62 463 72 467 87C478 87 488 94 492 104C506 103 518 106 526 112H374Z" fill="url(#night-cloud)" opacity="0.82"/>
46 </g>
47 <path d="M0 111.5H540" stroke="#050A19" stroke-opacity="0.3"/>
48 <path d="M32 28H64" stroke="#FFFFFF" stroke-width="2"/>
49
50 <g font-family="-apple-system, BlinkMacSystemFont, sans-serif" fill="#FFFFFF">
51 <text x="32" y="50" font-size="12" font-weight="600" letter-spacing="1.4">T3 CODE</text>
52 <text x="32" y="82" font-size="22" font-weight="600" letter-spacing="-0.45">Desktop</text>
53 <text x="508" y="50" text-anchor="end" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="8.5" font-weight="600" letter-spacing="1.2" opacity="0.84">NIGHTLY BUILD</text>
54 </g>
55
56 <path d="M196 224C242 239 307 201 362 220" stroke="#71717A" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="1 7" opacity="0.42"/>
57 <circle cx="362" cy="220" r="2" fill="#71717A" opacity="0.55"/>
58 <path d="M176 204V227L182 221L187.5 232L192 229.8L186.5 219H194L176 204Z" fill="#71717A" stroke="#F7F7F5" stroke-width="1.5" stroke-linejoin="round"/>
59
60 <g font-family="-apple-system, BlinkMacSystemFont, sans-serif" text-anchor="middle">
61 <text x="270" y="322" font-size="13" font-weight="600" letter-spacing="-0.1" fill="#18181B">Drag T3 Code to Applications</text>
62 <text x="270" y="345" font-size="9.5" fill="#71717A">Open it from Applications when the copy finishes.</text>
63 </g>
64</svg>
apps/desktop/resources/icon.icns deleted
Binary files a/apps/desktop/resources/icon.icns and /dev/null differ
apps/desktop/resources/icon.ico deleted
Binary files a/apps/desktop/resources/icon.ico and /dev/null differ
apps/desktop/resources/icon.png deleted
Binary files a/apps/desktop/resources/icon.png and /dev/null differ
apps/desktop/scripts/electron-launcher.mjs+17-16
......@@ -20,14 +20,14 @@ export const APP_BUNDLE_ID = isDevelopment
2020 ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}`
2121 : "com.t3tools.t3code";
2222const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"];
23const LAUNCHER_VERSION = 14;
24const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns");
23const LAUNCHER_VERSION = 15;
2524const developmentMacIconPngPath = NodePath.join(
2625 repoRoot,
2726 "assets",
2827 "dev",
2928 "blueprint-macos-1024.png",
3029);
30const productionMacIconPngPath = NodePath.join(repoRoot, "assets", "prod", "black-macos-1024.png");
3131// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher script has no Effect runtime.
3232const hostPlatform = NodeOS.platform();
3333
......@@ -165,15 +165,22 @@ function registerMacLauncherBundle(appBundlePath) {
165165 }
166166}
167167
168function ensureDevelopmentIconIcns(runtimeDir) {
169 const generatedIconPath = NodePath.join(runtimeDir, "icon-dev.icns");
168export function resolveMacLauncherIconPaths(runtimeDir, development = isDevelopment) {
169 return {
170 sourceIconPath: development ? developmentMacIconPngPath : productionMacIconPngPath,
171 generatedIconPath: NodePath.join(runtimeDir, development ? "icon-dev.icns" : "icon-prod.icns"),
172 };
173}
174
175function ensureMacIconIcns(runtimeDir) {
176 const { sourceIconPath, generatedIconPath } = resolveMacLauncherIconPaths(runtimeDir);
170177 NodeFS.mkdirSync(runtimeDir, { recursive: true });
171178
172 if (!NodeFS.existsSync(developmentMacIconPngPath)) {
173 return defaultIconPath;
179 if (!NodeFS.existsSync(sourceIconPath)) {
180 throw new Error(`Desktop macOS icon source is missing at ${sourceIconPath}`);
174181 }
175182
176 const sourceMtimeMs = NodeFS.statSync(developmentMacIconPngPath).mtimeMs;
183 const sourceMtimeMs = NodeFS.statSync(sourceIconPath).mtimeMs;
177184 if (
178185 NodeFS.existsSync(generatedIconPath) &&
179186 NodeFS.statSync(generatedIconPath).mtimeMs >= sourceMtimeMs
......@@ -191,7 +198,7 @@ function ensureDevelopmentIconIcns(runtimeDir) {
191198 "-z",
192199 String(size),
193200 String(size),
194 developmentMacIconPngPath,
201 sourceIconPath,
195202 "--out",
196203 NodePath.join(iconsetDir, `icon_${size}x${size}.png`),
197204 ]);
......@@ -201,7 +208,7 @@ function ensureDevelopmentIconIcns(runtimeDir) {
201208 "-z",
202209 String(retinaSize),
203210 String(retinaSize),
204 developmentMacIconPngPath,
211 sourceIconPath,
205212 "--out",
206213 NodePath.join(iconsetDir, `icon_${size}x${size}@2x.png`),
207214 ]);
......@@ -209,12 +216,6 @@ function ensureDevelopmentIconIcns(runtimeDir) {
209216
210217 runChecked("iconutil", ["-c", "icns", iconsetDir, "-o", generatedIconPath]);
211218 return generatedIconPath;
212 } catch (error) {
213 console.warn(
214 "[desktop-launcher] Failed to generate dev macOS icon, falling back to default icon.",
215 error,
216 );
217 return defaultIconPath;
218219 } finally {
219220 NodeFS.rmSync(iconsetRoot, { recursive: true, force: true });
220221 }
......@@ -297,7 +298,7 @@ function buildMacLauncher(electronBinaryPath) {
297298 const launcherBinaryPath = isDevelopment
298299 ? developmentPaths.launcherBinaryPath
299300 : runtimeElectronBinaryPath;
300 const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath;
301 const iconPath = ensureMacIconIcns(runtimeDir);
301302 const metadataPath = NodePath.join(runtimeDir, "metadata.json");
302303
303304 NodeFS.mkdirSync(runtimeDir, { recursive: true });
apps/desktop/scripts/electron-launcher.test.mjs+11
......@@ -3,6 +3,7 @@ import { assert, describe, it } from "vite-plus/test";
33import {
44 makeDevelopmentLauncherScript,
55 resolveElectronBinaryPath,
6 resolveMacLauncherIconPaths,
67 resolveMacLauncherPaths,
78} from "./electron-launcher.mjs";
89
......@@ -78,4 +79,14 @@ describe("electron development launcher", () => {
7879 );
7980 assert.notInclude(script, "node_modules/electron");
8081 });
82
83 it("derives launcher icons from canonical development and production assets", () => {
84 const development = resolveMacLauncherIconPaths("/runtime", true);
85 const production = resolveMacLauncherIconPaths("/runtime", false);
86
87 assert.match(development.sourceIconPath, /assets\/dev\/blueprint-macos-1024\.png$/);
88 assert.equal(development.generatedIconPath, "/runtime/icon-dev.icns");
89 assert.match(production.sourceIconPath, /assets\/prod\/black-macos-1024\.png$/);
90 assert.equal(production.generatedIconPath, "/runtime/icon-prod.icns");
91 });
8192});
apps/desktop/src/app/DesktopAssets.test.ts+40
......@@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest";
33import * as Effect from "effect/Effect";
44import * as FileSystem from "effect/FileSystem";
55import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
67import * as PlatformError from "effect/PlatformError";
78
89import * as DesktopAssets from "./DesktopAssets.ts";
......@@ -22,6 +23,45 @@ const environmentLayer = DesktopEnvironment.layer({
2223}).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))));
2324
2425describe("DesktopAssets", () => {
26 it.effect("uses canonical source-tree icons for unpackaged development", () =>
27 Effect.gen(function* () {
28 const developmentEnvironmentLayer = DesktopEnvironment.layer({
29 dirname: "/repo/apps/desktop/dist-electron",
30 homeDirectory: "/Users/alice",
31 platform: "linux",
32 processArch: "x64",
33 appVersion: "1.2.3",
34 appPath: "/repo",
35 isPackaged: false,
36 resourcesPath: "/repo/apps/desktop/resources",
37 runningUnderArm64Translation: false,
38 }).pipe(
39 Layer.provide(
40 Layer.mergeAll(
41 NodeServices.layer,
42 DesktopConfig.layerTest({ VITE_DEV_SERVER_URL: "http://localhost:5733" }),
43 ),
44 ),
45 );
46 const fileSystemLayer = FileSystem.layerNoop({
47 exists: (path) => Effect.succeed(String(path).includes("/assets/dev/")),
48 });
49 const assets = yield* DesktopAssets.DesktopAssets.pipe(
50 Effect.provide(
51 DesktopAssets.layer.pipe(
52 Layer.provide(Layer.merge(fileSystemLayer, developmentEnvironmentLayer)),
53 ),
54 ),
55 );
56
57 const icons = yield* assets.iconPaths;
58
59 assert.match(Option.getOrThrow(icons.ico), /assets\/dev\/blueprint-windows\.ico$/);
60 assert.match(Option.getOrThrow(icons.png), /assets\/dev\/blueprint-universal-1024\.png$/);
61 assert.isTrue(Option.isNone(icons.icns));
62 }),
63 );
64
2565 it.effect("preserves the failed asset candidate and filesystem cause", () =>
2666 Effect.gen(function* () {
2767 const fileName = "custom.bin";
apps/desktop/src/app/DesktopAssets.ts+36-7
......@@ -61,6 +61,35 @@ const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(func
6161 return Option.none<string>();
6262});
6363
64const sourceTreeIconFileNames = {
65 dev: {
66 ico: "blueprint-windows.ico",
67 macPng: "blueprint-macos-1024.png",
68 universalPng: "blueprint-universal-1024.png",
69 },
70 prod: {
71 ico: "t3-black-windows.ico",
72 macPng: "black-macos-1024.png",
73 universalPng: "black-universal-1024.png",
74 },
75} as const;
76
77function resolveSourceTreeIconPath(
78 environment: DesktopEnvironment.DesktopEnvironment["Service"],
79 ext: keyof DesktopIconPaths,
80): string | undefined {
81 if (environment.isPackaged || ext === "icns") return undefined;
82 const brand = environment.isDevelopment ? "dev" : "prod";
83 const fileNames = sourceTreeIconFileNames[brand];
84 const fileName =
85 ext === "ico"
86 ? fileNames.ico
87 : environment.platform === "darwin"
88 ? fileNames.macPng
89 : fileNames.universalPng;
90 return environment.path.join(environment.rootDir, "assets", brand, fileName);
91}
92
6493const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* (
6594 ext: keyof DesktopIconPaths,
6695): Effect.fn.Return<
......@@ -70,20 +99,20 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* (
7099> {
71100 const fileSystem = yield* FileSystem.FileSystem;
72101 const environment = yield* DesktopEnvironment.DesktopEnvironment;
73 if (environment.isDevelopment && environment.platform === "darwin" && ext === "png") {
74 const developmentDockIconPath = environment.developmentDockIconPath;
75 const developmentDockIconExists = yield* fileSystem.exists(developmentDockIconPath).pipe(
102 const sourceTreeIconPath = resolveSourceTreeIconPath(environment, ext);
103 if (sourceTreeIconPath !== undefined) {
104 const sourceTreeIconExists = yield* fileSystem.exists(sourceTreeIconPath).pipe(
76105 Effect.mapError(
77106 (cause) =>
78107 new DesktopAssetProbeError({
79 fileName: "icon.png",
80 candidatePath: developmentDockIconPath,
108 fileName: `icon.${ext}`,
109 candidatePath: sourceTreeIconPath,
81110 cause,
82111 }),
83112 ),
84113 );
85 if (developmentDockIconExists) {
86 return Option.some(developmentDockIconPath);
114 if (sourceTreeIconExists) {
115 return Option.some(sourceTreeIconPath);
87116 }
88117 }
89118
apps/desktop/src/app/DesktopEnvironment.ts-2
......@@ -82,7 +82,6 @@ export class DesktopEnvironment extends Context.Service<
8282 readonly runtimeInfo: DesktopRuntimeInfo;
8383 readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option<string>;
8484 readonly resolveResourcePathCandidates: (fileName: string) => readonly string[];
85 readonly developmentDockIconPath: string;
8685 }
8786>()("@t3tools/desktop/app/DesktopEnvironment") {}
8887
......@@ -270,7 +269,6 @@ const make = Effect.fn("desktop.environment.make")(function* (
270269 path.join(resourcesPath, "resources", fileName),
271270 path.join(resourcesPath, fileName),
272271 ],
273 developmentDockIconPath: path.join(rootDir, "assets", "dev", "blueprint-macos-1024.png"),
274272 });
275273});
276274
docs/internals/scripts.md+5
......@@ -78,6 +78,11 @@ authenticated.
7878
7979- Default build is unsigned/not notarized for local sharing.
8080- The DMG build uses `assets/prod/black-macos-1024.png` as the production app icon source.
81- The DMG chrome follows the release channel: neutral for Latest and the Nightly sky artwork for
82 Nightly. Blueprint artwork remains exclusive to Dev builds. Packaging rasterizes the selected
83 SVG into standard and Retina PNGs inside the disposable staging directory.
84- The Finder window is 540×412 while its background is 540×380; the extra 32px accounts for the
85 title bar included in Finder's window bounds.
8186- Desktop production windows load the bundled UI from the `t3code://app/` root URL (not a
8287 `127.0.0.1` document URL, and not an explicit `index.html` path).
8388- Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an
scripts/build-desktop-artifact.test.ts+103
......@@ -13,6 +13,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
1313import {
1414 BundleNotSelfContainedError,
1515 BuildCommandFailedError,
16 DesktopDmgBackgroundSourceMissingError,
1617 createStageWorkspaceConfig,
1718 createStagePatchedDependencies,
1819 createBuildConfig,
......@@ -45,6 +46,7 @@ import {
4546 resolveMockUpdateServerUrl,
4647 resolvePackageManagerUserAgent,
4748 stageLinuxIconSize,
49 stageDesktopDmgBackground,
4850 STAGE_INSTALL_ARGS,
4951 ancestorNodeModulesPaths,
5052 copyDirectoryPreservingSymlinks,
......@@ -463,6 +465,17 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
463465 "**/node_modules/.bin",
464466 "**/node_modules/.bin/**",
465467 ]);
468 assert.deepStrictEqual(mac.dmg, {
469 title: "T3 Code (Alpha) 1.2.3 Installer",
470 background: "dmg/dmg-background-latest.png",
471 window: { width: 540, height: 412 },
472 contents: [
473 { x: 130, y: 220, type: "file" },
474 { x: 410, y: 220, type: "link", path: "/Applications" },
475 ],
476 iconSize: 80,
477 iconTextSize: 12,
478 });
466479 // Linux must register the renderer schemes so the generated .desktop
467480 // entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links.
468481 assert.deepStrictEqual((linux.linux as Record<string, unknown>).protocols, [
......@@ -784,6 +797,77 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
784797 });
785798 });
786799
800 it.effect("rasterizes staged DMG backgrounds at standard and Retina sizes", () =>
801 Effect.scoped(
802 Effect.gen(function* () {
803 const fs = yield* FileSystem.FileSystem;
804 const path = yield* Path.Path;
805 const stageResourcesDir = yield* fs.makeTempDirectoryScoped({
806 prefix: "t3code-dmg-background-",
807 });
808 const dmgDir = path.join(stageResourcesDir, "dmg");
809 yield* fs.makeDirectory(dmgDir, { recursive: true });
810 const sourcePath = path.join(dmgDir, "dmg-background-nightly.svg");
811 yield* fs.writeFileString(sourcePath, '<svg xmlns="http://www.w3.org/2000/svg"/>');
812 const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> =
813 [];
814
815 yield* stageDesktopDmgBackground(stageResourcesDir, "nightly", false).pipe(
816 Effect.provide(iconResizeSpawnerLayer(commands, [0, 0])),
817 );
818
819 assert.deepStrictEqual(
820 commands.map((command) => [command.command, ...command.args]),
821 [
822 [
823 "sips",
824 "-s",
825 "format",
826 "png",
827 "-z",
828 "380",
829 "540",
830 sourcePath,
831 "--out",
832 path.join(dmgDir, "dmg-background-nightly.png"),
833 ],
834 [
835 "sips",
836 "-s",
837 "format",
838 "png",
839 "-z",
840 "760",
841 "1080",
842 sourcePath,
843 "--out",
844 path.join(dmgDir, "dmg-background-nightly@2x.png"),
845 ],
846 ],
847 );
848 }),
849 ),
850 );
851
852 it.effect("fails clearly when the selected DMG background source is missing", () =>
853 Effect.scoped(
854 Effect.gen(function* () {
855 const fs = yield* FileSystem.FileSystem;
856 const stageResourcesDir = yield* fs.makeTempDirectoryScoped({
857 prefix: "t3code-dmg-background-missing-",
858 });
859
860 const error = yield* stageDesktopDmgBackground(stageResourcesDir, "latest", false).pipe(
861 Effect.flip,
862 );
863
864 assert.instanceOf(error, DesktopDmgBackgroundSourceMissingError);
865 assert.equal(error.channel, "latest");
866 assert.include(error.sourcePath, "dmg-background-latest.svg");
867 }),
868 ),
869 );
870
787871 it("derives macOS passkey signing configuration from the Clerk publishable key", () => {
788872 const configuration = resolveMacPasskeySigningConfiguration({
789873 T3CODE_APPLE_TEAM_ID: "abc1234567",
......@@ -916,6 +1000,25 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
9161000 }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
9171001 );
9181002
1003 it.effect("uses the nightly DMG background for nightly macOS builds", () =>
1004 Effect.gen(function* () {
1005 const config = yield* createBuildConfig(
1006 "mac",
1007 "dmg",
1008 "1.2.3-nightly.20260815.1",
1009 false,
1010 false,
1011 undefined,
1012 undefined,
1013 );
1014
1015 assert.equal(
1016 (config.dmg as Record<string, unknown>).background,
1017 "dmg/dmg-background-nightly.png",
1018 );
1019 }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
1020 );
1021
9191022 it.effect("keeps executable resource editing enabled for unsigned Windows builds", () =>
9201023 Effect.gen(function* () {
9211024 const config = yield* createBuildConfig(
scripts/build-desktop-artifact.ts+75
......@@ -318,6 +318,18 @@ export class DesktopIconSourceMissingError extends Schema.TaggedErrorClass<Deskt
318318 }
319319}
320320
321export class DesktopDmgBackgroundSourceMissingError extends Schema.TaggedErrorClass<DesktopDmgBackgroundSourceMissingError>()(
322 "DesktopDmgBackgroundSourceMissingError",
323 {
324 channel: Schema.Literals(["latest", "nightly"]),
325 sourcePath: Schema.String,
326 },
327) {
328 override get message(): string {
329 return `Desktop ${this.channel} DMG background source is missing at ${this.sourcePath}`;
330 }
331}
332
321333export class BundledClientAssetsMissingError extends Schema.TaggedErrorClass<BundledClientAssetsMissingError>()(
322334 "BundledClientAssetsMissingError",
323335 {
......@@ -1770,6 +1782,39 @@ function stageMacIcons(stageResourcesDir: string, sourcePng: string, verbose: bo
17701782 });
17711783}
17721784
1785export const stageDesktopDmgBackground = Effect.fn("stageDesktopDmgBackground")(function* (
1786 stageResourcesDir: string,
1787 channel: "latest" | "nightly",
1788 verbose: boolean,
1789) {
1790 const fs = yield* FileSystem.FileSystem;
1791 const path = yield* Path.Path;
1792 const sourcePath = path.join(stageResourcesDir, "dmg", `dmg-background-${channel}.svg`);
1793 if (!(yield* fs.exists(sourcePath))) {
1794 return yield* new DesktopDmgBackgroundSourceMissingError({ channel, sourcePath });
1795 }
1796
1797 for (const output of [
1798 { suffix: "", width: 540, height: 380 },
1799 { suffix: "@2x", width: 1080, height: 760 },
1800 ] as const) {
1801 const targetPath = path.join(
1802 stageResourcesDir,
1803 "dmg",
1804 `dmg-background-${channel}${output.suffix}.png`,
1805 );
1806 yield* runCommand(
1807 ChildProcess.make(
1808 {},
1809 )`sips -s format png -z ${output.height} ${output.width} ${sourcePath} --out ${targetPath}`,
1810 {
1811 label: `sips ${channel} DMG background${output.suffix || "@1x"}`,
1812 verbose,
1813 },
1814 );
1815 }
1816});
1817
17731818function stageLinuxIcons(stageResourcesDir: string, sourcePng: string, verbose: boolean) {
17741819 return Effect.gen(function* () {
17751820 const fs = yield* FileSystem.FileSystem;
......@@ -2039,6 +2084,29 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
20392084 };
20402085 }
20412086
2087 if (platform === "mac" && target === "dmg") {
2088 buildConfig.dmg = {
2089 // Give the themed installer its own Finder volume name. Finder caches
2090 // DMG window backgrounds by volume name, so reusing a generic name can
2091 // make a newly built background look unchanged during testing.
2092 title: `${resolveDesktopProductName(version)} ${version} Installer`,
2093 background: `dmg/dmg-background-${updateChannel}.png`,
2094 window: {
2095 width: 540,
2096 // Finder counts its 32px title bar in the window bounds. The themed
2097 // background itself is 380px tall, so add the chrome height here to
2098 // keep the full canvas visible.
2099 height: 412,
2100 },
2101 contents: [
2102 { x: 130, y: 220, type: "file" },
2103 { x: 410, y: 220, type: "link", path: "/Applications" },
2104 ],
2105 iconSize: 80,
2106 iconTextSize: 12,
2107 };
2108 }
2109
20422110 if (platform === "linux") {
20432111 buildConfig.linux = {
20442112 target: [target],
......@@ -2738,6 +2806,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
27382806 yield* Effect.log("[desktop-artifact] Staging release app...");
27392807 yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"));
27402808 yield* fs.copy(distDirs.desktopResources, stageResourcesDir);
2809 if (options.platform === "mac" && options.target === "dmg") {
2810 yield* stageDesktopDmgBackground(
2811 stageResourcesDir,
2812 resolveDesktopUpdateChannel(appVersion),
2813 options.verbose,
2814 );
2815 }
27412816 // On Windows the server tree ships in the server.asar sidecar instead of
27422817 // app.asar (see stageWindowsServerSidecar), so the app stage omits it.
27432818 if (options.platform !== "win") {