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...@@ -20,14 +20,14 @@ export const APP_BUNDLE_ID = isDevelopment
20 ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}`20 ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}`
21 : "com.t3tools.t3code";21 : "com.t3tools.t3code";
22const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"];22const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"];
23const LAUNCHER_VERSION = 14;23const LAUNCHER_VERSION = 15;
24const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns");
25const developmentMacIconPngPath = NodePath.join(24const developmentMacIconPngPath = NodePath.join(
26 repoRoot,25 repoRoot,
27 "assets",26 "assets",
28 "dev",27 "dev",
29 "blueprint-macos-1024.png",28 "blueprint-macos-1024.png",
30);29);
30const productionMacIconPngPath = NodePath.join(repoRoot, "assets", "prod", "black-macos-1024.png");
31// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher script has no Effect runtime.31// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher script has no Effect runtime.
32const hostPlatform = NodeOS.platform();32const hostPlatform = NodeOS.platform();
3333
...@@ -165,15 +165,22 @@ function registerMacLauncherBundle(appBundlePath) {...@@ -165,15 +165,22 @@ function registerMacLauncherBundle(appBundlePath) {
165 }165 }
166}166}
167167
168function ensureDevelopmentIconIcns(runtimeDir) {168export function resolveMacLauncherIconPaths(runtimeDir, development = isDevelopment) {
169 const generatedIconPath = NodePath.join(runtimeDir, "icon-dev.icns");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);
170 NodeFS.mkdirSync(runtimeDir, { recursive: true });177 NodeFS.mkdirSync(runtimeDir, { recursive: true });
171178
172 if (!NodeFS.existsSync(developmentMacIconPngPath)) {179 if (!NodeFS.existsSync(sourceIconPath)) {
173 return defaultIconPath;180 throw new Error(`Desktop macOS icon source is missing at ${sourceIconPath}`);
174 }181 }
175182
176 const sourceMtimeMs = NodeFS.statSync(developmentMacIconPngPath).mtimeMs;183 const sourceMtimeMs = NodeFS.statSync(sourceIconPath).mtimeMs;
177 if (184 if (
178 NodeFS.existsSync(generatedIconPath) &&185 NodeFS.existsSync(generatedIconPath) &&
179 NodeFS.statSync(generatedIconPath).mtimeMs >= sourceMtimeMs186 NodeFS.statSync(generatedIconPath).mtimeMs >= sourceMtimeMs
...@@ -191,7 +198,7 @@ function ensureDevelopmentIconIcns(runtimeDir) {...@@ -191,7 +198,7 @@ function ensureDevelopmentIconIcns(runtimeDir) {
191 "-z",198 "-z",
192 String(size),199 String(size),
193 String(size),200 String(size),
194 developmentMacIconPngPath,201 sourceIconPath,
195 "--out",202 "--out",
196 NodePath.join(iconsetDir, `icon_${size}x${size}.png`),203 NodePath.join(iconsetDir, `icon_${size}x${size}.png`),
197 ]);204 ]);
...@@ -201,7 +208,7 @@ function ensureDevelopmentIconIcns(runtimeDir) {...@@ -201,7 +208,7 @@ function ensureDevelopmentIconIcns(runtimeDir) {
201 "-z",208 "-z",
202 String(retinaSize),209 String(retinaSize),
203 String(retinaSize),210 String(retinaSize),
204 developmentMacIconPngPath,211 sourceIconPath,
205 "--out",212 "--out",
206 NodePath.join(iconsetDir, `icon_${size}x${size}@2x.png`),213 NodePath.join(iconsetDir, `icon_${size}x${size}@2x.png`),
207 ]);214 ]);
...@@ -209,12 +216,6 @@ function ensureDevelopmentIconIcns(runtimeDir) {...@@ -209,12 +216,6 @@ function ensureDevelopmentIconIcns(runtimeDir) {
209216
210 runChecked("iconutil", ["-c", "icns", iconsetDir, "-o", generatedIconPath]);217 runChecked("iconutil", ["-c", "icns", iconsetDir, "-o", generatedIconPath]);
211 return generatedIconPath;218 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;
218 } finally {219 } finally {
219 NodeFS.rmSync(iconsetRoot, { recursive: true, force: true });220 NodeFS.rmSync(iconsetRoot, { recursive: true, force: true });
220 }221 }
...@@ -297,7 +298,7 @@ function buildMacLauncher(electronBinaryPath) {...@@ -297,7 +298,7 @@ function buildMacLauncher(electronBinaryPath) {
297 const launcherBinaryPath = isDevelopment298 const launcherBinaryPath = isDevelopment
298 ? developmentPaths.launcherBinaryPath299 ? developmentPaths.launcherBinaryPath
299 : runtimeElectronBinaryPath;300 : runtimeElectronBinaryPath;
300 const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath;301 const iconPath = ensureMacIconIcns(runtimeDir);
301 const metadataPath = NodePath.join(runtimeDir, "metadata.json");302 const metadataPath = NodePath.join(runtimeDir, "metadata.json");
302303
303 NodeFS.mkdirSync(runtimeDir, { recursive: true });304 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";...@@ -3,6 +3,7 @@ import { assert, describe, it } from "vite-plus/test";
3import {3import {
4 makeDevelopmentLauncherScript,4 makeDevelopmentLauncherScript,
5 resolveElectronBinaryPath,5 resolveElectronBinaryPath,
6 resolveMacLauncherIconPaths,
6 resolveMacLauncherPaths,7 resolveMacLauncherPaths,
7} from "./electron-launcher.mjs";8} from "./electron-launcher.mjs";
89
...@@ -78,4 +79,14 @@ describe("electron development launcher", () => {...@@ -78,4 +79,14 @@ describe("electron development launcher", () => {
78 );79 );
79 assert.notInclude(script, "node_modules/electron");80 assert.notInclude(script, "node_modules/electron");
80 });81 });
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 });
81});92});
apps/desktop/src/app/DesktopAssets.test.ts+40
...@@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest";...@@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
6import * as PlatformError from "effect/PlatformError";7import * as PlatformError from "effect/PlatformError";
78
8import * as DesktopAssets from "./DesktopAssets.ts";9import * as DesktopAssets from "./DesktopAssets.ts";
...@@ -22,6 +23,45 @@ const environmentLayer = DesktopEnvironment.layer({...@@ -22,6 +23,45 @@ const environmentLayer = DesktopEnvironment.layer({
22}).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))));23}).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))));
2324
24describe("DesktopAssets", () => {25describe("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
25 it.effect("preserves the failed asset candidate and filesystem cause", () =>65 it.effect("preserves the failed asset candidate and filesystem cause", () =>
26 Effect.gen(function* () {66 Effect.gen(function* () {
27 const fileName = "custom.bin";67 const fileName = "custom.bin";
apps/desktop/src/app/DesktopAssets.ts+36-7
...@@ -61,6 +61,35 @@ const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(func...@@ -61,6 +61,35 @@ const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(func
61 return Option.none<string>();61 return Option.none<string>();
62});62});
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
64const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* (93const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* (
65 ext: keyof DesktopIconPaths,94 ext: keyof DesktopIconPaths,
66): Effect.fn.Return<95): Effect.fn.Return<
...@@ -70,20 +99,20 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* (...@@ -70,20 +99,20 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* (
70> {99> {
71 const fileSystem = yield* FileSystem.FileSystem;100 const fileSystem = yield* FileSystem.FileSystem;
72 const environment = yield* DesktopEnvironment.DesktopEnvironment;101 const environment = yield* DesktopEnvironment.DesktopEnvironment;
73 if (environment.isDevelopment && environment.platform === "darwin" && ext === "png") {102 const sourceTreeIconPath = resolveSourceTreeIconPath(environment, ext);
74 const developmentDockIconPath = environment.developmentDockIconPath;103 if (sourceTreeIconPath !== undefined) {
75 const developmentDockIconExists = yield* fileSystem.exists(developmentDockIconPath).pipe(104 const sourceTreeIconExists = yield* fileSystem.exists(sourceTreeIconPath).pipe(
76 Effect.mapError(105 Effect.mapError(
77 (cause) =>106 (cause) =>
78 new DesktopAssetProbeError({107 new DesktopAssetProbeError({
79 fileName: "icon.png",108 fileName: `icon.${ext}`,
80 candidatePath: developmentDockIconPath,109 candidatePath: sourceTreeIconPath,
81 cause,110 cause,
82 }),111 }),
83 ),112 ),
84 );113 );
85 if (developmentDockIconExists) {114 if (sourceTreeIconExists) {
86 return Option.some(developmentDockIconPath);115 return Option.some(sourceTreeIconPath);
87 }116 }
88 }117 }
89118
apps/desktop/src/app/DesktopEnvironment.ts-2
...@@ -82,7 +82,6 @@ export class DesktopEnvironment extends Context.Service<...@@ -82,7 +82,6 @@ export class DesktopEnvironment extends Context.Service<
82 readonly runtimeInfo: DesktopRuntimeInfo;82 readonly runtimeInfo: DesktopRuntimeInfo;
83 readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option<string>;83 readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option<string>;
84 readonly resolveResourcePathCandidates: (fileName: string) => readonly string[];84 readonly resolveResourcePathCandidates: (fileName: string) => readonly string[];
85 readonly developmentDockIconPath: string;
86 }85 }
87>()("@t3tools/desktop/app/DesktopEnvironment") {}86>()("@t3tools/desktop/app/DesktopEnvironment") {}
8887
...@@ -270,7 +269,6 @@ const make = Effect.fn("desktop.environment.make")(function* (...@@ -270,7 +269,6 @@ const make = Effect.fn("desktop.environment.make")(function* (
270 path.join(resourcesPath, "resources", fileName),269 path.join(resourcesPath, "resources", fileName),
271 path.join(resourcesPath, fileName),270 path.join(resourcesPath, fileName),
272 ],271 ],
273 developmentDockIconPath: path.join(rootDir, "assets", "dev", "blueprint-macos-1024.png"),
274 });272 });
275});273});
276274
docs/internals/scripts.md+5
...@@ -78,6 +78,11 @@ authenticated....@@ -78,6 +78,11 @@ authenticated.
7878
79- Default build is unsigned/not notarized for local sharing.79- Default build is unsigned/not notarized for local sharing.
80- The DMG build uses `assets/prod/black-macos-1024.png` as the production app icon source.80- 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.
81- Desktop production windows load the bundled UI from the `t3code://app/` root URL (not a86- Desktop production windows load the bundled UI from the `t3code://app/` root URL (not a
82 `127.0.0.1` document URL, and not an explicit `index.html` path).87 `127.0.0.1` document URL, and not an explicit `index.html` path).
83- Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an88- 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";...@@ -13,6 +13,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
13import {13import {
14 BundleNotSelfContainedError,14 BundleNotSelfContainedError,
15 BuildCommandFailedError,15 BuildCommandFailedError,
16 DesktopDmgBackgroundSourceMissingError,
16 createStageWorkspaceConfig,17 createStageWorkspaceConfig,
17 createStagePatchedDependencies,18 createStagePatchedDependencies,
18 createBuildConfig,19 createBuildConfig,
...@@ -45,6 +46,7 @@ import {...@@ -45,6 +46,7 @@ import {
45 resolveMockUpdateServerUrl,46 resolveMockUpdateServerUrl,
46 resolvePackageManagerUserAgent,47 resolvePackageManagerUserAgent,
47 stageLinuxIconSize,48 stageLinuxIconSize,
49 stageDesktopDmgBackground,
48 STAGE_INSTALL_ARGS,50 STAGE_INSTALL_ARGS,
49 ancestorNodeModulesPaths,51 ancestorNodeModulesPaths,
50 copyDirectoryPreservingSymlinks,52 copyDirectoryPreservingSymlinks,
...@@ -463,6 +465,17 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -463,6 +465,17 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
463 "**/node_modules/.bin",465 "**/node_modules/.bin",
464 "**/node_modules/.bin/**",466 "**/node_modules/.bin/**",
465 ]);467 ]);
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 });
466 // Linux must register the renderer schemes so the generated .desktop479 // Linux must register the renderer schemes so the generated .desktop
467 // entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links.480 // entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links.
468 assert.deepStrictEqual((linux.linux as Record<string, unknown>).protocols, [481 assert.deepStrictEqual((linux.linux as Record<string, unknown>).protocols, [
...@@ -784,6 +797,77 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -784,6 +797,77 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
784 });797 });
785 });798 });
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
787 it("derives macOS passkey signing configuration from the Clerk publishable key", () => {871 it("derives macOS passkey signing configuration from the Clerk publishable key", () => {
788 const configuration = resolveMacPasskeySigningConfiguration({872 const configuration = resolveMacPasskeySigningConfiguration({
789 T3CODE_APPLE_TEAM_ID: "abc1234567",873 T3CODE_APPLE_TEAM_ID: "abc1234567",
...@@ -916,6 +1000,25 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -916,6 +1000,25 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
916 }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),1000 }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
917 );1001 );
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
919 it.effect("keeps executable resource editing enabled for unsigned Windows builds", () =>1022 it.effect("keeps executable resource editing enabled for unsigned Windows builds", () =>
920 Effect.gen(function* () {1023 Effect.gen(function* () {
921 const config = yield* createBuildConfig(1024 const config = yield* createBuildConfig(
scripts/build-desktop-artifact.ts+75
...@@ -318,6 +318,18 @@ export class DesktopIconSourceMissingError extends Schema.TaggedErrorClass<Deskt...@@ -318,6 +318,18 @@ export class DesktopIconSourceMissingError extends Schema.TaggedErrorClass<Deskt
318 }318 }
319}319}
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
321export class BundledClientAssetsMissingError extends Schema.TaggedErrorClass<BundledClientAssetsMissingError>()(333export class BundledClientAssetsMissingError extends Schema.TaggedErrorClass<BundledClientAssetsMissingError>()(
322 "BundledClientAssetsMissingError",334 "BundledClientAssetsMissingError",
323 {335 {
...@@ -1770,6 +1782,39 @@ function stageMacIcons(stageResourcesDir: string, sourcePng: string, verbose: bo...@@ -1770,6 +1782,39 @@ function stageMacIcons(stageResourcesDir: string, sourcePng: string, verbose: bo
1770 });1782 });
1771}1783}
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
1773function stageLinuxIcons(stageResourcesDir: string, sourcePng: string, verbose: boolean) {1818function stageLinuxIcons(stageResourcesDir: string, sourcePng: string, verbose: boolean) {
1774 return Effect.gen(function* () {1819 return Effect.gen(function* () {
1775 const fs = yield* FileSystem.FileSystem;1820 const fs = yield* FileSystem.FileSystem;
...@@ -2039,6 +2084,29 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (...@@ -2039,6 +2084,29 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
2039 };2084 };
2040 }2085 }
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
2042 if (platform === "linux") {2110 if (platform === "linux") {
2043 buildConfig.linux = {2111 buildConfig.linux = {
2044 target: [target],2112 target: [target],
...@@ -2738,6 +2806,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -2738,6 +2806,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
2738 yield* Effect.log("[desktop-artifact] Staging release app...");2806 yield* Effect.log("[desktop-artifact] Staging release app...");
2739 yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"));2807 yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"));
2740 yield* fs.copy(distDirs.desktopResources, stageResourcesDir);2808 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 }
2741 // On Windows the server tree ships in the server.asar sidecar instead of2816 // On Windows the server tree ships in the server.asar sidecar instead of
2742 // app.asar (see stageWindowsServerSidecar), so the app stage omits it.2817 // app.asar (see stageWindowsServerSidecar), so the app stage omits it.
2743 if (options.platform !== "win") {2818 if (options.platform !== "win") {