authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-14 18:18:37-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-14 18:18:37-07:00
log409ff90a1401c0b8e2bef05d73b3be57ff9bc669
treed74b22eaea5ac7a32e6913438ff3d87a9f9651de
parent0d280262f6c9dcaf529376b2007dc7447f86fa3c
signature Signed by PGP key B5690EEEBB952194

Nightly release channel (#2012)

Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>

49 files changed, 1221 insertions(+), 149 deletions(-)

.github/workflows/ci.yml+3
......@@ -92,5 +92,8 @@ jobs:
9292 with:
9393 node-version-file: package.json
9494
95 - name: Install dependencies
96 run: bun install --frozen-lockfile
97
9598 - name: Exercise release-only workflow steps
9699 run: node scripts/release-smoke.ts
.github/workflows/release.yml+119-39
......@@ -1,14 +1,24 @@
1name: Release Desktop
1name: Release
22
33on:
44 push:
55 tags:
66 - "v*.*.*"
7 schedule:
8 - cron: "0 9 * * *"
79 workflow_dispatch:
810 inputs:
11 channel:
12 description: "Release channel"
13 required: false
14 default: stable
15 type: choice
16 options:
17 - stable
18 - nightly
919 version:
1020 description: "Release version (for example 1.2.3 or v1.2.3)"
11 required: true
21 required: false
1222 type: string
1323
1424permissions:
......@@ -21,40 +31,21 @@ jobs:
2131 runs-on: ubuntu-24.04
2232 timeout-minutes: 10
2333 outputs:
34 release_channel: ${{ steps.release_meta.outputs.release_channel }}
2435 version: ${{ steps.release_meta.outputs.version }}
2536 tag: ${{ steps.release_meta.outputs.tag }}
37 release_name: ${{ steps.release_meta.outputs.name }}
38 short_sha: ${{ steps.release_meta.outputs.short_sha }}
39 previous_tag: ${{ steps.previous_tag.outputs.previous_tag }}
40 cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }}
2641 is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }}
2742 make_latest: ${{ steps.release_meta.outputs.make_latest }}
2843 ref: ${{ github.sha }}
2944 steps:
3045 - name: Checkout
3146 uses: actions/checkout@v6
32
33 - id: release_meta
34 name: Resolve release version
35 shell: bash
36 run: |
37 if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
38 raw="${{ github.event.inputs.version }}"
39 else
40 raw="${GITHUB_REF_NAME}"
41 fi
42
43 version="${raw#v}"
44 if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
45 echo "Invalid release version: $raw" >&2
46 exit 1
47 fi
48
49 echo "version=$version" >> "$GITHUB_OUTPUT"
50 echo "tag=v$version" >> "$GITHUB_OUTPUT"
51 if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
52 echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
53 echo "make_latest=true" >> "$GITHUB_OUTPUT"
54 else
55 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
56 echo "make_latest=false" >> "$GITHUB_OUTPUT"
57 fi
47 with:
48 fetch-depth: 0
5849
5950 - name: Setup Bun
6051 uses: oven-sh/setup-bun@v2
......@@ -69,6 +60,60 @@ jobs:
6960 - name: Install dependencies
7061 run: bun install --frozen-lockfile
7162
63 - id: release_meta
64 name: Resolve release version
65 shell: bash
66 env:
67 DISPATCH_CHANNEL: ${{ github.event.inputs.channel }}
68 DISPATCH_VERSION: ${{ github.event.inputs.version }}
69 NIGHTLY_DATE: ${{ github.run_started_at }}
70 NIGHTLY_SHA: ${{ github.sha }}
71 NIGHTLY_RUN_NUMBER: ${{ github.run_number }}
72 run: |
73 if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then
74 nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)"
75
76 node scripts/resolve-nightly-release.ts \
77 --date "$nightly_date" \
78 --run-number "$NIGHTLY_RUN_NUMBER" \
79 --sha "$NIGHTLY_SHA" \
80 --github-output
81
82 echo "release_channel=nightly" >> "$GITHUB_OUTPUT"
83 echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT"
84 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
85 echo "make_latest=false" >> "$GITHUB_OUTPUT"
86 else
87 if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
88 raw="${DISPATCH_VERSION}"
89 if [[ -z "$raw" ]]; then
90 echo "workflow_dispatch stable releases require the version input." >&2
91 exit 1
92 fi
93 else
94 raw="${GITHUB_REF_NAME}"
95 fi
96
97 version="${raw#v}"
98 if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
99 echo "Invalid release version: $raw" >&2
100 exit 1
101 fi
102
103 echo "release_channel=stable" >> "$GITHUB_OUTPUT"
104 echo "version=$version" >> "$GITHUB_OUTPUT"
105 echo "tag=v$version" >> "$GITHUB_OUTPUT"
106 echo "name=T3 Code v$version" >> "$GITHUB_OUTPUT"
107 echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT"
108 if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
109 echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
110 echo "make_latest=true" >> "$GITHUB_OUTPUT"
111 else
112 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
113 echo "make_latest=false" >> "$GITHUB_OUTPUT"
114 fi
115 fi
116
72117 - name: Lint
73118 run: bun run lint
74119
......@@ -78,6 +123,14 @@ jobs:
78123 - name: Test
79124 run: bun run test
80125
126 - id: previous_tag
127 name: Resolve previous release tag
128 run: |
129 node scripts/resolve-previous-release-tag.ts \
130 --channel "${{ steps.release_meta.outputs.release_channel }}" \
131 --current-tag "${{ steps.release_meta.outputs.tag }}" \
132 --github-output
133
81134 build:
82135 name: Build ${{ matrix.label }}
83136 needs: preflight
......@@ -206,16 +259,17 @@ jobs:
206259 "release/*.AppImage" \
207260 "release/*.exe" \
208261 "release/*.blockmap" \
209 "release/latest*.yml"; do
262 "release/*.yml"; do
210263 for file in $pattern; do
211264 cp "$file" release-publish/
212265 done
213266 done
214267
215268 if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then
216 if [[ -f release-publish/latest-mac.yml ]]; then
217 mv release-publish/latest-mac.yml "release-publish/latest-mac-${{ matrix.arch }}.yml"
218 fi
269 shopt -s nullglob
270 for manifest in release-publish/*-mac.yml; do
271 mv "$manifest" "${manifest%.yml}-${{ matrix.arch }}.yml"
272 done
219273 fi
220274
221275 - name: Upload build artifacts
......@@ -257,7 +311,7 @@ jobs:
257311 run: bun run build --filter=@t3tools/web --filter=t3
258312
259313 - name: Publish CLI package
260 run: node apps/server/scripts/cli.ts publish --tag latest --app-version "${{ needs.preflight.outputs.version }}" --verbose
314 run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose
261315
262316 release:
263317 name: Publish GitHub Release
......@@ -284,17 +338,42 @@ jobs:
284338
285339 - name: Merge macOS updater manifests
286340 run: |
287 node scripts/merge-mac-update-manifests.ts \
288 release-assets/latest-mac.yml \
289 release-assets/latest-mac-x64.yml
290 rm -f release-assets/latest-mac-x64.yml
341 shopt -s nullglob
342 for x64_manifest in release-assets/*-mac-x64.yml; do
343 arm64_manifest="${x64_manifest%-x64.yml}.yml"
344 if [[ -f "$arm64_manifest" ]]; then
345 node scripts/merge-mac-update-manifests.ts "$arm64_manifest" "$x64_manifest"
346 rm -f "$x64_manifest"
347 fi
348 done
291349
292350 - name: Publish release
351 if: needs.preflight.outputs.previous_tag != ''
352 uses: softprops/action-gh-release@v2
353 with:
354 tag_name: ${{ needs.preflight.outputs.tag }}
355 target_commitish: ${{ needs.preflight.outputs.ref }}
356 name: ${{ needs.preflight.outputs.release_name }}
357 generate_release_notes: true
358 previous_tag: ${{ needs.preflight.outputs.previous_tag }}
359 prerelease: ${{ needs.preflight.outputs.is_prerelease }}
360 make_latest: ${{ needs.preflight.outputs.make_latest }}
361 files: |
362 release-assets/*.dmg
363 release-assets/*.zip
364 release-assets/*.AppImage
365 release-assets/*.exe
366 release-assets/*.blockmap
367 release-assets/*.yml
368 fail_on_unmatched_files: true
369
370 - name: Publish first release
371 if: needs.preflight.outputs.previous_tag == ''
293372 uses: softprops/action-gh-release@v2
294373 with:
295374 tag_name: ${{ needs.preflight.outputs.tag }}
296375 target_commitish: ${{ needs.preflight.outputs.ref }}
297 name: T3 Code v${{ needs.preflight.outputs.version }}
376 name: ${{ needs.preflight.outputs.release_name }}
298377 generate_release_notes: true
299378 prerelease: ${{ needs.preflight.outputs.is_prerelease }}
300379 make_latest: ${{ needs.preflight.outputs.make_latest }}
......@@ -304,11 +383,12 @@ jobs:
304383 release-assets/*.AppImage
305384 release-assets/*.exe
306385 release-assets/*.blockmap
307 release-assets/latest*.yml
386 release-assets/*.yml
308387 fail_on_unmatched_files: true
309388
310389 finalize:
311390 name: Finalize release
391 if: needs.preflight.outputs.release_channel == 'stable'
312392 needs: [preflight, release]
313393 runs-on: ubuntu-24.04
314394 timeout-minutes: 10
.oxfmtrc.json+2-1
......@@ -10,7 +10,8 @@
1010 "*.tsbuildinfo",
1111 "**/routeTree.gen.ts",
1212 "apps/web/public/mockServiceWorker.js",
13 "apps/web/src/lib/vendor/qrcodegen.ts"
13 "apps/web/src/lib/vendor/qrcodegen.ts",
14 "*.icon/**"
1415 ],
1516 "sortPackageJson": {}
1617}
apps/desktop/scripts/electron-launcher.mjs+67-1
......@@ -6,6 +6,7 @@ import {
66 cpSync,
77 existsSync,
88 mkdirSync,
9 mkdtempSync,
910 readFileSync,
1011 readdirSync,
1112 rmSync,
......@@ -23,6 +24,9 @@ const LAUNCHER_VERSION = 1;
2324
2425const __dirname = dirname(fileURLToPath(import.meta.url));
2526export const desktopDir = resolve(__dirname, "..");
27const repoRoot = resolve(desktopDir, "..", "..");
28const defaultIconPath = join(desktopDir, "resources", "icon.icns");
29const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png");
2630
2731function setPlistString(plistPath, key, value) {
2832 const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], {
......@@ -43,6 +47,68 @@ function setPlistString(plistPath, key, value) {
4347 throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim());
4448}
4549
50function runChecked(command, args) {
51 const result = spawnSync(command, args, { encoding: "utf8" });
52 if (result.status === 0) {
53 return;
54 }
55
56 const details = [result.stdout, result.stderr].filter(Boolean).join("\n");
57 throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim());
58}
59
60function ensureDevelopmentIconIcns(runtimeDir) {
61 const generatedIconPath = join(runtimeDir, "icon-dev.icns");
62 mkdirSync(runtimeDir, { recursive: true });
63
64 if (!existsSync(developmentMacIconPngPath)) {
65 return defaultIconPath;
66 }
67
68 const sourceMtimeMs = statSync(developmentMacIconPngPath).mtimeMs;
69 if (existsSync(generatedIconPath) && statSync(generatedIconPath).mtimeMs >= sourceMtimeMs) {
70 return generatedIconPath;
71 }
72
73 const iconsetRoot = mkdtempSync(join(runtimeDir, "dev-iconset-"));
74 const iconsetDir = join(iconsetRoot, "icon.iconset");
75 mkdirSync(iconsetDir, { recursive: true });
76
77 try {
78 for (const size of [16, 32, 128, 256, 512]) {
79 runChecked("sips", [
80 "-z",
81 String(size),
82 String(size),
83 developmentMacIconPngPath,
84 "--out",
85 join(iconsetDir, `icon_${size}x${size}.png`),
86 ]);
87
88 const retinaSize = size * 2;
89 runChecked("sips", [
90 "-z",
91 String(retinaSize),
92 String(retinaSize),
93 developmentMacIconPngPath,
94 "--out",
95 join(iconsetDir, `icon_${size}x${size}@2x.png`),
96 ]);
97 }
98
99 runChecked("iconutil", ["-c", "icns", iconsetDir, "-o", generatedIconPath]);
100 return generatedIconPath;
101 } catch (error) {
102 console.warn(
103 "[desktop-launcher] Failed to generate dev macOS icon, falling back to default icon.",
104 error,
105 );
106 return defaultIconPath;
107 } finally {
108 rmSync(iconsetRoot, { recursive: true, force: true });
109 }
110}
111
46112function patchMainBundleInfoPlist(appBundlePath, iconPath) {
47113 const infoPlistPath = join(appBundlePath, "Contents", "Info.plist");
48114 setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME);
......@@ -102,7 +168,7 @@ function buildMacLauncher(electronBinaryPath) {
102168 const runtimeDir = join(desktopDir, ".electron-runtime");
103169 const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`);
104170 const targetBinaryPath = join(targetAppBundlePath, "Contents", "MacOS", "Electron");
105 const iconPath = join(desktopDir, "resources", "icon.icns");
171 const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath;
106172 const metadataPath = join(runtimeDir, "metadata.json");
107173
108174 mkdirSync(runtimeDir, { recursive: true });
apps/desktop/src/appBranding.test.ts created+47
......@@ -0,0 +1,47 @@
1import { describe, expect, it } from "vitest";
2
3import { resolveDesktopAppBranding, resolveDesktopAppStageLabel } from "./appBranding";
4
5describe("resolveDesktopAppStageLabel", () => {
6 it("uses Dev in desktop development", () => {
7 expect(
8 resolveDesktopAppStageLabel({
9 isDevelopment: true,
10 appVersion: "0.0.17-nightly.20260414.1",
11 }),
12 ).toBe("Dev");
13 });
14
15 it("uses Nightly for packaged nightly builds", () => {
16 expect(
17 resolveDesktopAppStageLabel({
18 isDevelopment: false,
19 appVersion: "0.0.17-nightly.20260414.1",
20 }),
21 ).toBe("Nightly");
22 });
23
24 it("uses Alpha for packaged stable builds", () => {
25 expect(
26 resolveDesktopAppStageLabel({
27 isDevelopment: false,
28 appVersion: "0.0.17",
29 }),
30 ).toBe("Alpha");
31 });
32});
33
34describe("resolveDesktopAppBranding", () => {
35 it("returns a complete desktop branding payload", () => {
36 expect(
37 resolveDesktopAppBranding({
38 isDevelopment: false,
39 appVersion: "0.0.17-nightly.20260414.1",
40 }),
41 ).toEqual({
42 baseName: "T3 Code",
43 stageLabel: "Nightly",
44 displayName: "T3 Code (Nightly)",
45 });
46 });
47});
apps/desktop/src/appBranding.ts created+27
......@@ -0,0 +1,27 @@
1import type { DesktopAppBranding, DesktopAppStageLabel } from "@t3tools/contracts";
2
3const APP_BASE_NAME = "T3 Code";
4const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/;
5
6export function resolveDesktopAppStageLabel(input: {
7 readonly isDevelopment: boolean;
8 readonly appVersion: string;
9}): DesktopAppStageLabel {
10 if (input.isDevelopment) {
11 return "Dev";
12 }
13
14 return NIGHTLY_VERSION_PATTERN.test(input.appVersion) ? "Nightly" : "Alpha";
15}
16
17export function resolveDesktopAppBranding(input: {
18 readonly isDevelopment: boolean;
19 readonly appVersion: string;
20}): DesktopAppBranding {
21 const stageLabel = resolveDesktopAppStageLabel(input);
22 return {
23 baseName: APP_BASE_NAME,
24 stageLabel,
25 displayName: `${APP_BASE_NAME} (${stageLabel})`,
26 };
27}
apps/desktop/src/desktopSettings.test.ts+20
......@@ -8,6 +8,7 @@ import {
88 DEFAULT_DESKTOP_SETTINGS,
99 readDesktopSettings,
1010 setDesktopServerExposurePreference,
11 setDesktopUpdateChannelPreference,
1112 writeDesktopSettings,
1213} from "./desktopSettings";
1314
......@@ -35,10 +36,12 @@ describe("desktopSettings", () => {
3536
3637 writeDesktopSettings(settingsPath, {
3738 serverExposureMode: "network-accessible",
39 updateChannel: "latest",
3840 });
3941
4042 expect(readDesktopSettings(settingsPath)).toEqual({
4143 serverExposureMode: "network-accessible",
44 updateChannel: "latest",
4245 });
4346 });
4447
......@@ -47,11 +50,28 @@ describe("desktopSettings", () => {
4750 setDesktopServerExposurePreference(
4851 {
4952 serverExposureMode: "local-only",
53 updateChannel: "latest",
5054 },
5155 "network-accessible",
5256 ),
5357 ).toEqual({
5458 serverExposureMode: "network-accessible",
59 updateChannel: "latest",
60 });
61 });
62
63 it("persists the requested nightly update channel", () => {
64 expect(
65 setDesktopUpdateChannelPreference(
66 {
67 serverExposureMode: "local-only",
68 updateChannel: "latest",
69 },
70 "nightly",
71 ),
72 ).toEqual({
73 serverExposureMode: "local-only",
74 updateChannel: "nightly",
5575 });
5676 });
5777
apps/desktop/src/desktopSettings.ts+17-1
......@@ -1,13 +1,15 @@
11import * as FS from "node:fs";
22import * as Path from "node:path";
3import type { DesktopServerExposureMode } from "@t3tools/contracts";
3import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@t3tools/contracts";
44
55export interface DesktopSettings {
66 readonly serverExposureMode: DesktopServerExposureMode;
7 readonly updateChannel: DesktopUpdateChannel;
78}
89
910export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
1011 serverExposureMode: "local-only",
12 updateChannel: "latest",
1113};
1214
1315export function setDesktopServerExposurePreference(
......@@ -22,6 +24,18 @@ export function setDesktopServerExposurePreference(
2224 };
2325}
2426
27export function setDesktopUpdateChannelPreference(
28 settings: DesktopSettings,
29 requestedChannel: DesktopUpdateChannel,
30): DesktopSettings {
31 return settings.updateChannel === requestedChannel
32 ? settings
33 : {
34 ...settings,
35 updateChannel: requestedChannel,
36 };
37}
38
2539export function readDesktopSettings(settingsPath: string): DesktopSettings {
2640 try {
2741 if (!FS.existsSync(settingsPath)) {
......@@ -31,11 +45,13 @@ export function readDesktopSettings(settingsPath: string): DesktopSettings {
3145 const raw = FS.readFileSync(settingsPath, "utf8");
3246 const parsed = JSON.parse(raw) as {
3347 readonly serverExposureMode?: unknown;
48 readonly updateChannel?: unknown;
3449 };
3550
3651 return {
3752 serverExposureMode:
3853 parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",
54 updateChannel: parsed.updateChannel === "nightly" ? "nightly" : "latest",
3955 };
4056 } catch {
4157 return DEFAULT_DESKTOP_SETTINGS;
apps/desktop/src/main.ts+91-13
......@@ -22,8 +22,10 @@ import type { MenuItemConstructorOptions } from "electron";
2222import type {
2323 ClientSettings,
2424 DesktopTheme,
25 DesktopAppBranding,
2526 DesktopServerExposureMode,
2627 DesktopServerExposureState,
28 DesktopUpdateChannel,
2729 PersistedSavedEnvironmentRecord,
2830 DesktopUpdateActionResult,
2931 DesktopUpdateCheckResult,
......@@ -39,6 +41,7 @@ import {
3941 DEFAULT_DESKTOP_SETTINGS,
4042 readDesktopSettings,
4143 setDesktopServerExposurePreference,
44 setDesktopUpdateChannelPreference,
4245 writeDesktopSettings,
4346} from "./desktopSettings";
4447import {
......@@ -69,6 +72,7 @@ import {
6972 reduceDesktopUpdateStateOnUpdateAvailable,
7073} from "./updateMachine";
7174import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";
75import { resolveDesktopAppBranding } from "./appBranding";
7276
7377syncShellEnvironment();
7478
......@@ -80,9 +84,11 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
8084const MENU_ACTION_CHANNEL = "desktop:menu-action";
8185const UPDATE_STATE_CHANNEL = "desktop:update-state";
8286const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
87const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
8388const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
8489const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
8590const UPDATE_CHECK_CHANNEL = "desktop:update-check";
91const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
8692const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
8793const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
8894const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
......@@ -101,7 +107,11 @@ const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments
101107const DESKTOP_SCHEME = "t3";
102108const ROOT_DIR = Path.resolve(__dirname, "../../..");
103109const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
104const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
110const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({
111 isDevelopment,
112 appVersion: app.getVersion(),
113});
114const APP_DISPLAY_NAME = desktopAppBranding.displayName;
105115const APP_USER_MODEL_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code";
106116const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop";
107117const LINUX_WM_CLASS = isDevelopment ? "t3code-dev" : "t3code";
......@@ -116,8 +126,6 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex");
116126const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json");
117127const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;
118128const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
119const DESKTOP_UPDATE_CHANNEL = "latest";
120const DESKTOP_UPDATE_ALLOW_PRERELEASE = false;
121129const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
122130const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const;
123131const TITLEBAR_HEIGHT = 40;
......@@ -167,7 +175,11 @@ const desktopRuntimeInfo = resolveDesktopRuntimeInfo({
167175 runningUnderArm64Translation: app.runningUnderARM64Translation === true,
168176});
169177const initialUpdateState = (): DesktopUpdateState =>
170 createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo);
178 createInitialDesktopUpdateState(
179 app.getVersion(),
180 desktopRuntimeInfo,
181 desktopSettings.updateChannel,
182 );
171183
172184function logTimestamp(): string {
173185 return new Date().toISOString();
......@@ -975,6 +987,18 @@ function resolveResourcePath(fileName: string): string | null {
975987}
976988
977989function resolveIconPath(ext: "ico" | "icns" | "png"): string | null {
990 if (isDevelopment && process.platform === "darwin" && ext === "png") {
991 const developmentDockIconPath = Path.join(
992 ROOT_DIR,
993 "assets",
994 "dev",
995 "blueprint-macos-1024.png",
996 );
997 if (FS.existsSync(developmentDockIconPath)) {
998 return developmentDockIconPath;
999 }
1000 }
1001
9781002 return resolveResourcePath(`icon.${ext}`);
9791003}
9801004
......@@ -1074,6 +1098,26 @@ function setUpdateState(patch: Partial<DesktopUpdateState>): void {
10741098 emitUpdateState();
10751099}
10761100
1101function createBaseUpdateState(
1102 channel: DesktopUpdateChannel,
1103 enabled: boolean,
1104): DesktopUpdateState {
1105 return {
1106 ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo, channel),
1107 enabled,
1108 status: enabled ? "idle" : "disabled",
1109 };
1110}
1111
1112function applyAutoUpdaterChannel(channel: DesktopUpdateChannel): void {
1113 autoUpdater.channel = channel;
1114 autoUpdater.allowPrerelease = channel === "nightly";
1115 autoUpdater.allowDowngrade = channel === "nightly";
1116 console.info(
1117 `[desktop-updater] Using update channel '${channel}' (allowPrerelease=${channel === "nightly"}).`,
1118 );
1119}
1120
10771121function shouldEnableAutoUpdates(): boolean {
10781122 const hasUpdateFeedConfig =
10791123 readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES);
......@@ -1193,11 +1237,7 @@ function configureAutoUpdater(): void {
11931237 }
11941238
11951239 const enabled = shouldEnableAutoUpdates();
1196 setUpdateState({
1197 ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo),
1198 enabled,
1199 status: enabled ? "idle" : "disabled",
1200 });
1240 setUpdateState(createBaseUpdateState(desktopSettings.updateChannel, enabled));
12011241 if (!enabled) {
12021242 return;
12031243 }
......@@ -1205,10 +1245,7 @@ function configureAutoUpdater(): void {
12051245
12061246 autoUpdater.autoDownload = false;
12071247 autoUpdater.autoInstallOnAppQuit = false;
1208 // Keep alpha branding, but force all installs onto the stable update track.
1209 autoUpdater.channel = DESKTOP_UPDATE_CHANNEL;
1210 autoUpdater.allowPrerelease = DESKTOP_UPDATE_ALLOW_PRERELEASE;
1211 autoUpdater.allowDowngrade = false;
1248 applyAutoUpdaterChannel(desktopSettings.updateChannel);
12121249 autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo);
12131250 let lastLoggedDownloadMilestone = -1;
12141251
......@@ -1486,6 +1523,11 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
14861523}
14871524
14881525function registerIpcHandlers(): void {
1526 ipcMain.removeAllListeners(GET_APP_BRANDING_CHANNEL);
1527 ipcMain.on(GET_APP_BRANDING_CHANNEL, (event) => {
1528 event.returnValue = desktopAppBranding;
1529 });
1530
14891531 ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
14901532 ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => {
14911533 event.returnValue = {
......@@ -1713,6 +1755,42 @@ function registerIpcHandlers(): void {
17131755 ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL);
17141756 ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState);
17151757
1758 ipcMain.removeHandler(UPDATE_SET_CHANNEL_CHANNEL);
1759 ipcMain.handle(UPDATE_SET_CHANNEL_CHANNEL, async (_event, rawChannel: unknown) => {
1760 if (rawChannel !== "latest" && rawChannel !== "nightly") {
1761 throw new Error("Invalid desktop update channel input.");
1762 }
1763 if (updateCheckInFlight || updateDownloadInFlight || updateInstallInFlight) {
1764 throw new Error("Cannot change update tracks while an update action is in progress.");
1765 }
1766
1767 const nextChannel = rawChannel as DesktopUpdateChannel;
1768 if (nextChannel === desktopSettings.updateChannel) {
1769 return updateState;
1770 }
1771
1772 desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel);
1773 writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings);
1774
1775 const enabled = shouldEnableAutoUpdates();
1776 setUpdateState(createBaseUpdateState(nextChannel, enabled));
1777
1778 if (!enabled || !updaterConfigured) {
1779 return updateState;
1780 }
1781
1782 applyAutoUpdaterChannel(nextChannel);
1783 const allowDowngrade = autoUpdater.allowDowngrade;
1784 // An explicit channel switch should allow the immediate nightly->stable rollback path.
1785 autoUpdater.allowDowngrade = true;
1786 try {
1787 await checkForUpdates("channel-change");
1788 } finally {
1789 autoUpdater.allowDowngrade = allowDowngrade;
1790 }
1791 return updateState;
1792 });
1793
17161794 ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL);
17171795 ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => {
17181796 const result = await downloadAvailableUpdate();
apps/desktop/src/preload.ts+10
......@@ -9,9 +9,11 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
99const MENU_ACTION_CHANNEL = "desktop:menu-action";
1010const UPDATE_STATE_CHANNEL = "desktop:update-state";
1111const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
12const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
1213const UPDATE_CHECK_CHANNEL = "desktop:update-check";
1314const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
1415const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
16const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
1517const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
1618const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
1719const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
......@@ -24,6 +26,13 @@ const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
2426const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
2527
2628contextBridge.exposeInMainWorld("desktopBridge", {
29 getAppBranding: () => {
30 const result = ipcRenderer.sendSync(GET_APP_BRANDING_CHANNEL);
31 if (typeof result !== "object" || result === null) {
32 return null;
33 }
34 return result as ReturnType<DesktopBridge["getAppBranding"]>;
35 },
2736 getLocalEnvironmentBootstrap: () => {
2837 const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
2938 if (typeof result !== "object" || result === null) {
......@@ -61,6 +70,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
6170 };
6271 },
6372 getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL),
73 setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel),
6474 checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL),
6575 downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL),
6676 installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL),
apps/desktop/src/updateMachine.test.ts+7-6
......@@ -23,7 +23,7 @@ describe("updateMachine", () => {
2323 it("clears transient errors when a check starts", () => {
2424 const state = reduceDesktopUpdateStateOnCheckStart(
2525 {
26 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
26 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
2727 enabled: true,
2828 status: "error",
2929 message: "network",
......@@ -42,7 +42,7 @@ describe("updateMachine", () => {
4242 it("records a check failure without exposing an action", () => {
4343 const state = reduceDesktopUpdateStateOnCheckFailure(
4444 {
45 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
45 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
4646 enabled: true,
4747 status: "checking",
4848 },
......@@ -58,7 +58,7 @@ describe("updateMachine", () => {
5858 it("preserves available version on download failure for retry", () => {
5959 const state = reduceDesktopUpdateStateOnDownloadFailure(
6060 {
61 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
61 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
6262 enabled: true,
6363 status: "downloading",
6464 availableVersion: "1.1.0",
......@@ -76,7 +76,7 @@ describe("updateMachine", () => {
7676 it("transitions to downloaded and then preserves install retry state", () => {
7777 const downloaded = reduceDesktopUpdateStateOnDownloadComplete(
7878 {
79 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
79 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
8080 enabled: true,
8181 status: "downloading",
8282 availableVersion: "1.1.0",
......@@ -98,7 +98,7 @@ describe("updateMachine", () => {
9898 it("clears stale download state when no update is available", () => {
9999 const state = reduceDesktopUpdateStateOnNoUpdate(
100100 {
101 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
101 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
102102 enabled: true,
103103 status: "error",
104104 availableVersion: "1.1.0",
......@@ -120,7 +120,7 @@ describe("updateMachine", () => {
120120 it("tracks available, download start, and progress cleanly", () => {
121121 const available = reduceDesktopUpdateStateOnUpdateAvailable(
122122 {
123 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
123 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
124124 enabled: true,
125125 status: "checking",
126126 },
......@@ -131,6 +131,7 @@ describe("updateMachine", () => {
131131 const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5);
132132
133133 expect(available.status).toBe("available");
134 expect(available.channel).toBe("latest");
134135 expect(downloading.status).toBe("downloading");
135136 expect(downloading.downloadPercent).toBe(0);
136137 expect(progress.downloadPercent).toBe(55.5);
apps/desktop/src/updateMachine.ts+7-1
......@@ -1,14 +1,20 @@
1import type { DesktopRuntimeInfo, DesktopUpdateState } from "@t3tools/contracts";
1import type {
2 DesktopRuntimeInfo,
3 DesktopUpdateChannel,
4 DesktopUpdateState,
5} from "@t3tools/contracts";
26
37import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState";
48
59export function createInitialDesktopUpdateState(
610 currentVersion: string,
711 runtimeInfo: DesktopRuntimeInfo,
12 channel: DesktopUpdateChannel,
813): DesktopUpdateState {
914 return {
1015 enabled: false,
1116 status: "disabled",
17 channel,
1218 currentVersion,
1319 hostArch: runtimeInfo.hostArch,
1420 appArch: runtimeInfo.appArch,
apps/desktop/src/updateState.test.ts+1
......@@ -11,6 +11,7 @@ import {
1111const baseState: DesktopUpdateState = {
1212 enabled: true,
1313 status: "idle",
14 channel: "latest",
1415 currentVersion: "1.0.0",
1516 hostArch: "x64",
1617 appArch: "x64",
apps/web/src/branding.test.ts created+37
......@@ -0,0 +1,37 @@
1import { afterEach, describe, expect, it, vi } from "vitest";
2
3const originalWindow = globalThis.window;
4
5afterEach(() => {
6 vi.resetModules();
7
8 if (originalWindow === undefined) {
9 Reflect.deleteProperty(globalThis, "window");
10 return;
11 }
12
13 globalThis.window = originalWindow;
14});
15
16describe("branding", () => {
17 it("uses injected desktop branding when available", async () => {
18 Object.defineProperty(globalThis, "window", {
19 configurable: true,
20 value: {
21 desktopBridge: {
22 getAppBranding: () => ({
23 baseName: "T3 Code",
24 stageLabel: "Nightly",
25 displayName: "T3 Code (Nightly)",
26 }),
27 },
28 },
29 });
30
31 const branding = await import("./branding");
32
33 expect(branding.APP_BASE_NAME).toBe("T3 Code");
34 expect(branding.APP_STAGE_LABEL).toBe("Nightly");
35 expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)");
36 });
37});
apps/web/src/branding.ts+17-3
......@@ -1,4 +1,18 @@
1export const APP_BASE_NAME = "T3 Code";
2export const APP_STAGE_LABEL = import.meta.env.DEV ? "Dev" : "Alpha";
3export const APP_DISPLAY_NAME = `${APP_BASE_NAME} (${APP_STAGE_LABEL})`;
1import type { DesktopAppBranding } from "@t3tools/contracts";
2
3function readInjectedDesktopAppBranding(): DesktopAppBranding | null {
4 if (typeof window === "undefined") {
5 return null;
6 }
7
8 return window.desktopBridge?.getAppBranding?.() ?? null;
9}
10
11const injectedDesktopAppBranding = readInjectedDesktopAppBranding();
12
13export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code";
14export const APP_STAGE_LABEL =
15 injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "Alpha");
16export const APP_DISPLAY_NAME =
17 injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`;
418export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0";
apps/web/src/components/desktopUpdate.logic.test.ts+1
......@@ -17,6 +17,7 @@ import {
1717const baseState: DesktopUpdateState = {
1818 enabled: true,
1919 status: "idle",
20 channel: "latest",
2021 currentVersion: "1.0.0",
2122 hostArch: "x64",
2223 appArch: "x64",
apps/web/src/components/settings/SettingsPanels.browser.tsx+10
......@@ -7,6 +7,7 @@ import {
77 DEFAULT_SERVER_SETTINGS,
88 EnvironmentId,
99 type DesktopBridge,
10 type DesktopUpdateChannel,
1011 type DesktopUpdateState,
1112 type LocalApi,
1213 type ServerConfig,
......@@ -258,10 +259,12 @@ function makeClientSession(input: {
258259const createDesktopBridgeStub = (overrides?: {
259260 readonly serverExposureState?: Awaited<ReturnType<DesktopBridge["getServerExposureState"]>>;
260261 readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"];
262 readonly setUpdateChannel?: DesktopBridge["setUpdateChannel"];
261263}): DesktopBridge => {
262264 const idleUpdateState: DesktopUpdateState = {
263265 enabled: false,
264266 status: "idle",
267 channel: "latest",
265268 currentVersion: "0.0.0-test",
266269 hostArch: "arm64",
267270 appArch: "arm64",
......@@ -276,6 +279,7 @@ const createDesktopBridgeStub = (overrides?: {
276279 };
277280
278281 return {
282 getAppBranding: vi.fn().mockReturnValue(null),
279283 getLocalEnvironmentBootstrap: () => ({
280284 label: "Local environment",
281285 httpBaseUrl: "http://127.0.0.1:3773",
......@@ -310,6 +314,12 @@ const createDesktopBridgeStub = (overrides?: {
310314 openExternal: vi.fn().mockResolvedValue(true),
311315 onMenuAction: () => () => {},
312316 getUpdateState: vi.fn().mockResolvedValue(idleUpdateState),
317 setUpdateChannel:
318 overrides?.setUpdateChannel ??
319 vi.fn().mockImplementation(async (channel: DesktopUpdateChannel) => ({
320 ...idleUpdateState,
321 channel,
322 })),
313323 checkForUpdate: vi.fn().mockResolvedValue({ checked: false, state: idleUpdateState }),
314324 downloadUpdate: vi
315325 .fn()
apps/web/src/components/settings/SettingsPanels.tsx+88-21
......@@ -12,6 +12,7 @@ import { useQueryClient } from "@tanstack/react-query";
1212import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";
1313import {
1414 PROVIDER_DISPLAY_NAMES,
15 type DesktopUpdateChannel,
1516 type ScopedThreadRef,
1617 type ProviderKind,
1718 type ServerProvider,
......@@ -232,8 +233,42 @@ function AboutVersionTitle() {
232233function AboutVersionSection() {
233234 const queryClient = useQueryClient();
234235 const updateStateQuery = useDesktopUpdateState();
236 const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false);
235237
236238 const updateState = updateStateQuery.data ?? null;
239 const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge);
240 const selectedUpdateChannel = updateState?.channel ?? "latest";
241
242 const handleUpdateChannelChange = useCallback(
243 (channel: DesktopUpdateChannel) => {
244 const bridge = window.desktopBridge;
245 if (
246 !bridge ||
247 typeof bridge.setUpdateChannel !== "function" ||
248 channel === selectedUpdateChannel
249 ) {
250 return;
251 }
252
253 setIsChangingUpdateChannel(true);
254 void bridge
255 .setUpdateChannel(channel)
256 .then((state) => {
257 setDesktopUpdateStateQueryData(queryClient, state);
258 })
259 .catch((error: unknown) => {
260 toastManager.add({
261 type: "error",
262 title: "Could not change update track",
263 description: error instanceof Error ? error.message : "Update track change failed.",
264 });
265 })
266 .finally(() => {
267 setIsChangingUpdateChannel(false);
268 });
269 },
270 [queryClient, selectedUpdateChannel],
271 );
237272
238273 const handleButtonClick = useCallback(() => {
239274 const bridge = window.desktopBridge;
......@@ -323,27 +358,59 @@ function AboutVersionSection() {
323358 : "Current version of the application.";
324359
325360 return (
326 <SettingsRow
327 title={<AboutVersionTitle />}
328 description={description}
329 control={
330 <Tooltip>
331 <TooltipTrigger
332 render={
333 <Button
334 size="xs"
335 variant={action === "install" ? "default" : "outline"}
336 disabled={buttonDisabled}
337 onClick={handleButtonClick}
338 >
339 {buttonLabel}
340 </Button>
341 }
342 />
343 {buttonTooltip ? <TooltipPopup>{buttonTooltip}</TooltipPopup> : null}
344 </Tooltip>
345 }
346 />
361 <>
362 <SettingsRow
363 title={<AboutVersionTitle />}
364 description={description}
365 control={
366 <Tooltip>
367 <TooltipTrigger
368 render={
369 <Button
370 size="xs"
371 variant={action === "install" ? "default" : "outline"}
372 disabled={buttonDisabled}
373 onClick={handleButtonClick}
374 >
375 {buttonLabel}
376 </Button>
377 }
378 />
379 {buttonTooltip ? <TooltipPopup>{buttonTooltip}</TooltipPopup> : null}
380 </Tooltip>
381 }
382 />
383 <SettingsRow
384 title="Update track"
385 description="Stable follows full releases. Nightly follows the nightly desktop channel and can switch back to stable immediately."
386 control={
387 <Select
388 value={selectedUpdateChannel}
389 onValueChange={(value) => {
390 handleUpdateChannelChange(value as DesktopUpdateChannel);
391 }}
392 >
393 <SelectTrigger
394 className="w-full sm:w-40"
395 aria-label="Update track"
396 disabled={!hasDesktopBridge || isChangingUpdateChannel}
397 >
398 <SelectValue>
399 {selectedUpdateChannel === "nightly" ? "Nightly" : "Stable"}
400 </SelectValue>
401 </SelectTrigger>
402 <SelectPopup align="end" alignItemWithTrigger={false}>
403 <SelectItem hideIndicator value="latest">
404 Stable
405 </SelectItem>
406 <SelectItem hideIndicator value="nightly">
407 Nightly
408 </SelectItem>
409 </SelectPopup>
410 </Select>
411 }
412 />
413 </>
347414 );
348415}
349416
apps/web/src/lib/desktopUpdateReactQuery.test.ts+1
......@@ -10,6 +10,7 @@ import {
1010const baseState: DesktopUpdateState = {
1111 enabled: true,
1212 status: "idle",
13 channel: "latest",
1314 currentVersion: "1.0.0",
1415 hostArch: "x64",
1516 appArch: "x64",
apps/web/src/localApi.test.ts+4
......@@ -157,6 +157,7 @@ function createLocalStorageStub(): Storage {
157157
158158function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge {
159159 return {
160 getAppBranding: () => null,
160161 getLocalEnvironmentBootstrap: () => null,
161162 getClientSettings: async () => null,
162163 setClientSettings: async () => undefined,
......@@ -184,6 +185,9 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg
184185 getUpdateState: async () => {
185186 throw new Error("getUpdateState not implemented in test");
186187 },
188 setUpdateChannel: async () => {
189 throw new Error("setUpdateChannel not implemented in test");
190 },
187191 checkForUpdate: async () => {
188192 throw new Error("checkForUpdate not implemented in test");
189193 },
assets/dev/blueprint-icon-composer.icon/Assets/T3.svg created+3
......@@ -0,0 +1,3 @@
1<svg width="77" height="47" viewBox="0 0 77 47" fill="none" xmlns="http://www.w3.org/2000/svg">
2<path d="M14.56 45.5V8.58H1.69082e-06V-3.09944e-06H39.65V8.58H25.09V45.5H14.56ZM57.8454 46.28C54.6821 46.28 51.5404 45.8683 48.4204 45.045C45.3004 44.1783 42.6571 42.965 40.4904 41.405L44.5854 33.345C46.3188 34.6017 48.3338 35.5983 50.6304 36.335C52.9271 37.0717 55.2454 37.44 57.5854 37.44C60.2288 37.44 62.3088 36.92 63.8254 35.88C65.3421 34.84 66.1004 33.41 66.1004 31.59C66.1004 29.8567 65.4288 28.4917 64.0854 27.495C62.7421 26.4983 60.5754 26 57.5854 26H52.7754V19.045L65.4504 4.68L66.6204 8.45H42.7654V-3.09944e-06H74.6154V6.825L62.0054 21.19L56.6754 18.135H59.7304C65.3204 18.135 69.5454 19.3917 72.4054 21.905C75.2654 24.4183 76.6954 27.6467 76.6954 31.59C76.6954 34.1467 76.0238 36.5517 74.6804 38.805C73.3371 41.015 71.2788 42.8133 68.5054 44.2C65.7321 45.5867 62.1788 46.28 57.8454 46.28Z" fill="white"/>
3</svg>
assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg created
Binary files /dev/null and b/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg differ
assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png created
Binary files /dev/null and b/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png differ
assets/dev/blueprint-icon-composer.icon/icon.json created+47
......@@ -0,0 +1,47 @@
1{
2 "fill": {
3 "solid": "display-p3:0.00000,0.00000,0.00000,1.00000"
4 },
5 "groups": [
6 {
7 "layers": [
8 {
9 "blend-mode": "normal",
10 "fill": "automatic",
11 "glass": true,
12 "hidden": false,
13 "image-name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png",
14 "name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871",
15 "position": {
16 "scale": 1.05,
17 "translation-in-points": [0, 0]
18 }
19 },
20 {
21 "image-name": "T3.svg",
22 "name": "T3",
23 "position": {
24 "scale": 10,
25 "translation-in-points": [0, 0]
26 }
27 },
28 {
29 "hidden": false,
30 "image-name": "Texturelabs_Paper_381XL.jpg",
31 "name": "Texturelabs_Paper_381XL"
32 }
33 ],
34 "shadow": {
35 "kind": "neutral",
36 "opacity": 0.5
37 },
38 "translucency": {
39 "enabled": true,
40 "value": 0.5
41 }
42 }
43 ],
44 "supported-platforms": {
45 "squares": ["iOS", "macOS"]
46 }
47}
assets/dev/blueprint-ios-1024.png
Binary files a/assets/dev/blueprint-ios-1024.png and b/assets/dev/blueprint-ios-1024.png differ
assets/dev/blueprint-macos-1024.png
Binary files a/assets/dev/blueprint-macos-1024.png and b/assets/dev/blueprint-macos-1024.png differ
assets/dev/blueprint-universal-1024.png
Binary files a/assets/dev/blueprint-universal-1024.png and b/assets/dev/blueprint-universal-1024.png differ
assets/dev/blueprint-web-apple-touch-180.png
Binary files a/assets/dev/blueprint-web-apple-touch-180.png and b/assets/dev/blueprint-web-apple-touch-180.png differ
assets/dev/blueprint-web-favicon-16x16.png
Binary files a/assets/dev/blueprint-web-favicon-16x16.png and b/assets/dev/blueprint-web-favicon-16x16.png differ
assets/dev/blueprint-web-favicon-32x32.png
Binary files a/assets/dev/blueprint-web-favicon-32x32.png and b/assets/dev/blueprint-web-favicon-32x32.png differ
assets/dev/blueprint-web-favicon.ico
Binary files a/assets/dev/blueprint-web-favicon.ico and b/assets/dev/blueprint-web-favicon.ico differ
assets/dev/blueprint-windows.ico
Binary files a/assets/dev/blueprint-windows.ico and b/assets/dev/blueprint-windows.ico differ
assets/nightly/blueprint-ios-1024.png created
Binary files /dev/null and b/assets/nightly/blueprint-ios-1024.png differ
assets/nightly/blueprint-macos-1024.png created
Binary files /dev/null and b/assets/nightly/blueprint-macos-1024.png differ
assets/nightly/blueprint-universal-1024.png created
Binary files /dev/null and b/assets/nightly/blueprint-universal-1024.png differ
assets/nightly/blueprint-web-apple-touch-180.png created
Binary files /dev/null and b/assets/nightly/blueprint-web-apple-touch-180.png differ
assets/nightly/blueprint-web-favicon-16x16.png created
Binary files /dev/null and b/assets/nightly/blueprint-web-favicon-16x16.png differ
assets/nightly/blueprint-web-favicon-32x32.png created
Binary files /dev/null and b/assets/nightly/blueprint-web-favicon-32x32.png differ
assets/nightly/blueprint-web-favicon.ico created
Binary files /dev/null and b/assets/nightly/blueprint-web-favicon.ico differ
assets/nightly/blueprint-windows.ico created
Binary files /dev/null and b/assets/nightly/blueprint-windows.ico differ
docs/release.md+37-12
......@@ -1,23 +1,47 @@
11# Release Checklist
22
3This document covers how to run desktop releases from one tag, first without signing, then with signing.
3This document covers the unified release workflow for stable and nightly desktop releases.
44
55## What the workflow does
66
7- Trigger: push tag matching `v*.*.*`.
7- Workflow: `.github/workflows/release.yml`
8- Triggers:
9 - push tag matching `v*.*.*` for stable releases
10 - scheduled nightly at `09:00 UTC`
11 - manual `workflow_dispatch` for either channel
812- Runs quality gates first: lint, typecheck, test.
9- Builds four artifacts in parallel:
13- Builds four artifacts in parallel for both channels:
1014 - macOS `arm64` DMG
1115 - macOS `x64` DMG
1216 - Linux `x64` AppImage
1317 - Windows `x64` NSIS installer
1418- Publishes one GitHub Release with all produced files.
15 - Versions with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases.
16 - Only plain `X.Y.Z` releases are marked as the repository's latest release.
17- Includes Electron auto-update metadata (for example `latest*.yml` and `*.blockmap`) in release assets.
18- Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing.
19 - Stable tags with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases.
20 - Only plain stable `X.Y.Z` releases are marked as the repository's latest release.
21 - Nightly runs are always GitHub prereleases and never marked latest.
22 - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag.
23- Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets.
24- Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing from the same workflow file:
25 - stable releases publish npm dist-tag `latest`
26 - nightly releases publish npm dist-tag `nightly`
1927- Signing is optional and auto-detected per platform from secrets.
2028
29## Nightly builds
30
31- Workflow: `.github/workflows/release.yml`
32- Triggers:
33 - scheduled every day at `09:00 UTC`
34 - manual `workflow_dispatch` with `channel=nightly`
35- Runs the same desktop quality gates and artifact matrix as the tagged release flow.
36- Publishes a GitHub prerelease only:
37 - tag format: `nightly-vX.Y.Z-nightly.YYYYMMDD.<run_number>`
38 - release name includes the short commit SHA
39 - `make_latest` is always `false`
40- Uses the current `apps/desktop/package.json` semver core (`X.Y.Z`) as the nightly base, then appends a nightly prerelease suffix.
41- Publishes Electron auto-update metadata to the dedicated `nightly` updater channel, so desktop users can opt into that track independently from stable.
42- Publishes the CLI package (`apps/server`, npm package `t3`) to the `nightly` npm dist-tag using the same nightly version.
43- Does not commit version bumps back to `main`.
44
2145## Desktop auto-update notes
2246
2347- Runtime updater: `electron-updater` in `apps/desktop/src/main.ts`.
......@@ -34,15 +58,15 @@ This document covers how to run desktop releases from one tag, first without sig
3458 - the app forwards it as an `Authorization: Bearer <token>` request header for updater HTTP calls.
3559- Required release assets for updater:
3660 - platform installers (`.exe`, `.dmg`, `.AppImage`, plus macOS `.zip` for Squirrel.Mac update payloads)
37 - `latest*.yml` metadata
61 - channel metadata: `latest*.yml` for stable releases, `nightly*.yml` for nightly releases
3862 - `*.blockmap` files (used for differential downloads)
3963- macOS metadata note:
40 - `electron-updater` reads `latest-mac.yml` for both Intel and Apple Silicon.
41 - The workflow merges the per-arch mac manifests into one `latest-mac.yml` before publishing the GitHub Release.
64 - `electron-updater` reads `latest-mac.yml` on stable and `nightly-mac.yml` on nightly, for both Intel and Apple Silicon.
65 - The workflow merges the per-arch mac manifests into one channel-specific mac manifest before publishing the GitHub Release.
4266
4367## 0) npm OIDC trusted publishing setup (CLI)
4468
45The workflow publishes the CLI with `bun publish` from `apps/server` after bumping
69The workflow publishes the CLI with `npm publish` from `apps/server` after bumping
4670the package version to the release tag version.
4771
4872Checklist:
......@@ -57,7 +81,8 @@ Checklist:
57814. Create release tag `vX.Y.Z` and push; workflow will:
5882 - set `apps/server/package.json` version to `X.Y.Z`
5983 - build web + server
60 - run `bun publish --access public`
84 - run `npm publish --access public --tag latest`
855. Nightly runs from the same workflow file publish with `npm publish --access public --tag nightly`.
6186
6287## 1) Dry-run release without signing
6388
packages/contracts/src/ipc.ts+11
......@@ -73,6 +73,14 @@ export type DesktopUpdateStatus =
7373
7474export type DesktopRuntimeArch = "arm64" | "x64" | "other";
7575export type DesktopTheme = "light" | "dark" | "system";
76export type DesktopUpdateChannel = "latest" | "nightly";
77export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly";
78
79export interface DesktopAppBranding {
80 baseName: string;
81 stageLabel: DesktopAppStageLabel;
82 displayName: string;
83}
7684
7785export interface DesktopRuntimeInfo {
7886 hostArch: DesktopRuntimeArch;
......@@ -83,6 +91,7 @@ export interface DesktopRuntimeInfo {
8391export interface DesktopUpdateState {
8492 enabled: boolean;
8593 status: DesktopUpdateStatus;
94 channel: DesktopUpdateChannel;
8695 currentVersion: string;
8796 hostArch: DesktopRuntimeArch;
8897 appArch: DesktopRuntimeArch;
......@@ -132,6 +141,7 @@ export interface DesktopServerExposureState {
132141}
133142
134143export interface DesktopBridge {
144 getAppBranding: () => DesktopAppBranding | null;
135145 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;
136146 getClientSettings: () => Promise<ClientSettings | null>;
137147 setClientSettings: (settings: ClientSettings) => Promise<void>;
......@@ -154,6 +164,7 @@ export interface DesktopBridge {
154164 openExternal: (url: string) => Promise<boolean>;
155165 onMenuAction: (listener: (action: string) => void) => () => void;
156166 getUpdateState: () => Promise<DesktopUpdateState>;
167 setUpdateChannel: (channel: DesktopUpdateChannel) => Promise<DesktopUpdateState>;
157168 checkForUpdate: () => Promise<DesktopUpdateCheckResult>;
158169 downloadUpdate: () => Promise<DesktopUpdateActionResult>;
159170 installUpdate: () => Promise<DesktopUpdateActionResult>;
scripts/build-desktop-artifact.test.ts+51-1
......@@ -2,9 +2,59 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
22import { assert, it } from "@effect/vitest";
33import { ConfigProvider, Effect, Option } from "effect";
44
5import { resolveBuildOptions } from "./build-desktop-artifact.ts";
5import {
6 resolveBuildOptions,
7 resolveDesktopBuildIconAssets,
8 resolveDesktopUpdateChannel,
9 resolveMockUpdateServerPort,
10 resolveMockUpdateServerUrl,
11} from "./build-desktop-artifact.ts";
12import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
613
714it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
15 it("resolves the dedicated nightly updater channel from nightly versions", () => {
16 assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly");
17 assert.equal(resolveDesktopUpdateChannel("0.0.17"), "latest");
18 });
19
20 it("switches desktop packaging icons to the nightly artwork for nightly versions", () => {
21 assert.deepStrictEqual(resolveDesktopBuildIconAssets("0.0.17"), {
22 macIconPng: BRAND_ASSET_PATHS.productionMacIconPng,
23 linuxIconPng: BRAND_ASSET_PATHS.productionLinuxIconPng,
24 windowsIconIco: BRAND_ASSET_PATHS.productionWindowsIconIco,
25 });
26
27 assert.deepStrictEqual(resolveDesktopBuildIconAssets("0.0.17-nightly.20260413.42"), {
28 macIconPng: BRAND_ASSET_PATHS.nightlyMacIconPng,
29 linuxIconPng: BRAND_ASSET_PATHS.nightlyLinuxIconPng,
30 windowsIconIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco,
31 });
32 });
33
34 it("falls back to the default mock update port when the configured port is blank", () => {
35 assert.equal(resolveMockUpdateServerUrl(undefined), "http://localhost:3000");
36 assert.equal(resolveMockUpdateServerUrl(4123), "http://localhost:4123");
37 });
38
39 it.effect("normalizes mock update server ports from env-style strings", () =>
40 Effect.gen(function* () {
41 assert.equal(yield* resolveMockUpdateServerPort(undefined), undefined);
42 assert.equal(yield* resolveMockUpdateServerPort(""), undefined);
43 assert.equal(yield* resolveMockUpdateServerPort(" "), undefined);
44 assert.equal(yield* resolveMockUpdateServerPort("4123"), 4123);
45 }),
46 );
47
48 it.effect("rejects non-numeric or out-of-range mock update ports", () =>
49 Effect.gen(function* () {
50 const invalidPorts = ["abc", "12.5", "0", "65536"];
51 for (const port of invalidPorts) {
52 const exit = yield* Effect.exit(resolveMockUpdateServerPort(port));
53 assert.equal(exit._tag, "Failure");
54 }
55 }),
56 );
57
858 it.effect("preserves explicit false boolean flags over true env defaults", () =>
959 Effect.gen(function* () {
1060 const resolved = yield* resolveBuildOptions({
scripts/build-desktop-artifact.ts+101-49
......@@ -23,23 +23,14 @@ const BuildArch = Schema.Literals(["arm64", "x64", "universal"]);
2323const RepoRoot = Effect.service(Path.Path).pipe(
2424 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
2525);
26const ProductionMacIconSource = Effect.zipWith(
27 RepoRoot,
28 Effect.service(Path.Path),
29 (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionMacIconPng),
30);
31const ProductionLinuxIconSource = Effect.zipWith(
32 RepoRoot,
33 Effect.service(Path.Path),
34 (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionLinuxIconPng),
35);
36const ProductionWindowsIconSource = Effect.zipWith(
37 RepoRoot,
38 Effect.service(Path.Path),
39 (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionWindowsIconIco),
40);
4126const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString);
4227
28interface DesktopBuildIconAssets {
29 readonly macIconPng: string;
30 readonly linuxIconPng: string;
31 readonly windowsIconIco: string;
32}
33
4334interface PlatformConfig {
4435 readonly cliFlag: "--mac" | "--linux" | "--win";
4536 readonly defaultTarget: string;
......@@ -75,7 +66,7 @@ interface BuildCliInput {
7566 readonly signed: Option.Option<boolean>;
7667 readonly verbose: Option.Option<boolean>;
7768 readonly mockUpdates: Option.Option<boolean>;
78 readonly mockUpdateServerPort: Option.Option<string>;
69 readonly mockUpdateServerPort: Option.Option<number>;
7970}
8071
8172function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined {
......@@ -165,7 +156,7 @@ interface ResolvedBuildOptions {
165156 readonly signed: boolean;
166157 readonly verbose: boolean;
167158 readonly mockUpdates: boolean;
168 readonly mockUpdateServerPort: string | undefined;
159 readonly mockUpdateServerPort: number | undefined;
169160}
170161
171162interface StagePackageJson {
......@@ -213,11 +204,28 @@ const BuildEnvConfig = Config.all({
213204 mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option),
214205});
215206
207const MockUpdateServerPortSchema = Schema.NumberFromString.check(
208 Schema.isInt(),
209 Schema.isBetween({ minimum: 1, maximum: 65535 }),
210);
211const decodeMockUpdateServerPort = Schema.decodeUnknownEffect(MockUpdateServerPortSchema);
212
216213const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
217214 Option.getOrElse(flag, () => envValue);
218215const mergeOptions = <A>(a: Option.Option<A>, b: Option.Option<A>, defaultValue: A) =>
219216 Option.getOrElse(a, () => Option.getOrElse(b, () => defaultValue));
220217
218export const resolveMockUpdateServerPort = Effect.fn("resolveMockUpdateServerPort")(function* (
219 mockUpdateServerPort: string | undefined,
220) {
221 const port = mockUpdateServerPort?.trim();
222 if (!port) {
223 return undefined;
224 }
225
226 return yield* decodeMockUpdateServerPort(port);
227});
228
221229export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (
222230 input: BuildCliInput,
223231) {
......@@ -254,11 +262,17 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (
254262 const verbose = resolveBooleanFlag(input.verbose, env.verbose);
255263
256264 const mockUpdates = resolveBooleanFlag(input.mockUpdates, env.mockUpdates);
257 const mockUpdateServerPort = mergeOptions(
258 input.mockUpdateServerPort,
259 env.mockUpdateServerPort,
260 undefined,
261 );
265 const mockUpdateServerPort =
266 Option.getOrUndefined(input.mockUpdateServerPort) ??
267 (yield* resolveMockUpdateServerPort(Option.getOrUndefined(env.mockUpdateServerPort)).pipe(
268 Effect.mapError(
269 (cause) =>
270 new BuildScriptError({
271 message: "Invalid mock update server port.",
272 cause,
273 }),
274 ),
275 ));
262276
263277 return {
264278 platform,
......@@ -329,14 +343,13 @@ function generateMacIconSet(
329343 });
330344}
331345
332function stageMacIcons(stageResourcesDir: string, verbose: boolean) {
346function stageMacIcons(stageResourcesDir: string, sourcePng: string, verbose: boolean) {
333347 return Effect.gen(function* () {
334348 const fs = yield* FileSystem.FileSystem;
335349 const path = yield* Path.Path;
336 const iconSource = yield* ProductionMacIconSource;
337 if (!(yield* fs.exists(iconSource))) {
350 if (!(yield* fs.exists(sourcePng))) {
338351 return yield* new BuildScriptError({
339 message: `Production icon source is missing at ${iconSource}`,
352 message: `Desktop macOS icon source is missing at ${sourcePng}`,
340353 });
341354 }
342355
......@@ -350,42 +363,40 @@ function stageMacIcons(stageResourcesDir: string, verbose: boolean) {
350363 yield* runCommand(
351364 ChildProcess.make({
352365 ...commandOutputOptions(verbose),
353 })`sips -z 512 512 ${iconSource} --out ${iconPngPath}`,
366 })`sips -z 512 512 ${sourcePng} --out ${iconPngPath}`,
354367 );
355368
356 yield* generateMacIconSet(iconSource, iconIcnsPath, tmpRoot, path, verbose);
369 yield* generateMacIconSet(sourcePng, iconIcnsPath, tmpRoot, path, verbose);
357370 });
358371}
359372
360function stageLinuxIcons(stageResourcesDir: string) {
373function stageLinuxIcons(stageResourcesDir: string, sourcePng: string) {
361374 return Effect.gen(function* () {
362375 const fs = yield* FileSystem.FileSystem;
363376 const path = yield* Path.Path;
364 const iconSource = yield* ProductionLinuxIconSource;
365 if (!(yield* fs.exists(iconSource))) {
377 if (!(yield* fs.exists(sourcePng))) {
366378 return yield* new BuildScriptError({
367 message: `Production icon source is missing at ${iconSource}`,
379 message: `Desktop Linux icon source is missing at ${sourcePng}`,
368380 });
369381 }
370382
371383 const iconPath = path.join(stageResourcesDir, "icon.png");
372 yield* fs.copyFile(iconSource, iconPath);
384 yield* fs.copyFile(sourcePng, iconPath);
373385 });
374386}
375387
376function stageWindowsIcons(stageResourcesDir: string) {
388function stageWindowsIcons(stageResourcesDir: string, sourceIco: string) {
377389 return Effect.gen(function* () {
378390 const fs = yield* FileSystem.FileSystem;
379391 const path = yield* Path.Path;
380 const iconSource = yield* ProductionWindowsIconSource;
381 if (!(yield* fs.exists(iconSource))) {
392 if (!(yield* fs.exists(sourceIco))) {
382393 return yield* new BuildScriptError({
383 message: `Production Windows icon source is missing at ${iconSource}`,
394 message: `Desktop Windows icon source is missing at ${sourceIco}`,
384395 });
385396 }
386397
387398 const iconPath = path.join(stageResourcesDir, "icon.ico");
388 yield* fs.copyFile(iconSource, iconPath);
399 yield* fs.copyFile(sourceIco, iconPath);
389400 });
390401}
391402
......@@ -441,12 +452,13 @@ function resolveDesktopRuntimeDependencies(
441452 return resolveCatalogDependencies(runtimeDependencies, catalog, "apps/desktop");
442453}
443454
444function resolveGitHubPublishConfig():
455function resolveGitHubPublishConfig(updateChannel: "latest" | "nightly"):
445456 | {
446457 readonly provider: "github";
447458 readonly owner: string;
448459 readonly repo: string;
449 readonly releaseType: "release";
460 readonly releaseType: "release" | "prerelease";
461 readonly channel?: "nightly";
450462 }
451463 | undefined {
452464 const rawRepo =
......@@ -462,17 +474,43 @@ function resolveGitHubPublishConfig():
462474 provider: "github",
463475 owner,
464476 repo,
465 releaseType: "release",
477 releaseType: updateChannel === "nightly" ? "prerelease" : "release",
478 ...(updateChannel === "nightly" ? { channel: "nightly" as const } : {}),
466479 };
467480}
468481
482export function resolveDesktopUpdateChannel(version: string): "latest" | "nightly" {
483 return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest";
484}
485
486export function resolveDesktopBuildIconAssets(version: string): DesktopBuildIconAssets {
487 if (resolveDesktopUpdateChannel(version) === "nightly") {
488 return {
489 macIconPng: BRAND_ASSET_PATHS.nightlyMacIconPng,
490 linuxIconPng: BRAND_ASSET_PATHS.nightlyLinuxIconPng,
491 windowsIconIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco,
492 };
493 }
494
495 return {
496 macIconPng: BRAND_ASSET_PATHS.productionMacIconPng,
497 linuxIconPng: BRAND_ASSET_PATHS.productionLinuxIconPng,
498 windowsIconIco: BRAND_ASSET_PATHS.productionWindowsIconIco,
499 };
500}
501
502export function resolveMockUpdateServerUrl(mockUpdateServerPort: number | undefined): string {
503 return `http://localhost:${mockUpdateServerPort ?? 3000}`;
504}
505
469506const createBuildConfig = Effect.fn("createBuildConfig")(function* (
470507 platform: typeof BuildPlatform.Type,
471508 target: string,
472509 productName: string,
510 version: string,
473511 signed: boolean,
474512 mockUpdates: boolean,
475 mockUpdateServerPort: string | undefined,
513 mockUpdateServerPort: number | undefined,
476514) {
477515 const buildConfig: Record<string, unknown> = {
478516 appId: "com.t3tools.t3code",
......@@ -482,14 +520,15 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* (
482520 buildResources: "apps/desktop/resources",
483521 },
484522 };
485 const publishConfig = resolveGitHubPublishConfig();
523 const updateChannel = resolveDesktopUpdateChannel(version);
524 const publishConfig = resolveGitHubPublishConfig(updateChannel);
486525 if (publishConfig) {
487526 buildConfig.publish = [publishConfig];
488527 } else if (mockUpdates) {
489528 buildConfig.publish = [
490529 {
491530 provider: "generic",
492 url: `http://localhost:${mockUpdateServerPort ?? 3000}`,
531 url: resolveMockUpdateServerUrl(mockUpdateServerPort),
493532 },
494533 ];
495534 }
......@@ -533,20 +572,21 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* (
533572const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(function* (
534573 platform: typeof BuildPlatform.Type,
535574 stageResourcesDir: string,
575 iconAssets: DesktopBuildIconAssets,
536576 verbose: boolean,
537577) {
538578 if (platform === "mac") {
539 yield* stageMacIcons(stageResourcesDir, verbose);
579 yield* stageMacIcons(stageResourcesDir, iconAssets.macIconPng, verbose);
540580 return;
541581 }
542582
543583 if (platform === "linux") {
544 yield* stageLinuxIcons(stageResourcesDir);
584 yield* stageLinuxIcons(stageResourcesDir, iconAssets.linuxIconPng);
545585 return;
546586 }
547587
548588 if (platform === "win") {
549 yield* stageWindowsIcons(stageResourcesDir);
589 yield* stageWindowsIcons(stageResourcesDir, iconAssets.windowsIconIco);
550590 }
551591});
552592
......@@ -614,6 +654,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
614654 });
615655
616656 const appVersion = options.version ?? serverPackageJson.version;
657 const iconAssets = resolveDesktopBuildIconAssets(appVersion);
617658 const commitHash = resolveGitCommitHash(repoRoot);
618659 const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped;
619660 const stageRoot = yield* mkdir({
......@@ -665,7 +706,16 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
665706 yield* fs.copy(distDirs.desktopResources, stageResourcesDir);
666707 yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"));
667708
668 yield* assertPlatformBuildResources(options.platform, stageResourcesDir, options.verbose);
709 yield* assertPlatformBuildResources(
710 options.platform,
711 stageResourcesDir,
712 {
713 macIconPng: join(repoRoot, iconAssets.macIconPng),
714 linuxIconPng: join(repoRoot, iconAssets.linuxIconPng),
715 windowsIconIco: join(repoRoot, iconAssets.windowsIconIco),
716 },
717 options.verbose,
718 );
669719
670720 // electron-builder is filtering out stageResourcesDir directory in the AppImage for production
671721 yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources"));
......@@ -683,6 +733,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
683733 options.platform,
684734 options.target,
685735 desktopPackageJson.productName ?? "T3 Code",
736 appVersion,
686737 options.signed,
687738 options.mockUpdates,
688739 options.mockUpdateServerPort,
......@@ -829,7 +880,8 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", {
829880 Flag.withDescription("Enable mock updates (env: T3CODE_DESKTOP_MOCK_UPDATES)."),
830881 Flag.optional,
831882 ),
832 mockUpdateServerPort: Flag.string("mock-update-server-port").pipe(
883 mockUpdateServerPort: Flag.integer("mock-update-server-port").pipe(
884 Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
833885 Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."),
834886 Flag.optional,
835887 ),
scripts/lib/brand-assets.ts+6
......@@ -6,6 +6,12 @@ export const BRAND_ASSET_PATHS = {
66 productionWebFavicon16Png: "assets/prod/t3-black-web-favicon-16x16.png",
77 productionWebFavicon32Png: "assets/prod/t3-black-web-favicon-32x32.png",
88 productionWebAppleTouchIconPng: "assets/prod/t3-black-web-apple-touch-180.png",
9
10 nightlyMacIconPng: "assets/nightly/blueprint-macos-1024.png",
11 nightlyLinuxIconPng: "assets/nightly/blueprint-universal-1024.png",
12 nightlyWindowsIconIco: "assets/nightly/blueprint-windows.ico",
13
14 developmentDesktopIconPng: "assets/dev/blueprint-macos-1024.png",
915 developmentWindowsIconIco: "assets/dev/blueprint-windows.ico",
1016 developmentWebFaviconIco: "assets/dev/blueprint-web-favicon.ico",
1117 developmentWebFavicon16Png: "assets/dev/blueprint-web-favicon-16x16.png",
scripts/release-smoke.ts+35-1
......@@ -95,7 +95,7 @@ try {
9595 },
9696 );
9797
98 execFileSync("bun", ["install", "--lockfile-only", "--ignore-scripts"], {
98 execFileSync("bun", ["install", "--ignore-scripts"], {
9999 cwd: tempRoot,
100100 stdio: "inherit",
101101 });
......@@ -107,6 +107,40 @@ try {
107107 "Expected bun.lock to contain the smoke version.",
108108 );
109109
110 const nightlyReleaseMetadata = execFileSync(
111 process.execPath,
112 [
113 resolve(repoRoot, "scripts/resolve-nightly-release.ts"),
114 "--date",
115 "20260413",
116 "--run-number",
117 "321",
118 "--sha",
119 "abcdef1234567890",
120 "--root",
121 tempRoot,
122 ],
123 {
124 cwd: repoRoot,
125 encoding: "utf8",
126 },
127 );
128 assertContains(
129 nightlyReleaseMetadata,
130 "version=9.9.9-nightly.20260413.321",
131 "Expected nightly metadata to contain the derived nightly version.",
132 );
133 assertContains(
134 nightlyReleaseMetadata,
135 "tag=nightly-v9.9.9-nightly.20260413.321",
136 "Expected nightly metadata to contain the derived nightly tag.",
137 );
138 assertContains(
139 nightlyReleaseMetadata,
140 "name=T3 Code Nightly 9.9.9-nightly.20260413.321 (abcdef123456)",
141 "Expected nightly metadata to include the short commit SHA in the release name.",
142 );
143
110144 const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot);
111145 execFileSync(
112146 process.execPath,
scripts/resolve-nightly-release.test.ts created+25
......@@ -0,0 +1,25 @@
1import { assert, it } from "@effect/vitest";
2
3import {
4 resolveNightlyBaseVersion,
5 resolveNightlyReleaseMetadata,
6} from "./resolve-nightly-release.ts";
7
8it("strips prerelease and build metadata when deriving the nightly base version", () => {
9 assert.equal(resolveNightlyBaseVersion("0.0.17"), "0.0.17");
10 assert.equal(resolveNightlyBaseVersion("9.9.9-smoke.0"), "9.9.9");
11 assert.equal(resolveNightlyBaseVersion("1.2.3-beta.4+build.9"), "1.2.3");
12});
13
14it("derives nightly metadata including the short commit sha in the release name", () => {
15 assert.deepStrictEqual(
16 resolveNightlyReleaseMetadata("9.9.9", "20260413", 321, "abcdef1234567890"),
17 {
18 baseVersion: "9.9.9",
19 version: "9.9.9-nightly.20260413.321",
20 tag: "nightly-v9.9.9-nightly.20260413.321",
21 name: "T3 Code Nightly 9.9.9-nightly.20260413.321 (abcdef123456)",
22 shortSha: "abcdef123456",
23 },
24 );
25});
scripts/resolve-nightly-release.ts created+126
......@@ -0,0 +1,126 @@
1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { Config, Effect, FileSystem, Option, Path, Schema } from "effect";
6import { Command, Flag } from "effect/unstable/cli";
7
8interface NightlyReleaseMetadata {
9 readonly baseVersion: string;
10 readonly version: string;
11 readonly tag: string;
12 readonly name: string;
13 readonly shortSha: string;
14}
15
16const DateSchema = Schema.String.check(Schema.isPattern(/^\d{8}$/));
17const RunNumberSchema = Schema.FiniteFromString.check(
18 Schema.isInt(),
19 Schema.isGreaterThanOrEqualTo(1),
20);
21const ShaSchema = Schema.String.check(Schema.isPattern(/^[0-9a-f]{7,40}$/i));
22const DesktopPackageJsonSchema = Schema.Struct({
23 version: Schema.NonEmptyString,
24});
25
26const RepoRoot = Effect.service(Path.Path).pipe(
27 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
28);
29const decodeDesktopPackageJson = Schema.decodeUnknownEffect(
30 Schema.fromJsonString(DesktopPackageJsonSchema),
31);
32
33export const resolveNightlyBaseVersion = (version: string) => version.replace(/[-+].*$/, "");
34
35export const resolveNightlyReleaseMetadata = (
36 baseVersion: string,
37 date: string,
38 runNumber: number,
39 sha: string,
40) => {
41 const shortSha = sha.slice(0, 12);
42 const version = `${baseVersion}-nightly.${date}.${runNumber}`;
43 return {
44 baseVersion,
45 version,
46 tag: `nightly-v${version}`,
47 name: `T3 Code Nightly ${version} (${shortSha})`,
48 shortSha,
49 };
50};
51
52const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* (
53 rootDir: string | undefined,
54) {
55 const fs = yield* FileSystem.FileSystem;
56 const path = yield* Path.Path;
57 const workspaceRoot = rootDir ? path.resolve(rootDir) : yield* RepoRoot;
58 const packageJsonPath = path.join(workspaceRoot, "apps/desktop/package.json");
59 const packageJson = yield* fs
60 .readFileString(packageJsonPath)
61 .pipe(Effect.flatMap(decodeDesktopPackageJson));
62 return resolveNightlyBaseVersion(packageJson.version);
63});
64
65const writeOutput = Effect.fn("writeOutput")(function* (
66 metadata: NightlyReleaseMetadata,
67 writeGithubOutput: boolean,
68) {
69 const fs = yield* FileSystem.FileSystem;
70
71 const entries = [
72 ["base_version", metadata.baseVersion],
73 ["version", metadata.version],
74 ["tag", metadata.tag],
75 ["name", metadata.name],
76 ["short_sha", metadata.shortSha],
77 ] as const;
78
79 if (writeGithubOutput) {
80 const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT");
81 const serialized = entries.map(([key, value]) => `${key}=${value}\n`).join("");
82 yield* fs.writeFileString(githubOutputPath, serialized, { flag: "a" });
83 } else {
84 for (const [key, value] of entries) {
85 console.log(`${key}=${value}`);
86 }
87 }
88});
89
90const command = Command.make(
91 "resolve-nightly-release",
92 {
93 date: Flag.string("date").pipe(
94 Flag.withSchema(DateSchema),
95 Flag.withDescription("Nightly build date in YYYYMMDD."),
96 ),
97 runNumber: Flag.string("run-number").pipe(
98 Flag.withSchema(RunNumberSchema),
99 Flag.withDescription("GitHub Actions run number."),
100 ),
101 sha: Flag.string("sha").pipe(
102 Flag.withSchema(ShaSchema),
103 Flag.withDescription("Commit sha for the nightly build."),
104 ),
105 githubOutput: Flag.boolean("github-output").pipe(
106 Flag.withDescription("Write values to GITHUB_OUTPUT instead of stdout."),
107 Flag.withDefault(false),
108 ),
109 root: Flag.string("root").pipe(
110 Flag.withDescription("Workspace root used to resolve apps/desktop/package.json."),
111 Flag.optional,
112 ),
113 },
114 ({ date, runNumber, sha, githubOutput, root }) =>
115 readDesktopBaseVersion(Option.getOrUndefined(root)).pipe(
116 Effect.map((baseVersion) => resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha)),
117 Effect.flatMap((metadata) => writeOutput(metadata, githubOutput)),
118 ),
119).pipe(Command.withDescription("Resolve nightly release version metadata."));
120
121if (import.meta.main) {
122 Command.run(command, { version: "0.0.0" }).pipe(
123 Effect.provide(NodeServices.layer),
124 NodeRuntime.runMain,
125 );
126}
scripts/resolve-previous-release-tag.ts created+203
......@@ -0,0 +1,203 @@
1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { Array, Config, Effect, FileSystem, Schema, Stream, String } from "effect";
6import { Command, Flag } from "effect/unstable/cli";
7import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
8
9const ReleaseChannel = Schema.Literals(["stable", "nightly"]);
10type ReleaseChannel = typeof ReleaseChannel.Type;
11
12interface StableVersion {
13 readonly major: number;
14 readonly minor: number;
15 readonly patch: number;
16 readonly prerelease: ReadonlyArray<string>;
17}
18
19interface NightlyVersion {
20 readonly major: number;
21 readonly minor: number;
22 readonly patch: number;
23 readonly date: number;
24 readonly runNumber: number;
25}
26
27const parseNumericIdentifier = (identifier: string): number | undefined =>
28 /^\d+$/.test(identifier) ? Number(identifier) : undefined;
29
30const comparePrereleaseIdentifiers = (left: string, right: string): number => {
31 const leftNumeric = parseNumericIdentifier(left);
32 const rightNumeric = parseNumericIdentifier(right);
33
34 if (leftNumeric !== undefined && rightNumeric !== undefined) {
35 return leftNumeric - rightNumeric;
36 }
37 if (leftNumeric !== undefined) {
38 return -1;
39 }
40 if (rightNumeric !== undefined) {
41 return 1;
42 }
43 return left.localeCompare(right);
44};
45
46const compareStableVersions = (left: StableVersion, right: StableVersion): number => {
47 if (left.major !== right.major) return left.major - right.major;
48 if (left.minor !== right.minor) return left.minor - right.minor;
49 if (left.patch !== right.patch) return left.patch - right.patch;
50
51 const leftHasPrerelease = left.prerelease.length > 0;
52 const rightHasPrerelease = right.prerelease.length > 0;
53 if (!leftHasPrerelease && !rightHasPrerelease) return 0;
54 if (!leftHasPrerelease) return 1;
55 if (!rightHasPrerelease) return -1;
56
57 const maxLength = Math.max(left.prerelease.length, right.prerelease.length);
58 for (let index = 0; index < maxLength; index += 1) {
59 const leftIdentifier = left.prerelease[index];
60 const rightIdentifier = right.prerelease[index];
61 if (leftIdentifier === undefined) return -1;
62 if (rightIdentifier === undefined) return 1;
63
64 const comparison = comparePrereleaseIdentifiers(leftIdentifier, rightIdentifier);
65 if (comparison !== 0) return comparison;
66 }
67
68 return 0;
69};
70
71const parseStableTag = (tag: string): StableVersion | undefined => {
72 const match = /^v(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(tag);
73 if (!match) return undefined;
74
75 const [, major, minor, patch, prerelease] = match;
76 if (!major || !minor || !patch) return undefined;
77
78 return {
79 major: Number(major),
80 minor: Number(minor),
81 patch: Number(patch),
82 prerelease: prerelease ? prerelease.split(".") : [],
83 };
84};
85
86const compareNightlyVersions = (left: NightlyVersion, right: NightlyVersion): number => {
87 if (left.major !== right.major) return left.major - right.major;
88 if (left.minor !== right.minor) return left.minor - right.minor;
89 if (left.patch !== right.patch) return left.patch - right.patch;
90 if (left.date !== right.date) return left.date - right.date;
91 return left.runNumber - right.runNumber;
92};
93
94const parseNightlyTag = (tag: string): NightlyVersion | undefined => {
95 const match = /^nightly-v(\d+)\.(\d+)\.(\d+)-nightly\.(\d{8})\.(\d+)$/.exec(tag);
96 if (!match) return undefined;
97
98 const [, major, minor, patch, date, runNumber] = match;
99 if (!major || !minor || !patch || !date || !runNumber) return undefined;
100
101 return {
102 major: Number(major),
103 minor: Number(minor),
104 patch: Number(patch),
105 date: Number(date),
106 runNumber: Number(runNumber),
107 };
108};
109
110const resolvePreviousReleaseTag = (
111 channel: ReleaseChannel,
112 currentTag: string,
113 tags: ReadonlyArray<string>,
114): string | undefined => {
115 if (channel === "stable") {
116 const current = parseStableTag(currentTag);
117 if (!current) {
118 throw new Error(`Invalid stable release tag '${currentTag}'.`);
119 }
120
121 const candidates = tags
122 .map((tag) => ({ tag, parsed: parseStableTag(tag) }))
123 .filter(
124 (entry): entry is { tag: string; parsed: StableVersion } => entry.parsed !== undefined,
125 )
126 .filter((entry) => compareStableVersions(entry.parsed, current) < 0)
127 .toSorted((left, right) => compareStableVersions(right.parsed, left.parsed));
128
129 return candidates[0]?.tag;
130 }
131
132 const current = parseNightlyTag(currentTag);
133 if (!current) {
134 throw new Error(`Invalid nightly release tag '${currentTag}'.`);
135 }
136
137 const candidates = tags
138 .map((tag) => ({ tag, parsed: parseNightlyTag(tag) }))
139 .filter((entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined)
140 .filter((entry) => compareNightlyVersions(entry.parsed, current) < 0)
141 .toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed));
142
143 return candidates[0]?.tag;
144};
145
146const listGitTags = Effect.fn("listGitTags")(function* () {
147 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
148 const child = yield* spawner.spawn(ChildProcess.make("git", ["tag", "--list"]));
149 const tags = yield* child.stdout.pipe(
150 Stream.decodeText(),
151 Stream.runFold(
152 () => "",
153 (acc, chunk) => acc + chunk,
154 ),
155 Effect.map(String.split(/\r?\n/)),
156 Effect.map(Array.map(String.trim)),
157 Effect.map(Array.filter(String.isNonEmpty)),
158 );
159 return tags;
160});
161
162const writeOutput = Effect.fn("writeOutput")(function* (
163 previousTag: string | undefined,
164 writeGithubOutput: boolean,
165) {
166 const entry = `previous_tag=${previousTag ?? ""}\n`;
167
168 if (writeGithubOutput) {
169 const fs = yield* FileSystem.FileSystem;
170 const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT");
171 yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" });
172 return;
173 }
174
175 process.stdout.write(entry);
176});
177
178const command = Command.make(
179 "resolve-previous-release-tag",
180 {
181 channel: Flag.choice("channel", ReleaseChannel.literals).pipe(
182 Flag.withDescription("Release channel whose previous tag should be resolved."),
183 ),
184 currentTag: Flag.string("current-tag").pipe(
185 Flag.withDescription("Current release tag to compare against."),
186 ),
187 githubOutput: Flag.boolean("github-output").pipe(
188 Flag.withDescription("Write values to GITHUB_OUTPUT instead of stdout."),
189 Flag.withDefault(false),
190 ),
191 },
192 ({ channel, currentTag, githubOutput }) =>
193 listGitTags().pipe(
194 Effect.map((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)),
195 Effect.flatMap((previousTag) => writeOutput(previousTag, githubOutput)),
196 ),
197).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series."));
198
199Command.run(command, { version: "0.0.0" }).pipe(
200 Effect.scoped,
201 Effect.provide(NodeServices.layer),
202 NodeRuntime.runMain,
203);