diff --git a/apps/desktop/resources/dmg/dmg-background-latest.svg b/apps/desktop/resources/dmg/dmg-background-latest.svg
new file mode 100644
index 000000000000..132d829b103e
--- /dev/null
+++ b/apps/desktop/resources/dmg/dmg-background-latest.svg
@@ -0,0 +1,33 @@
+
diff --git a/apps/desktop/resources/dmg/dmg-background-nightly.svg b/apps/desktop/resources/dmg/dmg-background-nightly.svg
new file mode 100644
index 000000000000..8df5e4c07c36
--- /dev/null
+++ b/apps/desktop/resources/dmg/dmg-background-nightly.svg
@@ -0,0 +1,64 @@
+
diff --git a/apps/desktop/resources/icon.icns b/apps/desktop/resources/icon.icns
deleted file mode 100644
index da16d12a0c7c..000000000000
Binary files a/apps/desktop/resources/icon.icns and /dev/null differ
diff --git a/apps/desktop/resources/icon.ico b/apps/desktop/resources/icon.ico
deleted file mode 100644
index 8298f70d8b36..000000000000
Binary files a/apps/desktop/resources/icon.ico and /dev/null differ
diff --git a/apps/desktop/resources/icon.png b/apps/desktop/resources/icon.png
deleted file mode 100644
index 37f3f756a553..000000000000
Binary files a/apps/desktop/resources/icon.png and /dev/null differ
diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs
index 929afeeabe9c..07fb87b051f1 100644
--- a/apps/desktop/scripts/electron-launcher.mjs
+++ b/apps/desktop/scripts/electron-launcher.mjs
@@ -20,14 +20,14 @@ export const APP_BUNDLE_ID = isDevelopment
? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}`
: "com.t3tools.t3code";
const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"];
-const LAUNCHER_VERSION = 14;
-const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns");
+const LAUNCHER_VERSION = 15;
const developmentMacIconPngPath = NodePath.join(
repoRoot,
"assets",
"dev",
"blueprint-macos-1024.png",
);
+const productionMacIconPngPath = NodePath.join(repoRoot, "assets", "prod", "black-macos-1024.png");
// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher script has no Effect runtime.
const hostPlatform = NodeOS.platform();
@@ -165,15 +165,22 @@ function registerMacLauncherBundle(appBundlePath) {
}
}
-function ensureDevelopmentIconIcns(runtimeDir) {
- const generatedIconPath = NodePath.join(runtimeDir, "icon-dev.icns");
+export function resolveMacLauncherIconPaths(runtimeDir, development = isDevelopment) {
+ return {
+ sourceIconPath: development ? developmentMacIconPngPath : productionMacIconPngPath,
+ generatedIconPath: NodePath.join(runtimeDir, development ? "icon-dev.icns" : "icon-prod.icns"),
+ };
+}
+
+function ensureMacIconIcns(runtimeDir) {
+ const { sourceIconPath, generatedIconPath } = resolveMacLauncherIconPaths(runtimeDir);
NodeFS.mkdirSync(runtimeDir, { recursive: true });
- if (!NodeFS.existsSync(developmentMacIconPngPath)) {
- return defaultIconPath;
+ if (!NodeFS.existsSync(sourceIconPath)) {
+ throw new Error(`Desktop macOS icon source is missing at ${sourceIconPath}`);
}
- const sourceMtimeMs = NodeFS.statSync(developmentMacIconPngPath).mtimeMs;
+ const sourceMtimeMs = NodeFS.statSync(sourceIconPath).mtimeMs;
if (
NodeFS.existsSync(generatedIconPath) &&
NodeFS.statSync(generatedIconPath).mtimeMs >= sourceMtimeMs
@@ -191,7 +198,7 @@ function ensureDevelopmentIconIcns(runtimeDir) {
"-z",
String(size),
String(size),
- developmentMacIconPngPath,
+ sourceIconPath,
"--out",
NodePath.join(iconsetDir, `icon_${size}x${size}.png`),
]);
@@ -201,7 +208,7 @@ function ensureDevelopmentIconIcns(runtimeDir) {
"-z",
String(retinaSize),
String(retinaSize),
- developmentMacIconPngPath,
+ sourceIconPath,
"--out",
NodePath.join(iconsetDir, `icon_${size}x${size}@2x.png`),
]);
@@ -209,12 +216,6 @@ function ensureDevelopmentIconIcns(runtimeDir) {
runChecked("iconutil", ["-c", "icns", iconsetDir, "-o", generatedIconPath]);
return generatedIconPath;
- } catch (error) {
- console.warn(
- "[desktop-launcher] Failed to generate dev macOS icon, falling back to default icon.",
- error,
- );
- return defaultIconPath;
} finally {
NodeFS.rmSync(iconsetRoot, { recursive: true, force: true });
}
@@ -297,7 +298,7 @@ function buildMacLauncher(electronBinaryPath) {
const launcherBinaryPath = isDevelopment
? developmentPaths.launcherBinaryPath
: runtimeElectronBinaryPath;
- const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath;
+ const iconPath = ensureMacIconIcns(runtimeDir);
const metadataPath = NodePath.join(runtimeDir, "metadata.json");
NodeFS.mkdirSync(runtimeDir, { recursive: true });
diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs
index 1c82167ea217..1ed5a1b8ebf9 100644
--- a/apps/desktop/scripts/electron-launcher.test.mjs
+++ b/apps/desktop/scripts/electron-launcher.test.mjs
@@ -3,6 +3,7 @@ import { assert, describe, it } from "vite-plus/test";
import {
makeDevelopmentLauncherScript,
resolveElectronBinaryPath,
+ resolveMacLauncherIconPaths,
resolveMacLauncherPaths,
} from "./electron-launcher.mjs";
@@ -78,4 +79,14 @@ describe("electron development launcher", () => {
);
assert.notInclude(script, "node_modules/electron");
});
+
+ it("derives launcher icons from canonical development and production assets", () => {
+ const development = resolveMacLauncherIconPaths("/runtime", true);
+ const production = resolveMacLauncherIconPaths("/runtime", false);
+
+ assert.match(development.sourceIconPath, /assets\/dev\/blueprint-macos-1024\.png$/);
+ assert.equal(development.generatedIconPath, "/runtime/icon-dev.icns");
+ assert.match(production.sourceIconPath, /assets\/prod\/black-macos-1024\.png$/);
+ assert.equal(production.generatedIconPath, "/runtime/icon-prod.icns");
+ });
});
diff --git a/apps/desktop/src/app/DesktopAssets.test.ts b/apps/desktop/src/app/DesktopAssets.test.ts
index 2eb55c72057f..bb118d43d29a 100644
--- a/apps/desktop/src/app/DesktopAssets.test.ts
+++ b/apps/desktop/src/app/DesktopAssets.test.ts
@@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
import * as PlatformError from "effect/PlatformError";
import * as DesktopAssets from "./DesktopAssets.ts";
@@ -22,6 +23,45 @@ const environmentLayer = DesktopEnvironment.layer({
}).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))));
describe("DesktopAssets", () => {
+ it.effect("uses canonical source-tree icons for unpackaged development", () =>
+ Effect.gen(function* () {
+ const developmentEnvironmentLayer = DesktopEnvironment.layer({
+ dirname: "/repo/apps/desktop/dist-electron",
+ homeDirectory: "/Users/alice",
+ platform: "linux",
+ processArch: "x64",
+ appVersion: "1.2.3",
+ appPath: "/repo",
+ isPackaged: false,
+ resourcesPath: "/repo/apps/desktop/resources",
+ runningUnderArm64Translation: false,
+ }).pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ NodeServices.layer,
+ DesktopConfig.layerTest({ VITE_DEV_SERVER_URL: "http://localhost:5733" }),
+ ),
+ ),
+ );
+ const fileSystemLayer = FileSystem.layerNoop({
+ exists: (path) => Effect.succeed(String(path).includes("/assets/dev/")),
+ });
+ const assets = yield* DesktopAssets.DesktopAssets.pipe(
+ Effect.provide(
+ DesktopAssets.layer.pipe(
+ Layer.provide(Layer.merge(fileSystemLayer, developmentEnvironmentLayer)),
+ ),
+ ),
+ );
+
+ const icons = yield* assets.iconPaths;
+
+ assert.match(Option.getOrThrow(icons.ico), /assets\/dev\/blueprint-windows\.ico$/);
+ assert.match(Option.getOrThrow(icons.png), /assets\/dev\/blueprint-universal-1024\.png$/);
+ assert.isTrue(Option.isNone(icons.icns));
+ }),
+ );
+
it.effect("preserves the failed asset candidate and filesystem cause", () =>
Effect.gen(function* () {
const fileName = "custom.bin";
diff --git a/apps/desktop/src/app/DesktopAssets.ts b/apps/desktop/src/app/DesktopAssets.ts
index 95585acab74e..f1c6f1bb8f1f 100644
--- a/apps/desktop/src/app/DesktopAssets.ts
+++ b/apps/desktop/src/app/DesktopAssets.ts
@@ -61,6 +61,35 @@ const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(func
return Option.none();
});
+const sourceTreeIconFileNames = {
+ dev: {
+ ico: "blueprint-windows.ico",
+ macPng: "blueprint-macos-1024.png",
+ universalPng: "blueprint-universal-1024.png",
+ },
+ prod: {
+ ico: "t3-black-windows.ico",
+ macPng: "black-macos-1024.png",
+ universalPng: "black-universal-1024.png",
+ },
+} as const;
+
+function resolveSourceTreeIconPath(
+ environment: DesktopEnvironment.DesktopEnvironment["Service"],
+ ext: keyof DesktopIconPaths,
+): string | undefined {
+ if (environment.isPackaged || ext === "icns") return undefined;
+ const brand = environment.isDevelopment ? "dev" : "prod";
+ const fileNames = sourceTreeIconFileNames[brand];
+ const fileName =
+ ext === "ico"
+ ? fileNames.ico
+ : environment.platform === "darwin"
+ ? fileNames.macPng
+ : fileNames.universalPng;
+ return environment.path.join(environment.rootDir, "assets", brand, fileName);
+}
+
const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* (
ext: keyof DesktopIconPaths,
): Effect.fn.Return<
@@ -70,20 +99,20 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* (
> {
const fileSystem = yield* FileSystem.FileSystem;
const environment = yield* DesktopEnvironment.DesktopEnvironment;
- if (environment.isDevelopment && environment.platform === "darwin" && ext === "png") {
- const developmentDockIconPath = environment.developmentDockIconPath;
- const developmentDockIconExists = yield* fileSystem.exists(developmentDockIconPath).pipe(
+ const sourceTreeIconPath = resolveSourceTreeIconPath(environment, ext);
+ if (sourceTreeIconPath !== undefined) {
+ const sourceTreeIconExists = yield* fileSystem.exists(sourceTreeIconPath).pipe(
Effect.mapError(
(cause) =>
new DesktopAssetProbeError({
- fileName: "icon.png",
- candidatePath: developmentDockIconPath,
+ fileName: `icon.${ext}`,
+ candidatePath: sourceTreeIconPath,
cause,
}),
),
);
- if (developmentDockIconExists) {
- return Option.some(developmentDockIconPath);
+ if (sourceTreeIconExists) {
+ return Option.some(sourceTreeIconPath);
}
}
diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts
index eaf390187124..4583e5124091 100644
--- a/apps/desktop/src/app/DesktopEnvironment.ts
+++ b/apps/desktop/src/app/DesktopEnvironment.ts
@@ -82,7 +82,6 @@ export class DesktopEnvironment extends Context.Service<
readonly runtimeInfo: DesktopRuntimeInfo;
readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option;
readonly resolveResourcePathCandidates: (fileName: string) => readonly string[];
- readonly developmentDockIconPath: string;
}
>()("@t3tools/desktop/app/DesktopEnvironment") {}
@@ -270,7 +269,6 @@ const make = Effect.fn("desktop.environment.make")(function* (
path.join(resourcesPath, "resources", fileName),
path.join(resourcesPath, fileName),
],
- developmentDockIconPath: path.join(rootDir, "assets", "dev", "blueprint-macos-1024.png"),
});
});
diff --git a/docs/internals/scripts.md b/docs/internals/scripts.md
index 9440115e1a9b..b6cb014932e1 100644
--- a/docs/internals/scripts.md
+++ b/docs/internals/scripts.md
@@ -78,6 +78,11 @@ authenticated.
- Default build is unsigned/not notarized for local sharing.
- The DMG build uses `assets/prod/black-macos-1024.png` as the production app icon source.
+- The DMG chrome follows the release channel: neutral for Latest and the Nightly sky artwork for
+ Nightly. Blueprint artwork remains exclusive to Dev builds. Packaging rasterizes the selected
+ SVG into standard and Retina PNGs inside the disposable staging directory.
+- The Finder window is 540×412 while its background is 540×380; the extra 32px accounts for the
+ title bar included in Finder's window bounds.
- Desktop production windows load the bundled UI from the `t3code://app/` root URL (not a
`127.0.0.1` document URL, and not an explicit `index.html` path).
- Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an
diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts
index 2b9fd3e02585..b7383de236aa 100644
--- a/scripts/build-desktop-artifact.test.ts
+++ b/scripts/build-desktop-artifact.test.ts
@@ -13,6 +13,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
import {
BundleNotSelfContainedError,
BuildCommandFailedError,
+ DesktopDmgBackgroundSourceMissingError,
createStageWorkspaceConfig,
createStagePatchedDependencies,
createBuildConfig,
@@ -45,6 +46,7 @@ import {
resolveMockUpdateServerUrl,
resolvePackageManagerUserAgent,
stageLinuxIconSize,
+ stageDesktopDmgBackground,
STAGE_INSTALL_ARGS,
ancestorNodeModulesPaths,
copyDirectoryPreservingSymlinks,
@@ -463,6 +465,17 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
"**/node_modules/.bin",
"**/node_modules/.bin/**",
]);
+ assert.deepStrictEqual(mac.dmg, {
+ title: "T3 Code (Alpha) 1.2.3 Installer",
+ background: "dmg/dmg-background-latest.png",
+ window: { width: 540, height: 412 },
+ contents: [
+ { x: 130, y: 220, type: "file" },
+ { x: 410, y: 220, type: "link", path: "/Applications" },
+ ],
+ iconSize: 80,
+ iconTextSize: 12,
+ });
// Linux must register the renderer schemes so the generated .desktop
// entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links.
assert.deepStrictEqual((linux.linux as Record).protocols, [
@@ -784,6 +797,77 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
});
});
+ it.effect("rasterizes staged DMG backgrounds at standard and Retina sizes", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const stageResourcesDir = yield* fs.makeTempDirectoryScoped({
+ prefix: "t3code-dmg-background-",
+ });
+ const dmgDir = path.join(stageResourcesDir, "dmg");
+ yield* fs.makeDirectory(dmgDir, { recursive: true });
+ const sourcePath = path.join(dmgDir, "dmg-background-nightly.svg");
+ yield* fs.writeFileString(sourcePath, '');
+ const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> =
+ [];
+
+ yield* stageDesktopDmgBackground(stageResourcesDir, "nightly", false).pipe(
+ Effect.provide(iconResizeSpawnerLayer(commands, [0, 0])),
+ );
+
+ assert.deepStrictEqual(
+ commands.map((command) => [command.command, ...command.args]),
+ [
+ [
+ "sips",
+ "-s",
+ "format",
+ "png",
+ "-z",
+ "380",
+ "540",
+ sourcePath,
+ "--out",
+ path.join(dmgDir, "dmg-background-nightly.png"),
+ ],
+ [
+ "sips",
+ "-s",
+ "format",
+ "png",
+ "-z",
+ "760",
+ "1080",
+ sourcePath,
+ "--out",
+ path.join(dmgDir, "dmg-background-nightly@2x.png"),
+ ],
+ ],
+ );
+ }),
+ ),
+ );
+
+ it.effect("fails clearly when the selected DMG background source is missing", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const stageResourcesDir = yield* fs.makeTempDirectoryScoped({
+ prefix: "t3code-dmg-background-missing-",
+ });
+
+ const error = yield* stageDesktopDmgBackground(stageResourcesDir, "latest", false).pipe(
+ Effect.flip,
+ );
+
+ assert.instanceOf(error, DesktopDmgBackgroundSourceMissingError);
+ assert.equal(error.channel, "latest");
+ assert.include(error.sourcePath, "dmg-background-latest.svg");
+ }),
+ ),
+ );
+
it("derives macOS passkey signing configuration from the Clerk publishable key", () => {
const configuration = resolveMacPasskeySigningConfiguration({
T3CODE_APPLE_TEAM_ID: "abc1234567",
@@ -916,6 +1000,25 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
}).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
);
+ it.effect("uses the nightly DMG background for nightly macOS builds", () =>
+ Effect.gen(function* () {
+ const config = yield* createBuildConfig(
+ "mac",
+ "dmg",
+ "1.2.3-nightly.20260815.1",
+ false,
+ false,
+ undefined,
+ undefined,
+ );
+
+ assert.equal(
+ (config.dmg as Record).background,
+ "dmg/dmg-background-nightly.png",
+ );
+ }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
+ );
+
it.effect("keeps executable resource editing enabled for unsigned Windows builds", () =>
Effect.gen(function* () {
const config = yield* createBuildConfig(
diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts
index 0cda9766f372..bf36029bc75b 100644
--- a/scripts/build-desktop-artifact.ts
+++ b/scripts/build-desktop-artifact.ts
@@ -318,6 +318,18 @@ export class DesktopIconSourceMissingError extends Schema.TaggedErrorClass()(
+ "DesktopDmgBackgroundSourceMissingError",
+ {
+ channel: Schema.Literals(["latest", "nightly"]),
+ sourcePath: Schema.String,
+ },
+) {
+ override get message(): string {
+ return `Desktop ${this.channel} DMG background source is missing at ${this.sourcePath}`;
+ }
+}
+
export class BundledClientAssetsMissingError extends Schema.TaggedErrorClass()(
"BundledClientAssetsMissingError",
{
@@ -1770,6 +1782,39 @@ function stageMacIcons(stageResourcesDir: string, sourcePng: string, verbose: bo
});
}
+export const stageDesktopDmgBackground = Effect.fn("stageDesktopDmgBackground")(function* (
+ stageResourcesDir: string,
+ channel: "latest" | "nightly",
+ verbose: boolean,
+) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const sourcePath = path.join(stageResourcesDir, "dmg", `dmg-background-${channel}.svg`);
+ if (!(yield* fs.exists(sourcePath))) {
+ return yield* new DesktopDmgBackgroundSourceMissingError({ channel, sourcePath });
+ }
+
+ for (const output of [
+ { suffix: "", width: 540, height: 380 },
+ { suffix: "@2x", width: 1080, height: 760 },
+ ] as const) {
+ const targetPath = path.join(
+ stageResourcesDir,
+ "dmg",
+ `dmg-background-${channel}${output.suffix}.png`,
+ );
+ yield* runCommand(
+ ChildProcess.make(
+ {},
+ )`sips -s format png -z ${output.height} ${output.width} ${sourcePath} --out ${targetPath}`,
+ {
+ label: `sips ${channel} DMG background${output.suffix || "@1x"}`,
+ verbose,
+ },
+ );
+ }
+});
+
function stageLinuxIcons(stageResourcesDir: string, sourcePng: string, verbose: boolean) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
@@ -2039,6 +2084,29 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
};
}
+ if (platform === "mac" && target === "dmg") {
+ buildConfig.dmg = {
+ // Give the themed installer its own Finder volume name. Finder caches
+ // DMG window backgrounds by volume name, so reusing a generic name can
+ // make a newly built background look unchanged during testing.
+ title: `${resolveDesktopProductName(version)} ${version} Installer`,
+ background: `dmg/dmg-background-${updateChannel}.png`,
+ window: {
+ width: 540,
+ // Finder counts its 32px title bar in the window bounds. The themed
+ // background itself is 380px tall, so add the chrome height here to
+ // keep the full canvas visible.
+ height: 412,
+ },
+ contents: [
+ { x: 130, y: 220, type: "file" },
+ { x: 410, y: 220, type: "link", path: "/Applications" },
+ ],
+ iconSize: 80,
+ iconTextSize: 12,
+ };
+ }
+
if (platform === "linux") {
buildConfig.linux = {
target: [target],
@@ -2738,6 +2806,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
yield* Effect.log("[desktop-artifact] Staging release app...");
yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"));
yield* fs.copy(distDirs.desktopResources, stageResourcesDir);
+ if (options.platform === "mac" && options.target === "dmg") {
+ yield* stageDesktopDmgBackground(
+ stageResourcesDir,
+ resolveDesktopUpdateChannel(appVersion),
+ options.verbose,
+ );
+ }
// On Windows the server tree ships in the server.asar sidecar instead of
// app.asar (see stageWindowsServerSidecar), so the app stage omits it.
if (options.platform !== "win") {