diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 081ef903a066..929afeeabe9c 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -20,7 +20,7 @@ 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 = 12; +const LAUNCHER_VERSION = 14; const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns"); const developmentMacIconPngPath = NodePath.join( repoRoot, @@ -220,11 +220,12 @@ function ensureDevelopmentIconIcns(runtimeDir) { } } -function patchMainBundleInfoPlist(appBundlePath, iconPath) { +function patchMainBundleInfoPlist(appBundlePath, iconPath, executableName) { const infoPlistPath = NodePath.join(appBundlePath, "Contents", "Info.plist"); setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); + setPlistString(infoPlistPath, "CFBundleExecutable", executableName); setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns"); setPlistJson(infoPlistPath, "CFBundleURLTypes", [ { @@ -277,11 +278,25 @@ function readJson(path) { } } +export function resolveMacLauncherPaths(appBundlePath, displayName = APP_DISPLAY_NAME) { + const executableDir = NodePath.join(appBundlePath, "Contents", "MacOS"); + const launcherExecutableName = `${displayName} Launcher`; + return { + launcherExecutableName, + launcherBinaryPath: NodePath.join(executableDir, launcherExecutableName), + runtimeElectronBinaryPath: NodePath.join(executableDir, "Electron"), + }; +} + function buildMacLauncher(electronBinaryPath) { const sourceAppBundlePath = NodePath.resolve(NodePath.dirname(electronBinaryPath), "../.."); const runtimeDir = NodePath.join(desktopDir, ".electron-runtime"); const targetAppBundlePath = NodePath.join(runtimeDir, `${APP_DISPLAY_NAME}.app`); - const targetBinaryPath = NodePath.join(targetAppBundlePath, "Contents", "MacOS", "Electron"); + const developmentPaths = resolveMacLauncherPaths(targetAppBundlePath); + const runtimeElectronBinaryPath = developmentPaths.runtimeElectronBinaryPath; + const launcherBinaryPath = isDevelopment + ? developmentPaths.launcherBinaryPath + : runtimeElectronBinaryPath; const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath; const metadataPath = NodePath.join(runtimeDir, "metadata.json"); @@ -298,7 +313,8 @@ function buildMacLauncher(electronBinaryPath) { const currentMetadata = readJson(metadataPath); if ( - NodeFS.existsSync(targetBinaryPath) && + NodeFS.existsSync(launcherBinaryPath) && + (!isDevelopment || NodeFS.existsSync(runtimeElectronBinaryPath)) && currentMetadata && JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) ) { @@ -306,10 +322,10 @@ function buildMacLauncher(electronBinaryPath) { // The launcher also handles protocol activations outside the dev runner, // so refresh its fallback environment on every launch. Never let a value // captured by an older parent app override the live dev-runner environment. - writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath); + writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath); } registerMacLauncherBundle(targetAppBundlePath); - return targetBinaryPath; + return launcherBinaryPath; } NodeFS.rmSync(targetAppBundlePath, { recursive: true, force: true }); @@ -321,15 +337,24 @@ function buildMacLauncher(electronBinaryPath) { recursive: true, verbatimSymlinks: true, }); - patchMainBundleInfoPlist(targetAppBundlePath, iconPath); + patchMainBundleInfoPlist( + targetAppBundlePath, + iconPath, + isDevelopment ? developmentPaths.launcherExecutableName : "Electron", + ); patchHelperBundleInfoPlists(targetAppBundlePath); if (isDevelopment) { - writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath); + // Keep Electron's native executable inside the branded bundle. Launching the + // node_modules copy makes macOS associate the process (and Dock label) with + // Electron.app even though this bundle's Info.plist has the T3 Code name. + // Its conventional executable name also keeps Electron's default-app runtime + // in development mode instead of making app.isPackaged report true. + writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath); } NodeFS.writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); registerMacLauncherBundle(targetAppBundlePath); - return targetBinaryPath; + return launcherBinaryPath; } function isLinuxSetuidSandboxConfigured(electronBinaryPath) { diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 36e9429e4446..1c82167ea217 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -1,6 +1,10 @@ import { assert, describe, it } from "vite-plus/test"; -import { makeDevelopmentLauncherScript, resolveElectronBinaryPath } from "./electron-launcher.mjs"; +import { + makeDevelopmentLauncherScript, + resolveElectronBinaryPath, + resolveMacLauncherPaths, +} from "./electron-launcher.mjs"; describe("electron development launcher", () => { it("uses captured values only as fallbacks for a live runner environment", () => { @@ -45,4 +49,33 @@ describe("electron development launcher", () => { ); assert.deepEqual(calls, ["ensure", "require:electron"]); }); + + it("keeps the native Electron executable name inside the branded macOS bundle", () => { + const paths = resolveMacLauncherPaths( + "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app", + "T3 Code (Dev)", + ); + + assert.equal(paths.launcherExecutableName, "T3 Code (Dev) Launcher"); + assert.equal( + paths.launcherBinaryPath, + "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/T3 Code (Dev) Launcher", + ); + assert.equal( + paths.runtimeElectronBinaryPath, + "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/Electron", + ); + + const script = makeDevelopmentLauncherScript({ + electronBinaryPath: paths.runtimeElectronBinaryPath, + mainEntryPath: "/repo/apps/desktop/dist-electron/main.cjs", + desktopRoot: "/repo/apps/desktop", + environment: {}, + }); + assert.include( + script, + "exec '/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/Electron'", + ); + assert.notInclude(script, "node_modules/electron"); + }); }); diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index 8fcc34f41c24..c2acc9ce120a 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -10,6 +10,7 @@ const { autoUpdaterMock } = vi.hoisted(() => ({ autoInstallOnAppQuit: true, channel: "latest", disableDifferentialDownload: false, + fullChangelog: false, checkForUpdates: vi.fn(() => Promise.resolve(null)), downloadUpdate: vi.fn(() => Promise.resolve([])), on: vi.fn(), @@ -33,6 +34,7 @@ describe("ElectronUpdater", () => { autoUpdaterMock.autoInstallOnAppQuit = true; autoUpdaterMock.channel = "latest"; autoUpdaterMock.disableDifferentialDownload = false; + autoUpdaterMock.fullChangelog = false; autoUpdaterMock.checkForUpdates.mockClear(); autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null)); autoUpdaterMock.downloadUpdate.mockClear(); @@ -98,6 +100,18 @@ describe("ElectronUpdater", () => { }).pipe(Effect.provide(ElectronUpdater.layer)), ); + it.effect("sets full changelog mode", () => + Effect.gen(function* () { + const updater = yield* ElectronUpdater.ElectronUpdater; + + yield* updater.setFullChangelog(true); + assert.equal(autoUpdaterMock.fullChangelog, true); + + yield* updater.setFullChangelog(false); + assert.equal(autoUpdaterMock.fullChangelog, false); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + it.effect("preserves quit-and-install flags and the execution-time channel", () => Effect.gen(function* () { const cause = new Error("quit and install failed"); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 435fbd002289..4157d29a9df8 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -66,6 +66,7 @@ export class ElectronUpdater extends Context.Service< readonly setAllowPrerelease: (value: boolean) => Effect.Effect; readonly allowDowngrade: Effect.Effect; readonly setAllowDowngrade: (value: boolean) => Effect.Effect; + readonly setFullChangelog: (value: boolean) => Effect.Effect; readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; readonly checkForUpdates: Effect.Effect; readonly downloadUpdate: Effect.Effect; @@ -112,6 +113,11 @@ export const make = ElectronUpdater.of({ autoUpdater.allowDowngrade = value; return Effect.void; }), + setFullChangelog: (value) => + Effect.suspend(() => { + autoUpdater.fullChangelog = value; + return Effect.void; + }), setDisableDifferentialDownload: (value) => Effect.suspend(() => { autoUpdater.disableDifferentialDownload = value; diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 696bd755506b..5ae92bbee963 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -38,6 +38,7 @@ const flushCallbacks = Effect.yieldNow; function makeHarness(options: UpdatesHarnessOptions = {}) { let checkCount = 0; let allowDowngrade = false; + let fullChangelog = false; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; const listeners = new Map void>>(); const sentStates: DesktopUpdateState[] = []; @@ -73,6 +74,10 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { Effect.sync(() => { allowDowngrade = value; }), + setFullChangelog: (value) => + Effect.sync(() => { + fullChangelog = value; + }), setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, checkForUpdates: Effect.sync(() => { checkCount += 1; @@ -186,6 +191,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { layer, checkCount: () => checkCount, feedUrls: () => feedUrls, + fullChangelog: () => fullChangelog, listenerCount: () => Array.from(listeners.values()).reduce( (total, eventListeners) => total + eventListeners.size, @@ -287,6 +293,49 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("enables nightly full changelog release notes and broadcasts summaries", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + yield* updates.setChannel("nightly"); + assert.equal(harness.fullChangelog(), true); + + harness.emit("update-available", { + version: "1.2.4-nightly.20260709.766", + releaseNotes: [ + { + version: "1.2.4-nightly.20260709.766", + note: `

What's Changed

Full Changelog

`, + }, + { + version: "1.2.4-nightly.20260709.765", + note: "- [codex] Upgrade Clerk stack by @juliusmarminge in #3821", + }, + ], + }); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.status, "available"); + assert.deepEqual(state.releaseNotes, [ + { + version: "1.2.4-nightly.20260709.766", + items: ["feat(client): persist offline environment data by @juliusmarminge in #3795"], + }, + { + version: "1.2.4-nightly.20260709.765", + items: ["[codex] Upgrade Clerk stack by @juliusmarminge in #3821"], + }, + ]); + assert.deepEqual(harness.sentStates.at(-1)?.releaseNotes, state.releaseNotes); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("keeps raw updater event failures out of update state", () => { const harness = makeHarness(); const cause = new Error( diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index aabb0830b0f8..7357907e1783 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -27,6 +27,7 @@ import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; import { createInitialDesktopUpdateState, @@ -49,6 +50,10 @@ type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; const UpdateInfo = Schema.Struct({ version: Schema.String, + // Left unvalidated on purpose: a malformed release-notes payload must never + // fail the decode and block the update state transition. The shape is + // validated defensively in normalizeDesktopUpdateReleaseNotes. + releaseNotes: Schema.optional(Schema.Unknown), }); const DownloadProgressInfo = Schema.Struct({ @@ -330,10 +335,12 @@ export const make = Effect.gen(function* () { yield* electronUpdater.setChannel(channel); yield* electronUpdater.setAllowPrerelease(allowsPrerelease); yield* electronUpdater.setAllowDowngrade(allowsPrerelease); + yield* electronUpdater.setFullChangelog(allowsPrerelease); yield* logUpdaterInfo("using update channel", { channel, allowPrerelease: allowsPrerelease, allowDowngrade: allowsPrerelease, + fullChangelog: allowsPrerelease, }); }); @@ -567,11 +574,15 @@ export const make = Effect.gen(function* () { } const checkedAt = yield* currentIsoTimestamp; + const releaseNotes = normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version); yield* setState( - reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt), + reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt, releaseNotes), ); yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); - yield* logUpdaterInfo("update available", { version: info.version }); + yield* logUpdaterInfo("update available", { + version: info.version, + releaseNoteGroups: releaseNotes.length, + }); }), ), Effect.catchCause((cause) => { diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts new file mode 100644 index 000000000000..9d6bbaea6bcb --- /dev/null +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; + +describe("normalizeDesktopUpdateReleaseNotes", () => { + it("splits a plain string note into items under the fallback version", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + "## What's changed\n- First fix\n- Second fix", + "1.2.3", + ); + expect(notes).toEqual([{ version: "1.2.3", items: ["First fix", "Second fix"] }]); + }); + + it("keeps per-version groups and drops empty ones", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + [ + { version: "1.2.3", note: "- Newer change" }, + { version: "1.2.2", note: "Full changelog: https://example.com/compare/x...y" }, + { version: "1.2.1", note: "- Older change" }, + ], + "1.2.3", + ); + expect(notes).toEqual([ + { version: "1.2.3", items: ["Newer change"] }, + { version: "1.2.1", items: ["Older change"] }, + ]); + }); + + it("decodes valid HTML entities", () => { + const notes = normalizeDesktopUpdateReleaseNotes("- Fix & polish 😀", "1.0.0"); + expect(notes).toEqual([{ version: "1.0.0", items: ["Fix & polish 😀"] }]); + }); + + it("ignores malformed entries instead of throwing", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + [ + { version: "1.2.3", note: "- Valid change" }, + { version: 42, note: "- Bad version type" }, + { version: "1.2.1", note: { html: "

object note

" } }, + "not an object", + null, + ], + "1.2.3", + ); + expect(notes).toEqual([{ version: "1.2.3", items: ["Valid change"] }]); + }); + + it("returns non-empty groups even when preceded by many boilerplate-only groups", () => { + const boilerplate = Array.from({ length: 7 }, (_, index) => ({ + version: `1.3.${9 - index}`, + note: "Full changelog: https://example.com/compare/x...y", + })); + const notes = normalizeDesktopUpdateReleaseNotes( + [...boilerplate, { version: "1.3.2", note: "- Older but real change" }], + "1.3.9", + ); + expect(notes).toEqual([{ version: "1.3.2", items: ["Older but real change"] }]); + }); + + it("does not throw on out-of-range numeric entities and keeps the literal", () => { + expect(() => + normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"), + ).not.toThrow(); + const notes = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); + expect(notes).toEqual([{ version: "1.0.0", items: ["Broken entity �"] }]); + }); +}); diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts new file mode 100644 index 000000000000..69857c92b3f1 --- /dev/null +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -0,0 +1,125 @@ +import type { DesktopUpdateReleaseNote } from "@t3tools/contracts"; + +interface ElectronReleaseNoteInfo { + readonly version: string; + readonly note: string | null | undefined; +} + +function isElectronReleaseNoteInfo(value: unknown): value is ElectronReleaseNoteInfo { + if (typeof value !== "object" || value === null) return false; + const candidate = value as { readonly version?: unknown; readonly note?: unknown }; + return ( + typeof candidate.version === "string" && + (typeof candidate.note === "string" || candidate.note === null || candidate.note === undefined) + ); +} + +const MAX_RELEASE_NOTE_GROUPS = 6; +const MAX_RELEASE_NOTE_ITEMS_PER_GROUP = 8; +const MAX_RELEASE_NOTE_ITEM_LENGTH = 220; + +const HTML_ENTITY_REPLACEMENTS: Readonly> = { + amp: "&", + apos: "'", + gt: ">", + lt: "<", + nbsp: " ", + quot: '"', +}; + +function decodeCodePoint(codePoint: number, entity: string): string { + // String.fromCodePoint throws RangeError outside the valid Unicode range, and + // Number.isFinite alone lets oversized values (e.g. �) through. + if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { + return `&${entity};`; + } + return String.fromCodePoint(codePoint); +} + +function decodeHtmlEntity(entity: string): string { + const named = HTML_ENTITY_REPLACEMENTS[entity]; + if (named) return named; + if (entity.startsWith("#x")) { + return decodeCodePoint(Number.parseInt(entity.slice(2), 16), entity); + } + if (entity.startsWith("#")) { + return decodeCodePoint(Number.parseInt(entity.slice(1), 10), entity); + } + return `&${entity};`; +} + +function decodeHtmlEntities(input: string): string { + return input.replace(/&([a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);/g, (_, entity: string) => + decodeHtmlEntity(entity), + ); +} + +function stripMarkup(input: string): string { + return decodeHtmlEntities( + input + .replace(//gi, "\n") + .replace(/]*>/gi, "\n- ") + .replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n") + .replace(/<[^>]*>/g, "") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1"), + ); +} + +function truncateReleaseNoteItem(item: string): string { + if (item.length <= MAX_RELEASE_NOTE_ITEM_LENGTH) return item; + return `${item.slice(0, MAX_RELEASE_NOTE_ITEM_LENGTH - 3).trimEnd()}...`; +} + +function isIgnoredReleaseNoteLine(line: string): boolean { + const normalized = line + .toLowerCase() + .replace(/[*_`#]/g, "") + .trim(); + return ( + normalized === "" || + normalized === "what's changed" || + normalized === "whats changed" || + normalized === "full changelog" || + normalized === "new contributors" || + normalized.startsWith("compare: ") || + normalized.includes("/compare/") + ); +} + +function extractReleaseNoteItems(note: string | null | undefined): ReadonlyArray { + if (!note) return []; + + const items: string[] = []; + for (const rawLine of stripMarkup(note).split("\n")) { + const item = rawLine + .trim() + .replace(/^[-*]\s+/, "") + .replace(/^\d+[.)]\s+/, "") + .replace(/\s+/g, " "); + if (isIgnoredReleaseNoteLine(item)) continue; + items.push(truncateReleaseNoteItem(item)); + if (items.length >= MAX_RELEASE_NOTE_ITEMS_PER_GROUP) break; + } + return items; +} + +export function normalizeDesktopUpdateReleaseNotes( + releaseNotes: unknown, + fallbackVersion: string, +): ReadonlyArray { + const rawNotes = + typeof releaseNotes === "string" + ? [{ version: fallbackVersion, note: releaseNotes }] + : Array.isArray(releaseNotes) + ? releaseNotes.filter(isElectronReleaseNoteInfo) + : []; + + return rawNotes + .map((entry) => ({ + version: entry.version, + items: extractReleaseNoteItems(entry.note), + })) + .filter((entry) => entry.items.length > 0) + .slice(0, MAX_RELEASE_NOTE_GROUPS); +} diff --git a/apps/desktop/src/updates/updateMachine.test.ts b/apps/desktop/src/updates/updateMachine.test.ts index 4b2a87c7ce0c..040411f76f4f 100644 --- a/apps/desktop/src/updates/updateMachine.test.ts +++ b/apps/desktop/src/updates/updateMachine.test.ts @@ -118,6 +118,12 @@ describe("updateMachine", () => { }); it("tracks available, download start, and progress cleanly", () => { + const releaseNotes = [ + { + version: "1.1.0", + items: ["feat: add update release notes"], + }, + ]; const available = reduceDesktopUpdateStateOnUpdateAvailable( { ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), @@ -126,15 +132,33 @@ describe("updateMachine", () => { }, "1.1.0", "2026-03-04T00:00:00.000Z", + releaseNotes, ); const downloading = reduceDesktopUpdateStateOnDownloadStart(available); const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); expect(available.status).toBe("available"); expect(available.channel).toBe("latest"); + expect(available.releaseNotes).toBe(releaseNotes); + expect(downloading.releaseNotes).toBe(releaseNotes); expect(downloading.status).toBe("downloading"); expect(downloading.downloadPercent).toBe(0); expect(progress.downloadPercent).toBe(55.5); expect(progress.errorContext).toBeNull(); }); + + it("clears release notes when checking again", () => { + const state = reduceDesktopUpdateStateOnCheckStart( + { + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "nightly"), + enabled: true, + status: "available", + availableVersion: "1.1.0-nightly.1", + releaseNotes: [{ version: "1.1.0-nightly.1", items: ["feat: old note"] }], + }, + "2026-03-04T00:00:00.000Z", + ); + + expect(state.releaseNotes).toEqual([]); + }); }); diff --git a/apps/desktop/src/updates/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts index b5037225774b..fef51bbb8ab2 100644 --- a/apps/desktop/src/updates/updateMachine.ts +++ b/apps/desktop/src/updates/updateMachine.ts @@ -1,6 +1,7 @@ import type { DesktopRuntimeInfo, DesktopUpdateChannel, + DesktopUpdateReleaseNote, DesktopUpdateState, } from "@t3tools/contracts"; @@ -29,6 +30,7 @@ export function createInitialDesktopUpdateState( runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation, availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt: null, message: null, @@ -45,6 +47,7 @@ export function reduceDesktopUpdateStateOnCheckStart( ...state, status: "checking", checkedAt, + releaseNotes: [], message: null, downloadPercent: null, errorContext: null, @@ -72,12 +75,14 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( state: DesktopUpdateState, version: string, checkedAt: string, + releaseNotes: ReadonlyArray = [], ): DesktopUpdateState { return { ...state, status: "available", availableVersion: version, downloadedVersion: null, + releaseNotes, downloadPercent: null, checkedAt, message: null, @@ -95,6 +100,7 @@ export function reduceDesktopUpdateStateOnNoUpdate( status: "up-to-date", availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt, message: null, diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index fd7a93884159..d1c29ffcfc6e 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,5 +1,6 @@ import type { ExpoConfig } from "expo/config"; +import { BRAND_ASSET_PATHS } from "../../scripts/lib/brand-assets.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; type AppVariant = "development" | "preview" | "production"; @@ -15,6 +16,8 @@ const isIosPersonalTeamBuild = repoEnv.T3CODE_IOS_PERSONAL_TEAM === "1"; const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim(); const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/; +const fromRepoRoot = (relativePath: string) => `../../${relativePath}`; + if ( isIosPersonalTeamBuild && (!personalTeamBundleIdentifier || @@ -26,20 +29,31 @@ if ( } const DEVELOPMENT_ASSETS = { - appIcon: "./assets/splash-icon-dev.png", - iosIcon: "./assets/icon-composer-dev.icon", - splashIcon: "./assets/splash-icon-dev.png", - androidAdaptiveForeground: "./assets/android-icon-dev-foreground.png", + appIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), + iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIconComposerProject), + splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), + androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.developmentUniversalIconPng), androidAdaptiveBackgroundColor: "#00639B", androidMonochromeIcon: "./assets/android-icon-mark.png", androidNotificationIcon: "./assets/android-notification-icon.png", androidNotificationColor: "#00639B", } as const; +const PREVIEW_ASSETS = { + appIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), + iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIconComposerProject), + splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), + androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.nightlyLinuxIconPng), + androidAdaptiveBackgroundColor: "#111533", + androidMonochromeIcon: "./assets/android-icon-mark.png", + androidNotificationIcon: "./assets/android-notification-icon.png", + androidNotificationColor: "#7565C7", +} as const; + const RELEASE_ASSETS = { - appIcon: "./assets/splash-icon-prod.png", - iosIcon: "./assets/icon-composer-prod.icon", - splashIcon: "./assets/splash-icon-prod.png", + appIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), + iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIconComposerProject), + splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), androidAdaptiveForeground: "./assets/android-icon-mark.png", androidAdaptiveBackgroundColor: "#000000", androidMonochromeIcon: "./assets/android-icon-mark.png", @@ -62,7 +76,7 @@ const VARIANT_CONFIG = { iosBundleIdentifier: "com.t3tools.t3code.preview", androidPackage: "com.t3tools.t3code.preview", relyingParty: "clerk.t3.codes", - assets: RELEASE_ASSETS, + assets: PREVIEW_ASSETS, }, production: { appName: "T3 Code", diff --git a/apps/mobile/assets/android-icon-dev-foreground.png b/apps/mobile/assets/android-icon-dev-foreground.png deleted file mode 100644 index b33d7978d720..000000000000 Binary files a/apps/mobile/assets/android-icon-dev-foreground.png and /dev/null differ diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg b/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg deleted file mode 100644 index d98c41a4e3e8..000000000000 Binary files a/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg and /dev/null differ diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png b/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png deleted file mode 100644 index de5a82d8c49e..000000000000 Binary files a/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png and /dev/null differ diff --git a/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg b/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg deleted file mode 100644 index b12706fdfc23..000000000000 --- a/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/mobile/assets/splash-icon-dev.png b/apps/mobile/assets/splash-icon-dev.png deleted file mode 100644 index b33d6f337b0b..000000000000 Binary files a/apps/mobile/assets/splash-icon-dev.png and /dev/null differ diff --git a/apps/mobile/assets/splash-icon-prod.png b/apps/mobile/assets/splash-icon-prod.png deleted file mode 100644 index 2ff311ce786f..000000000000 Binary files a/apps/mobile/assets/splash-icon-prod.png and /dev/null differ diff --git a/apps/mobile/src/components/BrandMark.tsx b/apps/mobile/src/components/BrandMark.tsx index 4d1ac01b1ff4..c8f1d4385517 100644 --- a/apps/mobile/src/components/BrandMark.tsx +++ b/apps/mobile/src/components/BrandMark.tsx @@ -1,13 +1,23 @@ -import { Image, View } from "react-native"; +import Constants from "expo-constants"; +import { Image } from "expo-image"; +import { View } from "react-native"; import { AppText as Text } from "./AppText"; -const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png"); +const appVariant = Constants.expoConfig?.extra?.appVariant; +const BRAND_MARK_SOURCE = + appVariant === "development" + ? require("../../../../assets/dev/blueprint-ios-1024.png") + : appVariant === "preview" + ? require("../../../../assets/nightly/nightly-ios-1024.png") + : require("../../../../assets/prod/black-ios-1024.png"); +const DEFAULT_STAGE_LABEL = + appVariant === "development" ? "Dev" : appVariant === "preview" ? "Preview" : "Alpha"; export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) { const compact = props.compact ?? false; const iconSize = compact ? 32 : 44; - const stageLabel = props.stageLabel ?? "Alpha"; + const stageLabel = props.stageLabel ?? DEFAULT_STAGE_LABEL; return ( diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index a05bc33a10f2..65083b5b0bfd 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -24,6 +24,7 @@ const baseState: DesktopUpdateState = { runningUnderArm64Translation: false, availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt: null, message: null, diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index c3ac56d10928..fc15862fa46d 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -15,8 +15,48 @@ import { shouldToastDesktopUpdateActionResult, } from "../desktopUpdate.logic"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; +import { Separator } from "../ui/separator"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +function SidebarUpdateReleaseNotesTooltip({ + state, + tooltip, +}: { + readonly state: NonNullable>; + readonly tooltip: string; +}) { + if (state.channel !== "nightly" || state.releaseNotes.length === 0) { + return <>{tooltip}; + } + + return ( +
+
+
{tooltip}
+
+
+ {state.releaseNotes.map((releaseNote, index) => ( +
+ {index > 0 && } +
+

+ {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`} +

+
    + {releaseNote.items.map((item, itemIndex) => ( +
  • + {item} +
  • + ))} +
+
+
+ ))} +
+
+ ); +} + export function SidebarUpdatePill() { const state = useDesktopUpdateState(); const [dismissed, setDismissed] = useState(false); @@ -151,7 +191,21 @@ export function SidebarUpdatePill() { } /> - {tooltip} + 0 + ? "max-w-none text-balance" + : undefined + } + side="top" + > + {state ? ( + + ) : ( + tooltip + )} + {action === "download" && ( diff --git a/apps/web/src/state/desktopUpdate.test.ts b/apps/web/src/state/desktopUpdate.test.ts index a2bcbd19a33f..0c05b2d45395 100644 --- a/apps/web/src/state/desktopUpdate.test.ts +++ b/apps/web/src/state/desktopUpdate.test.ts @@ -15,6 +15,7 @@ const baseState: DesktopUpdateState = { runningUnderArm64Translation: false, availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt: null, message: null, diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 000000000000..8a7e3c5f20a8 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,50 @@ +# Brand icons + +The three Icon Composer projects are the source of truth for full application icons: + +- `dev/app-icon.icon` +- `nightly/app-icon.icon` +- `prod/app-icon.icon` + +Each project uses `text.svg` for the T3 mark and `background.svg` when the background is a vector layer. Additional layers use semantic names that describe their role and placement. + +Run `vp run icons:export` from the repository root to regenerate the tracked iOS, Linux, Windows, and web assets. Run `vp run icons:check` to verify that those generated assets match their sources without changing files. + +Exporting requires Icon Composer 2 or newer on macOS. The script selects the newest compatible exporter from Xcode or a standalone Icon Composer installation and pins design generation 26. Set `ICON_COMPOSER_TOOL` to the full path of `Icon Composer.app/Contents/Executables/ictool` to override automatic discovery. + +## macOS exports + +Icon Composer's command-line exporter does not expose the `macOS pre-Tahoe` preset. A plain command-line `macOS` export is full bleed and is not suitable for the desktop app, so the export script intentionally leaves the tracked macOS PNGs unchanged and prints a reminder after every run. + +After changing an Icon Composer project, open it in Icon Composer and export the macOS PNG with exactly these settings: + +- Platform: `macOS pre-Tahoe` +- Appearance: `Default` +- Size: `1024pt` +- Scale: `1×` + +Save the three exports to: + +- `dev/app-icon.icon` -> `dev/blueprint-macos-1024.png` +- `nightly/app-icon.icon` -> `nightly/nightly-macos-1024.png` +- `prod/app-icon.icon` -> `prod/black-macos-1024.png` + +The result must be a 1024×1024 PNG with the classic macOS safe area: the opaque icon body is 824×824, inset 100 pixels on every side, with only the native Icon Composer shadow extending into the surrounding transparent canvas. + +To have Codex perform the native exports, paste this prompt into a task opened at the repository root: + +```text +Use [@Computer](plugin://computer-use@openai-bundled) and the Icon Composer app to export the three macOS app icons in this repository. + +For each project below, use Platform: macOS pre-Tahoe, Appearance: Default, Size: 1024pt, and Scale: 1×, then save the PNG to the exact destination: + +- assets/dev/app-icon.icon -> assets/dev/blueprint-macos-1024.png +- assets/nightly/app-icon.icon -> assets/nightly/nightly-macos-1024.png +- assets/prod/app-icon.icon -> assets/prod/black-macos-1024.png + +Do not resize, composite, or otherwise post-process the exported PNGs. + +Verify every result is 1024×1024 and has the classic macOS safe area: an 824×824 opaque body inset 100px on every side, with only Icon Composer's native shadow extending beyond it. +``` + +Do not edit the generated PNG or ICO files directly. diff --git a/assets/dev/app-icon.icon/Assets/annotations.svg b/assets/dev/app-icon.icon/Assets/annotations.svg new file mode 100644 index 000000000000..dffde33559ea --- /dev/null +++ b/assets/dev/app-icon.icon/Assets/annotations.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/dev/app-icon.icon/Assets/background.svg b/assets/dev/app-icon.icon/Assets/background.svg new file mode 100644 index 000000000000..db5c319d607d --- /dev/null +++ b/assets/dev/app-icon.icon/Assets/background.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/dev/app-icon.icon/Assets/text.svg b/assets/dev/app-icon.icon/Assets/text.svg new file mode 100644 index 000000000000..0b1825f2ef47 --- /dev/null +++ b/assets/dev/app-icon.icon/Assets/text.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/assets/icon-composer-dev.icon/icon.json b/assets/dev/app-icon.icon/icon.json similarity index 53% rename from apps/mobile/assets/icon-composer-dev.icon/icon.json rename to assets/dev/app-icon.icon/icon.json index fd3fb6819a91..ff7d43d78112 100644 --- a/apps/mobile/assets/icon-composer-dev.icon/icon.json +++ b/assets/dev/app-icon.icon/icon.json @@ -6,35 +6,40 @@ { "layers": [ { - "hidden": false, - "image-name": "gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png", - "name": "gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr", + "glass": true, + "image-name": "text.svg", + "name": "Text", "position": { - "scale": 1.1, + "scale": 8.5, "translation-in-points": [0, 0] } }, { - "image-name": "T3.svg", - "name": "T3", + "glass": false, + "image-name": "annotations.svg", + "name": "Annotations", "position": { - "scale": 10, + "scale": 8.5, "translation-in-points": [0, 0] } }, { - "hidden": true, - "image-name": "Texturelabs_Paper_381XL.jpg", - "name": "Texturelabs_Paper_381XL" + "glass": true, + "image-name": "background.svg", + "name": "Background", + "position": { + "scale": 8.1, + "translation-in-points": [0, 0] + } } ], "shadow": { "kind": "neutral", - "opacity": 0.5 + "opacity": 0.6 }, "translucency": { "enabled": true, - "value": 0.5 + "value": 0.2 } } ], diff --git a/assets/dev/blueprint-icon-composer.icon/Assets/T3.svg b/assets/dev/blueprint-icon-composer.icon/Assets/T3.svg deleted file mode 100644 index b12706fdfc23..000000000000 --- a/assets/dev/blueprint-icon-composer.icon/Assets/T3.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg b/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg deleted file mode 100644 index d98c41a4e3e8..000000000000 Binary files a/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg and /dev/null differ diff --git a/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png b/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png deleted file mode 100644 index fa7700d01c73..000000000000 Binary files a/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png and /dev/null differ diff --git a/assets/dev/blueprint-icon-composer.icon/icon.json b/assets/dev/blueprint-icon-composer.icon/icon.json deleted file mode 100644 index 1c15123dab4f..000000000000 --- a/assets/dev/blueprint-icon-composer.icon/icon.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "fill": { - "solid": "display-p3:0.00000,0.00000,0.00000,1.00000" - }, - "groups": [ - { - "layers": [ - { - "blend-mode": "normal", - "fill": "automatic", - "glass": true, - "hidden": false, - "image-name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png", - "name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871", - "position": { - "scale": 1.05, - "translation-in-points": [0, 0] - } - }, - { - "image-name": "T3.svg", - "name": "T3", - "position": { - "scale": 10, - "translation-in-points": [0, 0] - } - }, - { - "hidden": false, - "image-name": "Texturelabs_Paper_381XL.jpg", - "name": "Texturelabs_Paper_381XL" - } - ], - "shadow": { - "kind": "neutral", - "opacity": 0.5 - }, - "translucency": { - "enabled": true, - "value": 0.5 - } - } - ], - "supported-platforms": { - "squares": ["iOS", "macOS"] - } -} diff --git a/assets/dev/blueprint-ios-1024.png b/assets/dev/blueprint-ios-1024.png index 51b764ac72ae..a53c8d19aa83 100644 Binary files a/assets/dev/blueprint-ios-1024.png and b/assets/dev/blueprint-ios-1024.png differ diff --git a/assets/dev/blueprint-macos-1024.png b/assets/dev/blueprint-macos-1024.png index 7c165fdb851b..68a567058be1 100644 Binary files a/assets/dev/blueprint-macos-1024.png and b/assets/dev/blueprint-macos-1024.png differ diff --git a/assets/dev/blueprint-universal-1024.png b/assets/dev/blueprint-universal-1024.png index 51b764ac72ae..a53c8d19aa83 100644 Binary files a/assets/dev/blueprint-universal-1024.png and b/assets/dev/blueprint-universal-1024.png differ diff --git a/assets/dev/blueprint-web-apple-touch-180.png b/assets/dev/blueprint-web-apple-touch-180.png index 4a135d22fdbc..3eed25ea6b78 100644 Binary files a/assets/dev/blueprint-web-apple-touch-180.png and b/assets/dev/blueprint-web-apple-touch-180.png differ diff --git a/assets/dev/blueprint-web-favicon-16x16.png b/assets/dev/blueprint-web-favicon-16x16.png index 56e0e837131e..a3431b8c6dfe 100644 Binary files a/assets/dev/blueprint-web-favicon-16x16.png and b/assets/dev/blueprint-web-favicon-16x16.png differ diff --git a/assets/dev/blueprint-web-favicon-32x32.png b/assets/dev/blueprint-web-favicon-32x32.png index 9e9f31ea9893..862f7629971f 100644 Binary files a/assets/dev/blueprint-web-favicon-32x32.png and b/assets/dev/blueprint-web-favicon-32x32.png differ diff --git a/assets/dev/blueprint-web-favicon.ico b/assets/dev/blueprint-web-favicon.ico index e0ee3e1408f0..750da22602ee 100644 Binary files a/assets/dev/blueprint-web-favicon.ico and b/assets/dev/blueprint-web-favicon.ico differ diff --git a/assets/dev/blueprint-windows.ico b/assets/dev/blueprint-windows.ico index e0ee3e1408f0..750da22602ee 100644 Binary files a/assets/dev/blueprint-windows.ico and b/assets/dev/blueprint-windows.ico differ diff --git a/assets/nightly/app-icon.icon/Assets/background.svg b/assets/nightly/app-icon.icon/Assets/background.svg new file mode 100644 index 000000000000..1ceca91c0236 --- /dev/null +++ b/assets/nightly/app-icon.icon/Assets/background.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/nightly/app-icon.icon/Assets/cloud-lower-left.svg b/assets/nightly/app-icon.icon/Assets/cloud-lower-left.svg new file mode 100644 index 000000000000..d43478860f46 --- /dev/null +++ b/assets/nightly/app-icon.icon/Assets/cloud-lower-left.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/nightly/app-icon.icon/Assets/cloud-upper-right.svg b/assets/nightly/app-icon.icon/Assets/cloud-upper-right.svg new file mode 100644 index 000000000000..7b094441db15 --- /dev/null +++ b/assets/nightly/app-icon.icon/Assets/cloud-upper-right.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/nightly/app-icon.icon/Assets/text.svg b/assets/nightly/app-icon.icon/Assets/text.svg new file mode 100644 index 000000000000..0b1825f2ef47 --- /dev/null +++ b/assets/nightly/app-icon.icon/Assets/text.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/nightly/app-icon.icon/icon.json b/assets/nightly/app-icon.icon/icon.json new file mode 100644 index 000000000000..eb48d26b4a52 --- /dev/null +++ b/assets/nightly/app-icon.icon/icon.json @@ -0,0 +1,94 @@ +{ + "fill": { + "automatic-gradient": "display-p3:0.00000,0.00000,0.00000,1.00000" + }, + "groups": [ + { + "layers": [ + { + "glass": true, + "image-name": "text.svg", + "name": "Text", + "position": { + "scale": 8.5, + "translation-in-points": [0, 0] + } + }, + { + "glass": false, + "image-name": "cloud-upper-right.svg", + "name": "Cloud Upper Right", + "opacity": 1, + "position": { + "scale": 15, + "translation-in-points": [387.9605131881942, -134.30064713259117] + } + }, + { + "glass": false, + "image-name": "cloud-lower-left.svg", + "name": "Cloud Lower Left", + "position": { + "scale": 25, + "translation-in-points": [-309.63750000000005, 268.66077693836917] + } + }, + { + "glass": true, + "hidden": false, + "image-name": "background.svg", + "name": "Background", + "position": { + "scale": 8.1, + "translation-in-points": [0, -2.171875] + } + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.6 + }, + "translucency": { + "enabled": true, + "value": 0.2 + } + }, + { + "layers": [], + "shadow": { + "kind": "neutral", + "opacity": 0.6 + }, + "translucency": { + "enabled": true, + "value": 0.2 + } + }, + { + "layers": [], + "shadow": { + "kind": "neutral", + "opacity": 0.6 + }, + "translucency": { + "enabled": true, + "value": 0.2 + } + }, + { + "layers": [], + "shadow": { + "kind": "neutral", + "opacity": 0.6 + }, + "translucency": { + "enabled": true, + "value": 0.2 + } + } + ], + "supported-platforms": { + "circles": ["watchOS"], + "squares": "shared" + } +} diff --git a/assets/nightly/blueprint-ios-1024.png b/assets/nightly/blueprint-ios-1024.png deleted file mode 100644 index b33d6f337b0b..000000000000 Binary files a/assets/nightly/blueprint-ios-1024.png and /dev/null differ diff --git a/assets/nightly/blueprint-macos-1024.png b/assets/nightly/blueprint-macos-1024.png deleted file mode 100644 index 8dba03e01fe1..000000000000 Binary files a/assets/nightly/blueprint-macos-1024.png and /dev/null differ diff --git a/assets/nightly/blueprint-universal-1024.png b/assets/nightly/blueprint-universal-1024.png deleted file mode 100644 index b33d6f337b0b..000000000000 Binary files a/assets/nightly/blueprint-universal-1024.png and /dev/null differ diff --git a/assets/nightly/blueprint-web-apple-touch-180.png b/assets/nightly/blueprint-web-apple-touch-180.png deleted file mode 100644 index e0e1b9659b83..000000000000 Binary files a/assets/nightly/blueprint-web-apple-touch-180.png and /dev/null differ diff --git a/assets/nightly/blueprint-web-favicon-16x16.png b/assets/nightly/blueprint-web-favicon-16x16.png deleted file mode 100644 index 673d84599981..000000000000 Binary files a/assets/nightly/blueprint-web-favicon-16x16.png and /dev/null differ diff --git a/assets/nightly/blueprint-web-favicon-32x32.png b/assets/nightly/blueprint-web-favicon-32x32.png deleted file mode 100644 index 25bcc95d4aaa..000000000000 Binary files a/assets/nightly/blueprint-web-favicon-32x32.png and /dev/null differ diff --git a/assets/nightly/blueprint-web-favicon.ico b/assets/nightly/blueprint-web-favicon.ico deleted file mode 100644 index 36975b997827..000000000000 Binary files a/assets/nightly/blueprint-web-favicon.ico and /dev/null differ diff --git a/assets/nightly/blueprint-windows.ico b/assets/nightly/blueprint-windows.ico deleted file mode 100644 index 36975b997827..000000000000 Binary files a/assets/nightly/blueprint-windows.ico and /dev/null differ diff --git a/assets/nightly/nightly-ios-1024.png b/assets/nightly/nightly-ios-1024.png new file mode 100644 index 000000000000..42ce5589e9ca Binary files /dev/null and b/assets/nightly/nightly-ios-1024.png differ diff --git a/assets/nightly/nightly-macos-1024.png b/assets/nightly/nightly-macos-1024.png new file mode 100644 index 000000000000..1beacd9f34ad Binary files /dev/null and b/assets/nightly/nightly-macos-1024.png differ diff --git a/assets/nightly/nightly-universal-1024.png b/assets/nightly/nightly-universal-1024.png new file mode 100644 index 000000000000..42ce5589e9ca Binary files /dev/null and b/assets/nightly/nightly-universal-1024.png differ diff --git a/assets/nightly/nightly-web-apple-touch-180.png b/assets/nightly/nightly-web-apple-touch-180.png new file mode 100644 index 000000000000..f09a169458ab Binary files /dev/null and b/assets/nightly/nightly-web-apple-touch-180.png differ diff --git a/assets/nightly/nightly-web-favicon-16x16.png b/assets/nightly/nightly-web-favicon-16x16.png new file mode 100644 index 000000000000..20208f5f3695 Binary files /dev/null and b/assets/nightly/nightly-web-favicon-16x16.png differ diff --git a/assets/nightly/nightly-web-favicon-32x32.png b/assets/nightly/nightly-web-favicon-32x32.png new file mode 100644 index 000000000000..b9008d518bce Binary files /dev/null and b/assets/nightly/nightly-web-favicon-32x32.png differ diff --git a/assets/nightly/nightly-web-favicon.ico b/assets/nightly/nightly-web-favicon.ico new file mode 100644 index 000000000000..b6a0b43b93d2 Binary files /dev/null and b/assets/nightly/nightly-web-favicon.ico differ diff --git a/assets/nightly/nightly-windows.ico b/assets/nightly/nightly-windows.ico new file mode 100644 index 000000000000..b6a0b43b93d2 Binary files /dev/null and b/assets/nightly/nightly-windows.ico differ diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/T3.svg b/assets/prod/app-icon.icon/Assets/text.svg similarity index 100% rename from apps/mobile/assets/icon-composer-dev.icon/Assets/T3.svg rename to assets/prod/app-icon.icon/Assets/text.svg diff --git a/apps/mobile/assets/icon-composer-prod.icon/icon.json b/assets/prod/app-icon.icon/icon.json similarity index 88% rename from apps/mobile/assets/icon-composer-prod.icon/icon.json rename to assets/prod/app-icon.icon/icon.json index 8f7579311f26..8c12c022ef86 100644 --- a/apps/mobile/assets/icon-composer-prod.icon/icon.json +++ b/assets/prod/app-icon.icon/icon.json @@ -6,8 +6,8 @@ { "layers": [ { - "image-name": "T3.svg", - "name": "T3", + "image-name": "text.svg", + "name": "Text", "position": { "scale": 10, "translation-in-points": [0, 0] diff --git a/assets/prod/black-ios-1024.png b/assets/prod/black-ios-1024.png index 073ad811c250..a8a458337ab0 100644 Binary files a/assets/prod/black-ios-1024.png and b/assets/prod/black-ios-1024.png differ diff --git a/assets/prod/black-macos-1024.png b/assets/prod/black-macos-1024.png index 073ad811c250..f5b68b1a6d40 100644 Binary files a/assets/prod/black-macos-1024.png and b/assets/prod/black-macos-1024.png differ diff --git a/assets/prod/black-universal-1024.png b/assets/prod/black-universal-1024.png index 073ad811c250..a8a458337ab0 100644 Binary files a/assets/prod/black-universal-1024.png and b/assets/prod/black-universal-1024.png differ diff --git a/assets/prod/t3-black-web-apple-touch-180.png b/assets/prod/t3-black-web-apple-touch-180.png index 3ed96e897f33..2e30aecbc72b 100644 Binary files a/assets/prod/t3-black-web-apple-touch-180.png and b/assets/prod/t3-black-web-apple-touch-180.png differ diff --git a/assets/prod/t3-black-web-favicon-16x16.png b/assets/prod/t3-black-web-favicon-16x16.png index 44c99d844718..c175f2c87a21 100644 Binary files a/assets/prod/t3-black-web-favicon-16x16.png and b/assets/prod/t3-black-web-favicon-16x16.png differ diff --git a/assets/prod/t3-black-web-favicon-32x32.png b/assets/prod/t3-black-web-favicon-32x32.png index c0ed3eddf734..fe6fb545b859 100644 Binary files a/assets/prod/t3-black-web-favicon-32x32.png and b/assets/prod/t3-black-web-favicon-32x32.png differ diff --git a/assets/prod/t3-black-web-favicon.ico b/assets/prod/t3-black-web-favicon.ico index 947f6d57b92a..a0fe86da92f0 100644 Binary files a/assets/prod/t3-black-web-favicon.ico and b/assets/prod/t3-black-web-favicon.ico differ diff --git a/assets/prod/t3-black-windows.ico b/assets/prod/t3-black-windows.ico index e3ab4ae5e024..a0fe86da92f0 100644 Binary files a/assets/prod/t3-black-windows.ico and b/assets/prod/t3-black-windows.ico differ diff --git a/package.json b/package.json index 4464d49f159a..600c3083a8db 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "start:marketing": "vp run --filter @t3tools/marketing preview", "start:mock-update-server": "node scripts/mock-update-server.ts", "screenshots:mobile": "node scripts/mobile-showcase.ts", + "icons:export": "node scripts/export-brand-icons.ts", + "icons:check": "node scripts/export-brand-icons.ts --check", "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build", "sync:upstream-prs": "node scripts/sync-upstream-pr-tracks.mjs", "build:marketing": "vp run --filter @t3tools/marketing build", diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 42e32e27121c..a0a857a6f35d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -213,6 +213,7 @@ export interface DesktopUpdateState { runningUnderArm64Translation: boolean; availableVersion: string | null; downloadedVersion: string | null; + releaseNotes: ReadonlyArray; downloadPercent: number | null; checkedAt: string | null; message: string | null; @@ -220,6 +221,16 @@ export interface DesktopUpdateState { canRetry: boolean; } +export interface DesktopUpdateReleaseNote { + version: string; + items: ReadonlyArray; +} + +export const DesktopUpdateReleaseNoteSchema = Schema.Struct({ + version: Schema.String, + items: Schema.Array(Schema.String), +}); + export const DesktopUpdateStateSchema = Schema.Struct({ enabled: Schema.Boolean, status: DesktopUpdateStatusSchema, @@ -230,6 +241,7 @@ export const DesktopUpdateStateSchema = Schema.Struct({ runningUnderArm64Translation: Schema.Boolean, availableVersion: Schema.NullOr(Schema.String), downloadedVersion: Schema.NullOr(Schema.String), + releaseNotes: Schema.Array(DesktopUpdateReleaseNoteSchema), downloadPercent: Schema.NullOr(Schema.Number), checkedAt: Schema.NullOr(Schema.String), message: Schema.NullOr(Schema.String), diff --git a/scripts/export-brand-icons.ts b/scripts/export-brand-icons.ts new file mode 100644 index 000000000000..c24169dc2e13 --- /dev/null +++ b/scripts/export-brand-icons.ts @@ -0,0 +1,800 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Command, Flag } from "effect/unstable/cli"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; +import { encodePngIco, readPngDimensions, WINDOWS_ICON_SIZES } from "./lib/icon-export.ts"; + +const DESIGN_GENERATION = 26; +const ICON_COMPOSER_EXECUTABLE_PARTS = [ + "Contents", + "Applications", + "Icon Composer.app", + "Contents", + "Executables", + "ictool", +] as const; +const STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS = [ + "Icon Composer.app", + "Contents", + "Executables", + "ictool", +] as const; +const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); + +const IconComposerVersion = Schema.Struct({ + "bundle-version": Schema.NonEmptyString, + "short-bundle-version": Schema.NonEmptyString, +}); +const decodeIconComposerVersion = Schema.decodeUnknownEffect( + Schema.fromJsonString(IconComposerVersion), +); + +type IconPlatform = "iOS"; + +interface VariantOutputs { + readonly ios: string; + readonly macos: string; + readonly universal: string; + readonly appleTouch: string; + readonly favicon16: string; + readonly favicon32: string; + readonly faviconIco: string; + readonly windowsIco: string; +} + +interface IconVariant { + readonly label: string; + readonly source: string; + readonly outputs: VariantOutputs; +} + +interface IconComposerTool { + readonly path: string; + readonly version: string; + readonly bundleVersion: string; + readonly supportsDesignGeneration: boolean; +} + +interface CommandResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; +} + +export class IconExportFileSystemError extends Schema.TaggedErrorClass()( + "IconExportFileSystemError", + { + operation: Schema.Literals([ + "resolve-repository-root", + "check-path", + "read-directory", + "read-file", + "make-directory", + "make-temp-directory", + "make-temp-file", + "write-file", + "rename-file", + ]), + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Icon export file-system operation '${this.operation}' failed for ${this.path}.`; + } +} + +export class IconExportProcessError extends Schema.TaggedErrorClass()( + "IconExportProcessError", + { + operation: Schema.Literals(["spawn", "collect-stdout", "collect-stderr", "wait-for-exit"]), + command: Schema.String, + argumentCount: NonNegativeInt, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Icon export process operation '${this.operation}' failed for ${this.command}.`; + } +} + +export class IconExportCommandFailedError extends Schema.TaggedErrorClass()( + "IconExportCommandFailedError", + { + command: Schema.String, + argumentCount: NonNegativeInt, + exitCode: Schema.Int, + sourcePath: Schema.String, + size: Schema.Int, + stdout: Schema.optional(Schema.String), + stderr: Schema.optional(Schema.String), + }, +) { + override get message(): string { + return `Icon Composer failed to export ${this.sourcePath} at ${this.size}x${this.size}.`; + } +} + +export class IconExportToolResolutionError extends Schema.TaggedErrorClass()( + "IconExportToolResolutionError", + { + reason: Schema.Literals(["configured-invalid", "configured-outdated", "not-found"]), + designGeneration: Schema.Int, + toolPath: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), + }, +) { + override get message(): string { + switch (this.reason) { + case "configured-invalid": + return `ICON_COMPOSER_TOOL does not point to Icon Composer's export-capable ictool: ${this.toolPath}`; + case "configured-outdated": + return `ICON_COMPOSER_TOOL points to Icon Composer ${this.version}, but version 2 or newer is required for design generation ${this.designGeneration}.`; + case "not-found": + return `Could not find an Icon Composer 2.x exporter compatible with design generation ${this.designGeneration}. Install a compatible Icon Composer/Xcode or set ICON_COMPOSER_TOOL to Icon Composer.app/Contents/Executables/ictool.`; + } + } +} + +export class IconExportSourceMissingError extends Schema.TaggedErrorClass()( + "IconExportSourceMissingError", + { + sourcePath: Schema.String, + }, +) { + override get message(): string { + return `Missing Icon Composer source project: ${this.sourcePath}`; + } +} + +export class IconExportRenditionError extends Schema.TaggedErrorClass()( + "IconExportRenditionError", + { + sourcePath: Schema.String, + outputPath: Schema.String, + expectedSize: Schema.Int, + actualWidth: Schema.optional(Schema.Int), + actualHeight: Schema.optional(Schema.Int), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const actual = + this.actualWidth === undefined || this.actualHeight === undefined + ? "an invalid PNG" + : `${this.actualWidth}x${this.actualHeight}`; + return `Icon Composer produced ${actual}; expected ${this.expectedSize}x${this.expectedSize} for ${this.sourcePath}.`; + } +} + +export class IconExportEncodingError extends Schema.TaggedErrorClass()( + "IconExportEncodingError", + { + variant: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to encode ICO renditions for the ${this.variant} icon.`; + } +} + +export class IconExportAssetsStaleError extends Schema.TaggedErrorClass()( + "IconExportAssetsStaleError", + { + paths: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return `Generated icon assets are stale:\n${this.paths.map((path) => `- ${path}`).join("\n")}`; + } +} + +const ICON_VARIANTS = [ + { + label: "development", + source: BRAND_ASSET_PATHS.developmentIconComposerProject, + outputs: { + ios: BRAND_ASSET_PATHS.developmentIosIconPng, + macos: BRAND_ASSET_PATHS.developmentDesktopIconPng, + universal: BRAND_ASSET_PATHS.developmentUniversalIconPng, + appleTouch: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng, + favicon16: BRAND_ASSET_PATHS.developmentWebFavicon16Png, + favicon32: BRAND_ASSET_PATHS.developmentWebFavicon32Png, + faviconIco: BRAND_ASSET_PATHS.developmentWebFaviconIco, + windowsIco: BRAND_ASSET_PATHS.developmentWindowsIconIco, + }, + }, + { + label: "preview", + source: BRAND_ASSET_PATHS.nightlyIconComposerProject, + outputs: { + ios: BRAND_ASSET_PATHS.nightlyIosIconPng, + macos: BRAND_ASSET_PATHS.nightlyMacIconPng, + universal: BRAND_ASSET_PATHS.nightlyLinuxIconPng, + appleTouch: BRAND_ASSET_PATHS.nightlyWebAppleTouchIconPng, + favicon16: BRAND_ASSET_PATHS.nightlyWebFavicon16Png, + favicon32: BRAND_ASSET_PATHS.nightlyWebFavicon32Png, + faviconIco: BRAND_ASSET_PATHS.nightlyWebFaviconIco, + windowsIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco, + }, + }, + { + label: "production", + source: BRAND_ASSET_PATHS.productionIconComposerProject, + outputs: { + ios: BRAND_ASSET_PATHS.productionIosIconPng, + macos: BRAND_ASSET_PATHS.productionMacIconPng, + universal: BRAND_ASSET_PATHS.productionLinuxIconPng, + appleTouch: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng, + favicon16: BRAND_ASSET_PATHS.productionWebFavicon16Png, + favicon32: BRAND_ASSET_PATHS.productionWebFavicon32Png, + faviconIco: BRAND_ASSET_PATHS.productionWebFaviconIco, + windowsIco: BRAND_ASSET_PATHS.productionWindowsIconIco, + }, + }, +] as const satisfies ReadonlyArray; + +const MACOS_EXPORT_CODEX_PROMPT = [ + "Use [@Computer](plugin://computer-use@openai-bundled) and the Icon Composer app to export the three macOS app icons in this repository.", + "For each project below, use Platform: macOS pre-Tahoe, Appearance: Default, Size: 1024pt, and Scale: 1×, then save the PNG to the exact destination:", + ...ICON_VARIANTS.map((variant) => `- ${variant.source} -> ${variant.outputs.macos}`), + "Do not resize, composite, or otherwise post-process the exported PNGs.", + "Verify every result is 1024×1024 and has the classic macOS safe area: an 824×824 opaque body inset 100px on every side, with only Icon Composer's native shadow extending beyond it.", +]; + +const RepositoryRoot = Effect.service(Path.Path).pipe( + Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "resolve-repository-root", + path: new URL("..", import.meta.url).pathname, + cause, + }), + ), +); + +const collectStreamAsString = (stream: Stream.Stream) => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); + +const runCommand = Effect.fn("iconExport.runCommand")(function* ( + command: string, + args: ReadonlyArray, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(ChildProcess.make(command, args)).pipe( + Effect.mapError( + (cause) => + new IconExportProcessError({ + operation: "spawn", + command, + argumentCount: args.length, + cause, + }), + ), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout).pipe( + Effect.mapError( + (cause) => + new IconExportProcessError({ + operation: "collect-stdout", + command, + argumentCount: args.length, + cause, + }), + ), + ), + collectStreamAsString(child.stderr).pipe( + Effect.mapError( + (cause) => + new IconExportProcessError({ + operation: "collect-stderr", + command, + argumentCount: args.length, + cause, + }), + ), + ), + child.exitCode.pipe( + Effect.map(Number), + Effect.mapError( + (cause) => + new IconExportProcessError({ + operation: "wait-for-exit", + command, + argumentCount: args.length, + cause, + }), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + return { stdout, stderr, exitCode } satisfies CommandResult; +}); + +const iconComposerToolFromDeveloperDirectory = (developerDirectory: string, path: Path.Path) => + path.resolve(developerDirectory, "..", ...ICON_COMPOSER_EXECUTABLE_PARTS.slice(1)); + +const readSelectedDeveloperDirectory = Effect.fn("iconExport.readSelectedDeveloperDirectory")( + function* () { + const result = yield* runCommand("xcode-select", ["-p"]).pipe(Effect.option); + return Option.flatMap(result, (output) => { + const developerDirectory = output.stdout.trim(); + return output.exitCode === 0 && developerDirectory.length > 0 + ? Option.some(developerDirectory) + : Option.none(); + }); + }, +); + +const findXcodeAppCandidates = Effect.fn("iconExport.findXcodeAppCandidates")(function* ( + directory: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const entries = yield* fs.readDirectory(directory).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "read-directory", + path: directory, + cause, + }), + ), + Effect.orElseSucceed(() => []), + ); + return entries + .filter((entry) => /^Xcode.*\.app$/.test(entry)) + .map((entry) => path.join(directory, entry, ...ICON_COMPOSER_EXECUTABLE_PARTS)); +}); + +const probeIconComposerTool = Effect.fn("iconExport.probeIconComposerTool")(function* ( + candidate: string, +) { + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs.exists(candidate).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "check-path", + path: candidate, + cause, + }), + ), + Effect.orElseSucceed(() => false), + ); + if (!exists) return Option.none(); + + const result = yield* runCommand(candidate, ["--version"]).pipe(Effect.option); + if (Option.isNone(result) || result.value.exitCode !== 0) { + return Option.none(); + } + + const version = yield* decodeIconComposerVersion(result.value.stdout).pipe(Effect.option); + if (Option.isNone(version)) return Option.none(); + + const bundleVersion = version.value["bundle-version"]; + const shortVersion = version.value["short-bundle-version"]; + return Option.some({ + path: candidate, + version: `${shortVersion} (${bundleVersion})`, + bundleVersion, + supportsDesignGeneration: Number.parseInt(shortVersion, 10) >= 2, + }); +}); + +const resolveIconComposerTool = Effect.fn("iconExport.resolveIconComposerTool")(function* () { + const path = yield* Path.Path; + const environment = yield* HostProcessEnvironment; + const configuredTool = environment.ICON_COMPOSER_TOOL?.trim(); + if (configuredTool) { + const tool = yield* probeIconComposerTool(configuredTool); + if (Option.isNone(tool)) { + return yield* new IconExportToolResolutionError({ + reason: "configured-invalid", + designGeneration: DESIGN_GENERATION, + toolPath: configuredTool, + }); + } + if (!tool.value.supportsDesignGeneration) { + return yield* new IconExportToolResolutionError({ + reason: "configured-outdated", + designGeneration: DESIGN_GENERATION, + toolPath: configuredTool, + version: tool.value.version, + }); + } + return tool.value; + } + + const selectedDeveloperDirectory = yield* readSelectedDeveloperDirectory(); + const configuredDeveloperDirectory = environment.DEVELOPER_DIR?.trim(); + const homeDirectory = environment.HOME?.trim(); + const searchDirectories = [ + "/Applications", + ...(homeDirectory ? [path.join(homeDirectory, "Downloads")] : []), + ]; + const xcodeCandidates = yield* Effect.forEach(searchDirectories, findXcodeAppCandidates, { + concurrency: "unbounded", + }); + const candidates = new Set([ + ...(configuredDeveloperDirectory + ? [iconComposerToolFromDeveloperDirectory(configuredDeveloperDirectory, path)] + : []), + ...Option.match(selectedDeveloperDirectory, { + onNone: () => [], + onSome: (developerDirectory) => [ + iconComposerToolFromDeveloperDirectory(developerDirectory, path), + ], + }), + path.join("/Applications", ...STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS), + ...(homeDirectory + ? [path.join(homeDirectory, "Applications", ...STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS)] + : []), + ...xcodeCandidates.flat(), + ]); + const probed = yield* Effect.forEach([...candidates], probeIconComposerTool, { + concurrency: "unbounded", + }); + const compatibleTools = probed + .filter(Option.isSome) + .map((tool) => tool.value) + .filter((tool) => tool.supportsDesignGeneration) + .sort((left, right) => + right.bundleVersion.localeCompare(left.bundleVersion, undefined, { numeric: true }), + ); + const newestTool = compatibleTools[0]; + if (newestTool) return newestTool; + + return yield* new IconExportToolResolutionError({ + reason: "not-found", + designGeneration: DESIGN_GENERATION, + }); +}); + +const renderIcon = Effect.fn("iconExport.renderIcon")(function* ( + toolPath: string, + sourcePath: string, + outputPath: string, + platform: IconPlatform, + size: number, +) { + const fs = yield* FileSystem.FileSystem; + const args = [ + sourcePath, + "--export-image", + "--output-file", + outputPath, + "--platform", + platform, + "--rendition", + "Default", + "--width", + String(size), + "--height", + String(size), + "--scale", + "1", + "--design-generation", + String(DESIGN_GENERATION), + ]; + const result = yield* runCommand(toolPath, args); + if (result.exitCode !== 0) { + return yield* new IconExportCommandFailedError({ + command: toolPath, + argumentCount: args.length, + exitCode: result.exitCode, + sourcePath, + size, + ...(result.stdout.trim() ? { stdout: result.stdout.trim() } : {}), + ...(result.stderr.trim() ? { stderr: result.stderr.trim() } : {}), + }); + } + + const contents = yield* fs.readFile(outputPath).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "read-file", + path: outputPath, + cause, + }), + ), + ); + const buffer = Buffer.from(contents); + const dimensions = yield* Effect.try({ + try: () => readPngDimensions(buffer), + catch: (cause) => + new IconExportRenditionError({ + sourcePath, + outputPath, + expectedSize: size, + cause, + }), + }); + if (dimensions.width !== size || dimensions.height !== size) { + return yield* new IconExportRenditionError({ + sourcePath, + outputPath, + expectedSize: size, + actualWidth: dimensions.width, + actualHeight: dimensions.height, + }); + } + return buffer; +}); + +const renderVariant = Effect.fn("iconExport.renderVariant")(function* ( + toolPath: string, + repositoryRoot: string, + temporaryDirectory: string, + variant: IconVariant, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourcePath = path.join(repositoryRoot, variant.source); + const sourceExists = yield* fs.exists(sourcePath).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "check-path", + path: sourcePath, + cause, + }), + ), + ); + if (!sourceExists) { + return yield* new IconExportSourceMissingError({ sourcePath: variant.source }); + } + + const renditionCache = new Map(); + const render = Effect.fn("iconExport.renderVariant.rendition")(function* ( + platform: IconPlatform, + size: number, + ) { + const cacheKey = `${platform}-${size}`; + const cached = renditionCache.get(cacheKey); + if (cached) return cached; + + const outputPath = path.join(temporaryDirectory, `${variant.label}-${platform}-${size}.png`); + const contents = yield* renderIcon(toolPath, sourcePath, outputPath, platform, size); + renditionCache.set(cacheKey, contents); + return contents; + }); + + const ios = yield* render("iOS", 1024); + const icoRenditions = yield* Effect.forEach( + WINDOWS_ICON_SIZES, + (size) => render("iOS", size).pipe(Effect.map((contents) => ({ size, contents }))), + { concurrency: 1 }, + ); + const ico = yield* Effect.try({ + try: () => encodePngIco(icoRenditions), + catch: (cause) => new IconExportEncodingError({ variant: variant.label, cause }), + }); + + return new Map([ + [variant.outputs.ios, ios], + [variant.outputs.universal, ios], + [variant.outputs.appleTouch, yield* render("iOS", 180)], + [variant.outputs.favicon16, yield* render("iOS", 16)], + [variant.outputs.favicon32, yield* render("iOS", 32)], + [variant.outputs.faviconIco, ico], + [variant.outputs.windowsIco, ico], + ]); +}); + +const logManualMacOsExportInstructions = Effect.fn("iconExport.logManualMacOsExportInstructions")( + function* () { + yield* Console.warn( + [ + "macOS icons require Icon Composer's GUI-only pre-Tahoe preset and were not changed.", + "Export each source with Platform: macOS pre-Tahoe, Appearance: Default, Size: 1024pt, Scale: 1×:", + ...ICON_VARIANTS.map((variant) => `- ${variant.source} -> ${variant.outputs.macos}`), + "See assets/README.md for the complete workflow.", + "", + "Copy/paste this prompt into Codex to perform the native exports:", + "---", + ...MACOS_EXPORT_CODEX_PROMPT, + "---", + ].join("\n"), + ); + }, +); + +const writeAtomically = Effect.fn("iconExport.writeAtomically")(function* ( + repositoryRoot: string, + relativePath: string, + contents: Buffer, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const targetPath = path.join(repositoryRoot, relativePath); + const targetDirectory = path.dirname(targetPath); + yield* fs.makeDirectory(targetDirectory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "make-directory", + path: targetDirectory, + cause, + }), + ), + ); + const temporaryPath = yield* fs + .makeTempFileScoped({ + directory: targetDirectory, + prefix: ".t3-icon-export-", + suffix: ".tmp", + }) + .pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "make-temp-file", + path: targetDirectory, + cause, + }), + ), + ); + yield* fs.writeFile(temporaryPath, contents).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "write-file", + path: temporaryPath, + cause, + }), + ), + ); + yield* fs.rename(temporaryPath, targetPath).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "rename-file", + path: targetPath, + cause, + }), + ), + ); +}); + +const isCurrent = Effect.fn("iconExport.isCurrent")(function* ( + repositoryRoot: string, + relativePath: string, + expected: Buffer, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const targetPath = path.join(repositoryRoot, relativePath); + const exists = yield* fs.exists(targetPath).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "check-path", + path: targetPath, + cause, + }), + ), + ); + if (!exists) return false; + + const actual = yield* fs.readFile(targetPath).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "read-file", + path: targetPath, + cause, + }), + ), + ); + return Buffer.from(actual).equals(expected); +}); + +export const exportBrandIcons = Effect.fn("exportBrandIcons")(function* (checkOnly: boolean) { + const fs = yield* FileSystem.FileSystem; + const repositoryRoot = yield* RepositoryRoot; + const tool = yield* resolveIconComposerTool(); + const temporaryDirectory = yield* fs + .makeTempDirectoryScoped({ + prefix: "t3-icon-export-", + }) + .pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "make-temp-directory", + path: "system temporary directory", + cause, + }), + ), + ); + yield* Console.log( + `Exporting icons with Icon Composer ${tool.version}, design generation ${DESIGN_GENERATION}.`, + ); + + const generated = new Map(); + for (const variant of ICON_VARIANTS) { + yield* Console.log(`Rendering ${variant.label} from ${variant.source}...`); + const variantAssets = yield* renderVariant( + tool.path, + repositoryRoot, + temporaryDirectory, + variant, + ); + for (const [relativePath, contents] of variantAssets) { + generated.set(relativePath, contents); + } + } + + if (checkOnly) { + const stale = yield* Effect.filter( + [...generated.entries()], + ([relativePath, contents]) => + isCurrent(repositoryRoot, relativePath, contents).pipe(Effect.map((current) => !current)), + { concurrency: "unbounded" }, + ); + if (stale.length > 0) { + return yield* new IconExportAssetsStaleError({ + paths: stale.map(([relativePath]) => relativePath), + }); + } + yield* Console.log(`All ${generated.size} generated icon assets are current.`); + yield* logManualMacOsExportInstructions(); + return; + } + + yield* Effect.forEach( + generated, + ([relativePath, contents]) => writeAtomically(repositoryRoot, relativePath, contents), + { concurrency: 1, discard: true }, + ); + yield* Console.log(`Updated ${generated.size} generated icon assets.`); + yield* logManualMacOsExportInstructions(); +}); + +export const exportBrandIconsCommand = Command.make( + "export-brand-icons", + { + check: Flag.boolean("check").pipe( + Flag.withDescription("Verify generated icon assets without modifying files."), + Flag.withDefault(false), + ), + }, + ({ check }) => exportBrandIcons(check).pipe(Effect.scoped), +).pipe( + Command.withDescription( + "Export development, preview, and production assets from Icon Composer projects.", + ), +); + +if (import.meta.main) { + Command.run(exportBrandIconsCommand, { version: "0.0.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} diff --git a/scripts/lib/brand-assets.test.ts b/scripts/lib/brand-assets.test.ts index 8e1875e8d274..6ab3c6a3a293 100644 --- a/scripts/lib/brand-assets.test.ts +++ b/scripts/lib/brand-assets.test.ts @@ -55,4 +55,19 @@ describe("brand-assets", () => { expect(resolveWebAssetBrandForChannel("latest")).toBe("production"); expect(resolveWebAssetBrandForChannel("nightly")).toBe("nightly"); }); + + it("keeps development, nightly, and production icon families separate", () => { + expect([ + BRAND_ASSET_PATHS.developmentIconComposerProject, + BRAND_ASSET_PATHS.nightlyIconComposerProject, + BRAND_ASSET_PATHS.productionIconComposerProject, + ]).toEqual([ + "assets/dev/app-icon.icon", + "assets/nightly/app-icon.icon", + "assets/prod/app-icon.icon", + ]); + expect(BRAND_ASSET_PATHS.developmentDesktopIconPng).toMatch(/^assets\/dev\/blueprint-/); + expect(BRAND_ASSET_PATHS.nightlyMacIconPng).toMatch(/^assets\/nightly\/nightly-/); + expect(BRAND_ASSET_PATHS.productionMacIconPng).toMatch(/^assets\/prod\/black-/); + }); }); diff --git a/scripts/lib/brand-assets.ts b/scripts/lib/brand-assets.ts index c326c43ff575..c4de2eb820b5 100644 --- a/scripts/lib/brand-assets.ts +++ b/scripts/lib/brand-assets.ts @@ -1,5 +1,13 @@ export const BRAND_ASSET_PATHS = { + developmentIconComposerProject: "assets/dev/app-icon.icon", + developmentIosIconPng: "assets/dev/blueprint-ios-1024.png", + developmentUniversalIconPng: "assets/dev/blueprint-universal-1024.png", + + // Fork-only: opt-in macOS 26 appearance-aware icon, populated manually via + // Apple Icon Composer. See docs/custom-alpha-workflow.md#macos-26-icon-method. productionMacIconComposer: "assets/prod/icon.icon", + productionIconComposerProject: "assets/prod/app-icon.icon", + productionIosIconPng: "assets/prod/black-ios-1024.png", productionMacIconPng: "assets/prod/black-macos-1024.png", productionLinuxIconPng: "assets/prod/black-universal-1024.png", productionWindowsIconIco: "assets/prod/t3-black-windows.ico", @@ -8,13 +16,15 @@ export const BRAND_ASSET_PATHS = { productionWebFavicon32Png: "assets/prod/t3-black-web-favicon-32x32.png", productionWebAppleTouchIconPng: "assets/prod/t3-black-web-apple-touch-180.png", - nightlyMacIconPng: "assets/nightly/blueprint-macos-1024.png", - nightlyLinuxIconPng: "assets/nightly/blueprint-universal-1024.png", - nightlyWindowsIconIco: "assets/nightly/blueprint-windows.ico", - nightlyWebFaviconIco: "assets/nightly/blueprint-web-favicon.ico", - nightlyWebFavicon16Png: "assets/nightly/blueprint-web-favicon-16x16.png", - nightlyWebFavicon32Png: "assets/nightly/blueprint-web-favicon-32x32.png", - nightlyWebAppleTouchIconPng: "assets/nightly/blueprint-web-apple-touch-180.png", + nightlyIconComposerProject: "assets/nightly/app-icon.icon", + nightlyIosIconPng: "assets/nightly/nightly-ios-1024.png", + nightlyMacIconPng: "assets/nightly/nightly-macos-1024.png", + nightlyLinuxIconPng: "assets/nightly/nightly-universal-1024.png", + nightlyWindowsIconIco: "assets/nightly/nightly-windows.ico", + nightlyWebFaviconIco: "assets/nightly/nightly-web-favicon.ico", + nightlyWebFavicon16Png: "assets/nightly/nightly-web-favicon-16x16.png", + nightlyWebFavicon32Png: "assets/nightly/nightly-web-favicon-32x32.png", + nightlyWebAppleTouchIconPng: "assets/nightly/nightly-web-apple-touch-180.png", developmentDesktopIconPng: "assets/dev/blueprint-macos-1024.png", developmentWindowsIconIco: "assets/dev/blueprint-windows.ico", diff --git a/scripts/lib/icon-export.test.ts b/scripts/lib/icon-export.test.ts new file mode 100644 index 000000000000..3db6f22cbdbc --- /dev/null +++ b/scripts/lib/icon-export.test.ts @@ -0,0 +1,47 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { encodePngIco, readPngDimensions } from "./icon-export.ts"; + +const pngHeader = (width: number, height: number) => { + const contents = Buffer.alloc(24); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(contents); + contents.write("IHDR", 12, "ascii"); + contents.writeUInt32BE(width, 16); + contents.writeUInt32BE(height, 20); + return contents; +}; + +describe("icon export", () => { + it("reads dimensions from a PNG IHDR chunk", () => { + assert.deepEqual(readPngDimensions(pngHeader(1024, 512)), { width: 1024, height: 512 }); + }); + + it("encodes PNG renditions into an ICO directory", () => { + const small = pngHeader(16, 16); + const large = pngHeader(256, 256); + const ico = encodePngIco([ + { size: 16, contents: small }, + { size: 256, contents: large }, + ]); + + assert.equal(ico.readUInt16LE(2), 1); + assert.equal(ico.readUInt16LE(4), 2); + assert.equal(ico.readUInt8(6), 16); + assert.equal(ico.readUInt8(22), 0); + assert.equal(ico.readUInt32LE(18), 38); + assert.equal(ico.readUInt32LE(34), 38 + small.length); + assert.deepEqual(ico.subarray(38, 38 + small.length), small); + assert.deepEqual(ico.subarray(38 + small.length), large); + }); + + it("rejects duplicate ICO rendition sizes", () => { + assert.throws( + () => + encodePngIco([ + { size: 32, contents: pngHeader(32, 32) }, + { size: 32, contents: pngHeader(32, 32) }, + ]), + /provided more than once/, + ); + }); +}); diff --git a/scripts/lib/icon-export.ts b/scripts/lib/icon-export.ts new file mode 100644 index 000000000000..f48228a94fd3 --- /dev/null +++ b/scripts/lib/icon-export.ts @@ -0,0 +1,72 @@ +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +export const WINDOWS_ICON_SIZES = [16, 24, 32, 48, 64, 128, 256] as const; + +export interface PngIconImage { + readonly size: number; + readonly contents: Buffer; +} + +export function readPngDimensions(contents: Buffer): { + readonly width: number; + readonly height: number; +} { + if ( + contents.length < 24 || + !contents.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) || + contents.toString("ascii", 12, 16) !== "IHDR" + ) { + throw new Error("Icon Composer produced an invalid PNG."); + } + + return { + width: contents.readUInt32BE(16), + height: contents.readUInt32BE(20), + }; +} + +/** Encodes PNG renditions directly into a modern, multi-resolution ICO file. */ +export function encodePngIco(images: ReadonlyArray): Buffer { + if (images.length === 0) { + throw new Error("An ICO file requires at least one PNG rendition."); + } + + const seenSizes = new Set(); + for (const image of images) { + if (!Number.isInteger(image.size) || image.size < 1 || image.size > 256) { + throw new Error(`ICO rendition size must be an integer from 1 to 256, got ${image.size}.`); + } + if (seenSizes.has(image.size)) { + throw new Error(`ICO rendition size ${image.size} was provided more than once.`); + } + if (image.contents.length === 0) { + throw new Error(`ICO rendition ${image.size}x${image.size} is empty.`); + } + seenSizes.add(image.size); + } + + const headerSize = 6; + const directoryEntrySize = 16; + const directorySize = directoryEntrySize * images.length; + const header = Buffer.alloc(headerSize + directorySize); + header.writeUInt16LE(0, 0); + header.writeUInt16LE(1, 2); + header.writeUInt16LE(images.length, 4); + + let imageOffset = header.length; + images.forEach((image, index) => { + const entryOffset = headerSize + index * directoryEntrySize; + const encodedSize = image.size === 256 ? 0 : image.size; + header.writeUInt8(encodedSize, entryOffset); + header.writeUInt8(encodedSize, entryOffset + 1); + header.writeUInt8(0, entryOffset + 2); + header.writeUInt8(0, entryOffset + 3); + header.writeUInt16LE(1, entryOffset + 4); + header.writeUInt16LE(32, entryOffset + 6); + header.writeUInt32LE(image.contents.length, entryOffset + 8); + header.writeUInt32LE(imageOffset, entryOffset + 12); + imageOffset += image.contents.length; + }); + + return Buffer.concat([header, ...images.map((image) => image.contents)]); +}