| author | |
| committer | |
| log | 56a3ad0f8b62381209be4db0ed7511142509f373 |
| tree | 3f24b902470942a6b94313e991cee4e39fd6e89d |
| parent | 36f484c25a965fd22f42d3834e2cb3a2c38bbc5e |
3 files changed, 630 insertions(+), 630 deletions(-)
package.json+6-6| ... | @@ -29,12 +29,12 @@ | ... | @@ -29,12 +29,12 @@ |
| 29 | "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", | 29 | "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", |
| 30 | "fmt": "oxfmt", | 30 | "fmt": "oxfmt", |
| 31 | "build:contracts": "turbo run build --filter=@t3tools/contracts", | 31 | "build:contracts": "turbo run build --filter=@t3tools/contracts", |
| 32 | "dist:desktop:artifact": "node scripts/build-desktop-artifact.mts", | 32 | "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", |
| 33 | "dist:desktop:dmg": "node scripts/build-desktop-artifact.mts --platform mac --target dmg", | 33 | "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg", |
| 34 | "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.mts --platform mac --target dmg --arch arm64", | 34 | "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64", |
| 35 | "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.mts --platform mac --target dmg --arch x64", | 35 | "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64", |
| 36 | "dist:desktop:linux": "node scripts/build-desktop-artifact.mts --platform linux --target AppImage --arch x64", | 36 | "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64", |
| 37 | "dist:desktop:win": "node scripts/build-desktop-artifact.mts --platform win --target nsis --arch x64", | 37 | "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", |
| 38 | "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", | 38 | "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", |
| 39 | "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" | 39 | "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" |
| 40 | }, | 40 | }, |
scripts/build-desktop-artifact.mts deleted-624| ... | @@ -1,624 +0,0 @@ | ||
| 1 | #!/usr/bin/env node | ||
| 2 | |||
| 3 | import rootPackageJson from "../package.json" with { type: "json" }; | ||
| 4 | import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; | ||
| 5 | import serverPackageJson from "../apps/server/package.json" with { type: "json" }; | ||
| 6 | |||
| 7 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | ||
| 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 9 | import { Config, Data, Effect, FileSystem, Logger, Option, Path, Schema } from "effect"; | ||
| 10 | import { Command, Flag } from "effect/unstable/cli"; | ||
| 11 | import { ChildProcess } from "effect/unstable/process"; | ||
| 12 | |||
| 13 | const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); | ||
| 14 | const BuildArch = Schema.Literals(["arm64", "x64", "universal"]); | ||
| 15 | |||
| 16 | const RepoRoot = Effect.service(Path.Path).pipe( | ||
| 17 | Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), | ||
| 18 | ); | ||
| 19 | const IconSource = Effect.zipWith(RepoRoot, Effect.service(Path.Path), (repoRoot, path) => | ||
| 20 | path.join(repoRoot, "assets/macos-icon-1024.png"), | ||
| 21 | ); | ||
| 22 | |||
| 23 | interface PlatformConfig { | ||
| 24 | readonly cliFlag: "--mac" | "--linux" | "--win"; | ||
| 25 | readonly defaultTarget: string; | ||
| 26 | readonly archChoices: ReadonlyArray<typeof BuildArch.Type>; | ||
| 27 | } | ||
| 28 | |||
| 29 | const PLATFORM_CONFIG: Record<typeof BuildPlatform.Type, PlatformConfig> = { | ||
| 30 | mac: { | ||
| 31 | cliFlag: "--mac", | ||
| 32 | defaultTarget: "dmg", | ||
| 33 | archChoices: ["arm64", "x64", "universal"], | ||
| 34 | }, | ||
| 35 | linux: { | ||
| 36 | cliFlag: "--linux", | ||
| 37 | defaultTarget: "AppImage", | ||
| 38 | archChoices: ["x64", "arm64"], | ||
| 39 | }, | ||
| 40 | win: { | ||
| 41 | cliFlag: "--win", | ||
| 42 | defaultTarget: "nsis", | ||
| 43 | archChoices: ["x64", "arm64"], | ||
| 44 | }, | ||
| 45 | }; | ||
| 46 | |||
| 47 | interface BuildCliInput { | ||
| 48 | readonly platform: Option.Option<typeof BuildPlatform.Type>; | ||
| 49 | readonly target: Option.Option<string>; | ||
| 50 | readonly arch: Option.Option<typeof BuildArch.Type>; | ||
| 51 | readonly buildVersion: Option.Option<string>; | ||
| 52 | readonly outputDir: Option.Option<string>; | ||
| 53 | readonly skipBuild: Option.Option<boolean>; | ||
| 54 | readonly keepStage: Option.Option<boolean>; | ||
| 55 | readonly signed: Option.Option<boolean>; | ||
| 56 | readonly verbose: Option.Option<boolean>; | ||
| 57 | } | ||
| 58 | |||
| 59 | function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined { | ||
| 60 | if (hostPlatform === "darwin") return "mac"; | ||
| 61 | if (hostPlatform === "linux") return "linux"; | ||
| 62 | if (hostPlatform === "win32") return "win"; | ||
| 63 | return undefined; | ||
| 64 | } | ||
| 65 | |||
| 66 | function getDefaultArch(platform: typeof BuildPlatform.Type): typeof BuildArch.Type { | ||
| 67 | const config = PLATFORM_CONFIG[platform]; | ||
| 68 | if (!config) { | ||
| 69 | return "x64"; | ||
| 70 | } | ||
| 71 | |||
| 72 | if (process.arch === "arm64" && config.archChoices.includes("arm64")) { | ||
| 73 | return "arm64"; | ||
| 74 | } | ||
| 75 | if (process.arch === "x64" && config.archChoices.includes("x64")) { | ||
| 76 | return "x64"; | ||
| 77 | } | ||
| 78 | |||
| 79 | return config.archChoices[0] ?? "x64"; | ||
| 80 | } | ||
| 81 | |||
| 82 | class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ | ||
| 83 | readonly message: string; | ||
| 84 | readonly cause?: unknown; | ||
| 85 | }> {} | ||
| 86 | |||
| 87 | interface ResolvedBuildOptions { | ||
| 88 | readonly platform: typeof BuildPlatform.Type; | ||
| 89 | readonly target: string; | ||
| 90 | readonly arch: typeof BuildArch.Type; | ||
| 91 | readonly version: string | undefined; | ||
| 92 | readonly outputDir: string; | ||
| 93 | readonly skipBuild: boolean; | ||
| 94 | readonly keepStage: boolean; | ||
| 95 | readonly signed: boolean; | ||
| 96 | readonly verbose: boolean; | ||
| 97 | } | ||
| 98 | |||
| 99 | interface StagePackageJson { | ||
| 100 | readonly name: string; | ||
| 101 | readonly version: string; | ||
| 102 | readonly private: true; | ||
| 103 | readonly description: string; | ||
| 104 | readonly author: string; | ||
| 105 | readonly main: string; | ||
| 106 | readonly build: Record<string, unknown>; | ||
| 107 | readonly dependencies: Record<string, unknown>; | ||
| 108 | readonly devDependencies: { | ||
| 109 | readonly electron: string; | ||
| 110 | }; | ||
| 111 | } | ||
| 112 | |||
| 113 | const AzureTrustedSigningOptionsConfig = Config.all({ | ||
| 114 | publisherName: Config.string("AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"), | ||
| 115 | endpoint: Config.string("AZURE_TRUSTED_SIGNING_ENDPOINT"), | ||
| 116 | certificateProfileName: Config.string("AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME"), | ||
| 117 | codeSigningAccountName: Config.string("AZURE_TRUSTED_SIGNING_ACCOUNT_NAME"), | ||
| 118 | fileDigest: Config.string("AZURE_TRUSTED_SIGNING_FILE_DIGEST").pipe( | ||
| 119 | Config.withDefault(() => "SHA256"), | ||
| 120 | ), | ||
| 121 | timestampDigest: Config.string("AZURE_TRUSTED_SIGNING_TIMESTAMP_DIGEST").pipe( | ||
| 122 | Config.withDefault(() => "SHA256"), | ||
| 123 | ), | ||
| 124 | timestampRfc3161: Config.string("AZURE_TRUSTED_SIGNING_TIMESTAMP_RFC3161").pipe( | ||
| 125 | Config.withDefault(() => "http://timestamp.acs.microsoft.com"), | ||
| 126 | ), | ||
| 127 | }); | ||
| 128 | |||
| 129 | const BuildEnvConfig = Config.all({ | ||
| 130 | platform: Config.schema(BuildPlatform, "T3CODE_DESKTOP_PLATFORM").pipe(Config.option), | ||
| 131 | target: Config.string("T3CODE_DESKTOP_TARGET").pipe(Config.option), | ||
| 132 | arch: Config.schema(BuildArch, "T3CODE_DESKTOP_ARCH").pipe(Config.option), | ||
| 133 | version: Config.string("T3CODE_DESKTOP_VERSION").pipe(Config.option), | ||
| 134 | outputDir: Config.string("T3CODE_DESKTOP_OUTPUT_DIR").pipe(Config.option), | ||
| 135 | skipBuild: Config.boolean("T3CODE_DESKTOP_SKIP_BUILD").pipe(Config.withDefault(() => false)), | ||
| 136 | keepStage: Config.boolean("T3CODE_DESKTOP_KEEP_STAGE").pipe(Config.withDefault(() => false)), | ||
| 137 | signed: Config.boolean("T3CODE_DESKTOP_SIGNED").pipe(Config.withDefault(() => false)), | ||
| 138 | verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(() => false)), | ||
| 139 | }); | ||
| 140 | |||
| 141 | const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) => | ||
| 142 | Option.getOrElse(Option.filter(flag, Boolean), () => envValue); | ||
| 143 | const mergeOptions = <A,>(a: Option.Option<A>, b: Option.Option<A>, defaultValue: A) => | ||
| 144 | Option.getOrElse(a, () => Option.getOrElse(b, () => defaultValue)); | ||
| 145 | |||
| 146 | const resolveBuildOptions = Effect.fn(function* (input: BuildCliInput) { | ||
| 147 | const path = yield* Path.Path; | ||
| 148 | const repoRoot = yield* RepoRoot; | ||
| 149 | const env = yield* BuildEnvConfig.asEffect(); | ||
| 150 | |||
| 151 | const platform = mergeOptions( | ||
| 152 | input.platform, | ||
| 153 | env.platform, | ||
| 154 | detectHostBuildPlatform(process.platform), | ||
| 155 | ); | ||
| 156 | |||
| 157 | if (!platform) { | ||
| 158 | return yield* new BuildScriptError({ | ||
| 159 | message: `Unsupported host platform '${process.platform}'.`, | ||
| 160 | }); | ||
| 161 | } | ||
| 162 | |||
| 163 | const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget); | ||
| 164 | const arch = mergeOptions(input.arch, env.arch, getDefaultArch(platform)); | ||
| 165 | const version = mergeOptions(input.buildVersion, env.version, undefined); | ||
| 166 | const outputDir = path.resolve(repoRoot, mergeOptions(input.outputDir, env.outputDir, "release")); | ||
| 167 | |||
| 168 | const skipBuild = resolveBooleanFlag(input.skipBuild, env.skipBuild); | ||
| 169 | const keepStage = resolveBooleanFlag(input.keepStage, env.keepStage); | ||
| 170 | const signed = resolveBooleanFlag(input.signed, env.signed); | ||
| 171 | const verbose = resolveBooleanFlag(input.verbose, env.verbose); | ||
| 172 | |||
| 173 | return { | ||
| 174 | platform, | ||
| 175 | target, | ||
| 176 | arch, | ||
| 177 | version, | ||
| 178 | outputDir, | ||
| 179 | skipBuild, | ||
| 180 | keepStage, | ||
| 181 | signed, | ||
| 182 | verbose, | ||
| 183 | } satisfies ResolvedBuildOptions; | ||
| 184 | }); | ||
| 185 | |||
| 186 | const commandOutputOptions = (verbose: boolean) => | ||
| 187 | ({ | ||
| 188 | stdout: verbose ? "inherit" : "ignore", | ||
| 189 | stderr: "inherit", | ||
| 190 | }) as const; | ||
| 191 | |||
| 192 | const runCommand = Effect.fn(function* (command: ChildProcess.Command) { | ||
| 193 | const exitCode = yield* ChildProcess.exitCode(command); | ||
| 194 | |||
| 195 | if (exitCode !== 0) { | ||
| 196 | return yield* new BuildScriptError({ | ||
| 197 | message: "Command exited with non-zero exit code", | ||
| 198 | cause: command, | ||
| 199 | }); | ||
| 200 | } | ||
| 201 | }); | ||
| 202 | |||
| 203 | function generateMacIconSet( | ||
| 204 | sourcePng: string, | ||
| 205 | targetIcns: string, | ||
| 206 | tmpRoot: string, | ||
| 207 | path: Path.Path, | ||
| 208 | verbose: boolean, | ||
| 209 | ) { | ||
| 210 | return Effect.gen(function* () { | ||
| 211 | const fs = yield* FileSystem.FileSystem; | ||
| 212 | const iconsetDir = path.join(tmpRoot, "icon.iconset"); | ||
| 213 | yield* fs.makeDirectory(iconsetDir, { recursive: true }); | ||
| 214 | |||
| 215 | const iconSizes = [16, 32, 128, 256, 512] as const; | ||
| 216 | for (const size of iconSizes) { | ||
| 217 | yield* runCommand( | ||
| 218 | ChildProcess.make({ | ||
| 219 | ...commandOutputOptions(verbose), | ||
| 220 | })`sips -z ${size} ${size} ${sourcePng} --out ${path.join(iconsetDir, `icon_${size}x${size}.png`)}`, | ||
| 221 | ); | ||
| 222 | |||
| 223 | const retinaSize = size * 2; | ||
| 224 | yield* runCommand( | ||
| 225 | ChildProcess.make({ | ||
| 226 | ...commandOutputOptions(verbose), | ||
| 227 | })`sips -z ${retinaSize} ${retinaSize} ${sourcePng} --out ${path.join(iconsetDir, `icon_${size}x${size}@2x.png`)}`, | ||
| 228 | ); | ||
| 229 | } | ||
| 230 | |||
| 231 | yield* runCommand( | ||
| 232 | ChildProcess.make({ | ||
| 233 | ...commandOutputOptions(verbose), | ||
| 234 | })`iconutil -c icns ${iconsetDir} -o ${targetIcns}`, | ||
| 235 | ); | ||
| 236 | }); | ||
| 237 | } | ||
| 238 | |||
| 239 | function stageMacIcons(stageResourcesDir: string, verbose: boolean) { | ||
| 240 | return Effect.gen(function* () { | ||
| 241 | const fs = yield* FileSystem.FileSystem; | ||
| 242 | const path = yield* Path.Path; | ||
| 243 | const iconSource = yield* IconSource; | ||
| 244 | if (!(yield* fs.exists(iconSource))) { | ||
| 245 | return yield* new BuildScriptError({ | ||
| 246 | message: `Production icon source is missing at ${iconSource}`, | ||
| 247 | }); | ||
| 248 | } | ||
| 249 | |||
| 250 | const tmpRoot = yield* fs.makeTempDirectoryScoped({ | ||
| 251 | prefix: "t3code-icon-build-", | ||
| 252 | }); | ||
| 253 | |||
| 254 | const iconPngPath = path.join(stageResourcesDir, "icon.png"); | ||
| 255 | const iconIcnsPath = path.join(stageResourcesDir, "icon.icns"); | ||
| 256 | |||
| 257 | yield* runCommand( | ||
| 258 | ChildProcess.make({ | ||
| 259 | ...commandOutputOptions(verbose), | ||
| 260 | })`sips -z 512 512 ${iconSource} --out ${iconPngPath}`, | ||
| 261 | ); | ||
| 262 | |||
| 263 | yield* generateMacIconSet(iconSource, iconIcnsPath, tmpRoot, path, verbose); | ||
| 264 | }); | ||
| 265 | } | ||
| 266 | |||
| 267 | function validateBundledClientAssets(clientDir: string) { | ||
| 268 | return Effect.gen(function* () { | ||
| 269 | const fs = yield* FileSystem.FileSystem; | ||
| 270 | const path = yield* Path.Path; | ||
| 271 | const indexPath = path.join(clientDir, "index.html"); | ||
| 272 | const indexHtml = yield* fs.readFileString(indexPath); | ||
| 273 | const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map( | ||
| 274 | (match) => match[1], | ||
| 275 | ); | ||
| 276 | const missing: string[] = []; | ||
| 277 | |||
| 278 | for (const ref of refs) { | ||
| 279 | const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? ""; | ||
| 280 | if (!normalizedRef) continue; | ||
| 281 | if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue; | ||
| 282 | if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue; | ||
| 283 | |||
| 284 | const ext = path.extname(normalizedRef); | ||
| 285 | if (!ext) continue; | ||
| 286 | |||
| 287 | const relativePath = normalizedRef.replace(/^\/+/, ""); | ||
| 288 | const assetPath = path.join(clientDir, relativePath); | ||
| 289 | if (!(yield* fs.exists(assetPath))) { | ||
| 290 | missing.push(normalizedRef); | ||
| 291 | } | ||
| 292 | } | ||
| 293 | |||
| 294 | if (missing.length > 0) { | ||
| 295 | const preview = missing.slice(0, 6).join(", "); | ||
| 296 | const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : ""; | ||
| 297 | return yield* new BuildScriptError({ | ||
| 298 | message: `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`, | ||
| 299 | }); | ||
| 300 | } | ||
| 301 | }); | ||
| 302 | } | ||
| 303 | |||
| 304 | function resolveCatalogDependencies( | ||
| 305 | dependencies: Record<string, unknown>, | ||
| 306 | catalog: Record<string, unknown>, | ||
| 307 | dependencySourceLabel: string, | ||
| 308 | ): Record<string, unknown> { | ||
| 309 | return Object.fromEntries( | ||
| 310 | Object.entries(dependencies).map(([dependencyName, spec]) => { | ||
| 311 | if (typeof spec !== "string" || !spec.startsWith("catalog:")) { | ||
| 312 | return [dependencyName, spec]; | ||
| 313 | } | ||
| 314 | |||
| 315 | const catalogKey = spec.slice("catalog:".length).trim(); | ||
| 316 | const lookupKey = catalogKey.length > 0 ? catalogKey : dependencyName; | ||
| 317 | const resolvedSpec = catalog[lookupKey]; | ||
| 318 | if (typeof resolvedSpec !== "string" || resolvedSpec.length === 0) { | ||
| 319 | throw new BuildScriptError({ | ||
| 320 | message: `Unable to resolve '${spec}' for ${dependencySourceLabel} dependency '${dependencyName}'. Expected key '${lookupKey}' in root workspace catalog.`, | ||
| 321 | }); | ||
| 322 | } | ||
| 323 | |||
| 324 | return [dependencyName, resolvedSpec]; | ||
| 325 | }), | ||
| 326 | ); | ||
| 327 | } | ||
| 328 | |||
| 329 | const createBuildConfig = Effect.fn(function* ( | ||
| 330 | platform: typeof BuildPlatform.Type, | ||
| 331 | target: string, | ||
| 332 | productName: string, | ||
| 333 | signed: boolean, | ||
| 334 | ) { | ||
| 335 | const buildConfig: Record<string, unknown> = { | ||
| 336 | appId: "com.t3tools.t3code", | ||
| 337 | productName, | ||
| 338 | artifactName: "T3-Code-${version}-${arch}.${ext}", | ||
| 339 | directories: { | ||
| 340 | buildResources: "apps/desktop/resources", | ||
| 341 | }, | ||
| 342 | }; | ||
| 343 | |||
| 344 | if (platform === "mac") { | ||
| 345 | buildConfig.mac = { | ||
| 346 | target: [target], | ||
| 347 | icon: "icon.icns", | ||
| 348 | category: "public.app-category.developer-tools", | ||
| 349 | }; | ||
| 350 | } | ||
| 351 | |||
| 352 | if (platform === "linux") { | ||
| 353 | buildConfig.linux = { | ||
| 354 | target: [target], | ||
| 355 | icon: "icon.png", | ||
| 356 | category: "Development", | ||
| 357 | }; | ||
| 358 | } | ||
| 359 | |||
| 360 | if (platform === "win") { | ||
| 361 | const winConfig: Record<string, unknown> = { | ||
| 362 | target: [target], | ||
| 363 | icon: "icon.ico", | ||
| 364 | }; | ||
| 365 | if (signed) { | ||
| 366 | winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig; | ||
| 367 | } | ||
| 368 | buildConfig.win = winConfig; | ||
| 369 | } | ||
| 370 | |||
| 371 | return buildConfig; | ||
| 372 | }); | ||
| 373 | |||
| 374 | const assertPlatformBuildResources = Effect.fn(function* ( | ||
| 375 | platform: typeof BuildPlatform.Type, | ||
| 376 | stageResourcesDir: string, | ||
| 377 | verbose: boolean, | ||
| 378 | ) { | ||
| 379 | const fs = yield* FileSystem.FileSystem; | ||
| 380 | const path = yield* Path.Path; | ||
| 381 | |||
| 382 | if (platform === "mac") { | ||
| 383 | yield* stageMacIcons(stageResourcesDir, verbose); | ||
| 384 | return; | ||
| 385 | } | ||
| 386 | |||
| 387 | if (platform === "linux") { | ||
| 388 | const iconPath = path.join(stageResourcesDir, "icon.png"); | ||
| 389 | if (!(yield* fs.exists(iconPath))) { | ||
| 390 | return yield* new BuildScriptError({ | ||
| 391 | message: `Missing Linux icon at ${iconPath}`, | ||
| 392 | }); | ||
| 393 | } | ||
| 394 | return; | ||
| 395 | } | ||
| 396 | |||
| 397 | if (platform === "win") { | ||
| 398 | const iconPath = path.join(stageResourcesDir, "icon.ico"); | ||
| 399 | if (!(yield* fs.exists(iconPath))) { | ||
| 400 | return yield* new BuildScriptError({ | ||
| 401 | message: `Missing Windows icon at ${iconPath}`, | ||
| 402 | }); | ||
| 403 | } | ||
| 404 | } | ||
| 405 | }); | ||
| 406 | |||
| 407 | const buildDesktopArtifact = Effect.fn(function* (options: ResolvedBuildOptions) { | ||
| 408 | const repoRoot = yield* RepoRoot; | ||
| 409 | const path = yield* Path.Path; | ||
| 410 | const fs = yield* FileSystem.FileSystem; | ||
| 411 | |||
| 412 | const platformConfig = PLATFORM_CONFIG[options.platform]; | ||
| 413 | if (!platformConfig) { | ||
| 414 | return yield* new BuildScriptError({ | ||
| 415 | message: `Unsupported platform '${options.platform}'.`, | ||
| 416 | }); | ||
| 417 | } | ||
| 418 | |||
| 419 | const electronVersion = desktopPackageJson.dependencies.electron; | ||
| 420 | |||
| 421 | const dependencies = serverPackageJson.dependencies; | ||
| 422 | if (!dependencies || Object.keys(dependencies).length === 0) { | ||
| 423 | return yield* new BuildScriptError({ | ||
| 424 | message: "Could not resolve production dependencies from apps/server/package.json.", | ||
| 425 | }); | ||
| 426 | } | ||
| 427 | |||
| 428 | const resolvedDependencies = yield* Effect.try({ | ||
| 429 | try: () => | ||
| 430 | resolveCatalogDependencies(dependencies, rootPackageJson.workspaces.catalog, "apps/server"), | ||
| 431 | catch: (cause) => | ||
| 432 | new BuildScriptError({ | ||
| 433 | message: "Could not resolve production dependencies from apps/server/package.json.", | ||
| 434 | cause, | ||
| 435 | }), | ||
| 436 | }); | ||
| 437 | |||
| 438 | const appVersion = options.version ?? serverPackageJson.version; | ||
| 439 | const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped; | ||
| 440 | const stageRoot = yield* mkdir({ | ||
| 441 | prefix: `t3code-desktop-${options.platform}-stage-`, | ||
| 442 | }); | ||
| 443 | |||
| 444 | const stageAppDir = path.join(stageRoot, "app"); | ||
| 445 | const stageResourcesDir = path.join(stageAppDir, "apps/desktop/resources"); | ||
| 446 | const distDirs = { | ||
| 447 | desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"), | ||
| 448 | desktopResources: path.join(repoRoot, "apps/desktop/resources"), | ||
| 449 | serverDist: path.join(repoRoot, "apps/server/dist"), | ||
| 450 | }; | ||
| 451 | const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html"); | ||
| 452 | |||
| 453 | if (!options.skipBuild) { | ||
| 454 | yield* Effect.log("[desktop-artifact] Building desktop/server/web artifacts..."); | ||
| 455 | yield* runCommand( | ||
| 456 | ChildProcess.make({ | ||
| 457 | cwd: repoRoot, | ||
| 458 | ...commandOutputOptions(options.verbose), | ||
| 459 | })`bun run build:desktop`, | ||
| 460 | ); | ||
| 461 | } | ||
| 462 | |||
| 463 | for (const [label, dir] of Object.entries(distDirs)) { | ||
| 464 | if (!(yield* fs.exists(dir))) { | ||
| 465 | return yield* new BuildScriptError({ | ||
| 466 | message: `Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`, | ||
| 467 | }); | ||
| 468 | } | ||
| 469 | } | ||
| 470 | |||
| 471 | if (!(yield* fs.exists(bundledClientEntry))) { | ||
| 472 | return yield* new BuildScriptError({ | ||
| 473 | message: `Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`, | ||
| 474 | }); | ||
| 475 | } | ||
| 476 | |||
| 477 | yield* validateBundledClientAssets(path.dirname(bundledClientEntry)); | ||
| 478 | |||
| 479 | yield* fs.makeDirectory(path.join(stageAppDir, "apps/desktop"), { recursive: true }); | ||
| 480 | yield* fs.makeDirectory(path.join(stageAppDir, "apps/server"), { recursive: true }); | ||
| 481 | |||
| 482 | yield* Effect.log("[desktop-artifact] Staging release app..."); | ||
| 483 | yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron")); | ||
| 484 | yield* fs.copy(distDirs.desktopResources, stageResourcesDir); | ||
| 485 | yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist")); | ||
| 486 | |||
| 487 | yield* assertPlatformBuildResources(options.platform, stageResourcesDir, options.verbose); | ||
| 488 | |||
| 489 | const stagePackageJson: StagePackageJson = { | ||
| 490 | name: "t3-code-desktop", | ||
| 491 | version: appVersion, | ||
| 492 | private: true, | ||
| 493 | description: "T3 Code desktop build", | ||
| 494 | author: "T3 Tools", | ||
| 495 | main: "apps/desktop/dist-electron/main.js", | ||
| 496 | build: yield* createBuildConfig( | ||
| 497 | options.platform, | ||
| 498 | options.target, | ||
| 499 | desktopPackageJson.productName ?? "T3 Code", | ||
| 500 | options.signed, | ||
| 501 | ), | ||
| 502 | dependencies: resolvedDependencies, | ||
| 503 | devDependencies: { | ||
| 504 | electron: electronVersion, | ||
| 505 | }, | ||
| 506 | }; | ||
| 507 | |||
| 508 | yield* fs.writeFileString( | ||
| 509 | path.join(stageAppDir, "package.json"), | ||
| 510 | `${JSON.stringify(stagePackageJson, null, 2)}\n`, | ||
| 511 | ); | ||
| 512 | |||
| 513 | yield* Effect.log("[desktop-artifact] Installing staged production dependencies..."); | ||
| 514 | yield* runCommand( | ||
| 515 | ChildProcess.make({ | ||
| 516 | cwd: stageAppDir, | ||
| 517 | ...commandOutputOptions(options.verbose), | ||
| 518 | })`bun install --production`, | ||
| 519 | ); | ||
| 520 | |||
| 521 | const buildEnv: NodeJS.ProcessEnv = { | ||
| 522 | ...process.env, | ||
| 523 | }; | ||
| 524 | if (!options.signed) { | ||
| 525 | buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; | ||
| 526 | } | ||
| 527 | |||
| 528 | yield* Effect.log( | ||
| 529 | `[desktop-artifact] Building ${options.platform}/${options.target} (arch=${options.arch}, version=${appVersion})...`, | ||
| 530 | ); | ||
| 531 | yield* runCommand( | ||
| 532 | ChildProcess.make({ | ||
| 533 | cwd: stageAppDir, | ||
| 534 | env: buildEnv, | ||
| 535 | ...commandOutputOptions(options.verbose), | ||
| 536 | })`bunx --bun electron-builder ${platformConfig.cliFlag} ${options.target} --${options.arch} --publish never`, | ||
| 537 | ); | ||
| 538 | |||
| 539 | const stageDistDir = path.join(stageAppDir, "dist"); | ||
| 540 | if (!(yield* fs.exists(stageDistDir))) { | ||
| 541 | return yield* new BuildScriptError({ | ||
| 542 | message: `Build completed but dist directory was not found at ${stageDistDir}`, | ||
| 543 | }); | ||
| 544 | } | ||
| 545 | |||
| 546 | const stageEntries = yield* fs.readDirectory(stageDistDir); | ||
| 547 | yield* fs.makeDirectory(options.outputDir, { recursive: true }); | ||
| 548 | |||
| 549 | const copiedArtifacts: string[] = []; | ||
| 550 | for (const entry of stageEntries) { | ||
| 551 | const from = path.join(stageDistDir, entry); | ||
| 552 | const stat = yield* fs.stat(from).pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 553 | if (!stat || stat.type !== "File") continue; | ||
| 554 | |||
| 555 | const to = path.join(options.outputDir, entry); | ||
| 556 | yield* fs.copyFile(from, to); | ||
| 557 | copiedArtifacts.push(to); | ||
| 558 | } | ||
| 559 | |||
| 560 | if (copiedArtifacts.length === 0) { | ||
| 561 | return yield* new BuildScriptError({ | ||
| 562 | message: `Build completed but no files were produced in ${stageDistDir}`, | ||
| 563 | }); | ||
| 564 | } | ||
| 565 | |||
| 566 | yield* Effect.log("[desktop-artifact] Done. Artifacts:").pipe( | ||
| 567 | Effect.annotateLogs({ artifacts: copiedArtifacts }), | ||
| 568 | ); | ||
| 569 | }); | ||
| 570 | |||
| 571 | export const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { | ||
| 572 | platform: Flag.choice("platform", BuildPlatform.literals).pipe( | ||
| 573 | Flag.withDescription("Build platform (env: T3CODE_DESKTOP_PLATFORM)."), | ||
| 574 | Flag.optional, | ||
| 575 | ), | ||
| 576 | target: Flag.string("target").pipe( | ||
| 577 | Flag.withDescription( | ||
| 578 | "Artifact target, for example dmg/AppImage/nsis (env: T3CODE_DESKTOP_TARGET).", | ||
| 579 | ), | ||
| 580 | Flag.optional, | ||
| 581 | ), | ||
| 582 | arch: Flag.choice("arch", BuildArch.literals).pipe( | ||
| 583 | Flag.withDescription("Build arch, for example arm64/x64/universal (env: T3CODE_DESKTOP_ARCH)."), | ||
| 584 | Flag.optional, | ||
| 585 | ), | ||
| 586 | buildVersion: Flag.string("build-version").pipe( | ||
| 587 | Flag.withDescription("Artifact version metadata (env: T3CODE_DESKTOP_VERSION)."), | ||
| 588 | Flag.optional, | ||
| 589 | ), | ||
| 590 | outputDir: Flag.string("output-dir").pipe( | ||
| 591 | Flag.withDescription("Output directory for artifacts (env: T3CODE_DESKTOP_OUTPUT_DIR)."), | ||
| 592 | Flag.optional, | ||
| 593 | ), | ||
| 594 | skipBuild: Flag.boolean("skip-build").pipe( | ||
| 595 | Flag.withDescription( | ||
| 596 | "Skip `bun run build:desktop` and use existing dist artifacts (env: T3CODE_DESKTOP_SKIP_BUILD).", | ||
| 597 | ), | ||
| 598 | Flag.optional, | ||
| 599 | ), | ||
| 600 | keepStage: Flag.boolean("keep-stage").pipe( | ||
| 601 | Flag.withDescription("Keep temporary staging files (env: T3CODE_DESKTOP_KEEP_STAGE)."), | ||
| 602 | Flag.optional, | ||
| 603 | ), | ||
| 604 | signed: Flag.boolean("signed").pipe( | ||
| 605 | Flag.withDescription( | ||
| 606 | "Enable signing/notarization discovery; Windows uses Azure Trusted Signing (env: T3CODE_DESKTOP_SIGNED).", | ||
| 607 | ), | ||
| 608 | Flag.optional, | ||
| 609 | ), | ||
| 610 | verbose: Flag.boolean("verbose").pipe( | ||
| 611 | Flag.withDescription("Stream subprocess stdout (env: T3CODE_DESKTOP_VERBOSE)."), | ||
| 612 | Flag.optional, | ||
| 613 | ), | ||
| 614 | }).pipe( | ||
| 615 | Command.withDescription("Build a desktop artifact for T3 Code."), | ||
| 616 | Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)), | ||
| 617 | ); | ||
| 618 | |||
| 619 | Command.run(buildDesktopArtifactCli, { version: "0.0.0" }).pipe( | ||
| 620 | Effect.scoped, | ||
| 621 | Effect.provide(Logger.layer([Logger.consolePretty()])), | ||
| 622 | Effect.provide(NodeServices.layer), | ||
| 623 | NodeRuntime.runMain, | ||
| 624 | ); | ||
scripts/build-desktop-artifact.ts created+624| ... | @@ -0,0 +1,624 @@ | ||
| 1 | #!/usr/bin/env node | ||
| 2 | |||
| 3 | import rootPackageJson from "../package.json" with { type: "json" }; | ||
| 4 | import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; | ||
| 5 | import serverPackageJson from "../apps/server/package.json" with { type: "json" }; | ||
| 6 | |||
| 7 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | ||
| 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 9 | import { Config, Data, Effect, FileSystem, Logger, Option, Path, Schema } from "effect"; | ||
| 10 | import { Command, Flag } from "effect/unstable/cli"; | ||
| 11 | import { ChildProcess } from "effect/unstable/process"; | ||
| 12 | |||
| 13 | const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); | ||
| 14 | const BuildArch = Schema.Literals(["arm64", "x64", "universal"]); | ||
| 15 | |||
| 16 | const RepoRoot = Effect.service(Path.Path).pipe( | ||
| 17 | Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), | ||
| 18 | ); | ||
| 19 | const IconSource = Effect.zipWith(RepoRoot, Effect.service(Path.Path), (repoRoot, path) => | ||
| 20 | path.join(repoRoot, "assets/macos-icon-1024.png"), | ||
| 21 | ); | ||
| 22 | |||
| 23 | interface PlatformConfig { | ||
| 24 | readonly cliFlag: "--mac" | "--linux" | "--win"; | ||
| 25 | readonly defaultTarget: string; | ||
| 26 | readonly archChoices: ReadonlyArray<typeof BuildArch.Type>; | ||
| 27 | } | ||
| 28 | |||
| 29 | const PLATFORM_CONFIG: Record<typeof BuildPlatform.Type, PlatformConfig> = { | ||
| 30 | mac: { | ||
| 31 | cliFlag: "--mac", | ||
| 32 | defaultTarget: "dmg", | ||
| 33 | archChoices: ["arm64", "x64", "universal"], | ||
| 34 | }, | ||
| 35 | linux: { | ||
| 36 | cliFlag: "--linux", | ||
| 37 | defaultTarget: "AppImage", | ||
| 38 | archChoices: ["x64", "arm64"], | ||
| 39 | }, | ||
| 40 | win: { | ||
| 41 | cliFlag: "--win", | ||
| 42 | defaultTarget: "nsis", | ||
| 43 | archChoices: ["x64", "arm64"], | ||
| 44 | }, | ||
| 45 | }; | ||
| 46 | |||
| 47 | interface BuildCliInput { | ||
| 48 | readonly platform: Option.Option<typeof BuildPlatform.Type>; | ||
| 49 | readonly target: Option.Option<string>; | ||
| 50 | readonly arch: Option.Option<typeof BuildArch.Type>; | ||
| 51 | readonly buildVersion: Option.Option<string>; | ||
| 52 | readonly outputDir: Option.Option<string>; | ||
| 53 | readonly skipBuild: Option.Option<boolean>; | ||
| 54 | readonly keepStage: Option.Option<boolean>; | ||
| 55 | readonly signed: Option.Option<boolean>; | ||
| 56 | readonly verbose: Option.Option<boolean>; | ||
| 57 | } | ||
| 58 | |||
| 59 | function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined { | ||
| 60 | if (hostPlatform === "darwin") return "mac"; | ||
| 61 | if (hostPlatform === "linux") return "linux"; | ||
| 62 | if (hostPlatform === "win32") return "win"; | ||
| 63 | return undefined; | ||
| 64 | } | ||
| 65 | |||
| 66 | function getDefaultArch(platform: typeof BuildPlatform.Type): typeof BuildArch.Type { | ||
| 67 | const config = PLATFORM_CONFIG[platform]; | ||
| 68 | if (!config) { | ||
| 69 | return "x64"; | ||
| 70 | } | ||
| 71 | |||
| 72 | if (process.arch === "arm64" && config.archChoices.includes("arm64")) { | ||
| 73 | return "arm64"; | ||
| 74 | } | ||
| 75 | if (process.arch === "x64" && config.archChoices.includes("x64")) { | ||
| 76 | return "x64"; | ||
| 77 | } | ||
| 78 | |||
| 79 | return config.archChoices[0] ?? "x64"; | ||
| 80 | } | ||
| 81 | |||
| 82 | class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ | ||
| 83 | readonly message: string; | ||
| 84 | readonly cause?: unknown; | ||
| 85 | }> {} | ||
| 86 | |||
| 87 | interface ResolvedBuildOptions { | ||
| 88 | readonly platform: typeof BuildPlatform.Type; | ||
| 89 | readonly target: string; | ||
| 90 | readonly arch: typeof BuildArch.Type; | ||
| 91 | readonly version: string | undefined; | ||
| 92 | readonly outputDir: string; | ||
| 93 | readonly skipBuild: boolean; | ||
| 94 | readonly keepStage: boolean; | ||
| 95 | readonly signed: boolean; | ||
| 96 | readonly verbose: boolean; | ||
| 97 | } | ||
| 98 | |||
| 99 | interface StagePackageJson { | ||
| 100 | readonly name: string; | ||
| 101 | readonly version: string; | ||
| 102 | readonly private: true; | ||
| 103 | readonly description: string; | ||
| 104 | readonly author: string; | ||
| 105 | readonly main: string; | ||
| 106 | readonly build: Record<string, unknown>; | ||
| 107 | readonly dependencies: Record<string, unknown>; | ||
| 108 | readonly devDependencies: { | ||
| 109 | readonly electron: string; | ||
| 110 | }; | ||
| 111 | } | ||
| 112 | |||
| 113 | const AzureTrustedSigningOptionsConfig = Config.all({ | ||
| 114 | publisherName: Config.string("AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"), | ||
| 115 | endpoint: Config.string("AZURE_TRUSTED_SIGNING_ENDPOINT"), | ||
| 116 | certificateProfileName: Config.string("AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME"), | ||
| 117 | codeSigningAccountName: Config.string("AZURE_TRUSTED_SIGNING_ACCOUNT_NAME"), | ||
| 118 | fileDigest: Config.string("AZURE_TRUSTED_SIGNING_FILE_DIGEST").pipe( | ||
| 119 | Config.withDefault(() => "SHA256"), | ||
| 120 | ), | ||
| 121 | timestampDigest: Config.string("AZURE_TRUSTED_SIGNING_TIMESTAMP_DIGEST").pipe( | ||
| 122 | Config.withDefault(() => "SHA256"), | ||
| 123 | ), | ||
| 124 | timestampRfc3161: Config.string("AZURE_TRUSTED_SIGNING_TIMESTAMP_RFC3161").pipe( | ||
| 125 | Config.withDefault(() => "http://timestamp.acs.microsoft.com"), | ||
| 126 | ), | ||
| 127 | }); | ||
| 128 | |||
| 129 | const BuildEnvConfig = Config.all({ | ||
| 130 | platform: Config.schema(BuildPlatform, "T3CODE_DESKTOP_PLATFORM").pipe(Config.option), | ||
| 131 | target: Config.string("T3CODE_DESKTOP_TARGET").pipe(Config.option), | ||
| 132 | arch: Config.schema(BuildArch, "T3CODE_DESKTOP_ARCH").pipe(Config.option), | ||
| 133 | version: Config.string("T3CODE_DESKTOP_VERSION").pipe(Config.option), | ||
| 134 | outputDir: Config.string("T3CODE_DESKTOP_OUTPUT_DIR").pipe(Config.option), | ||
| 135 | skipBuild: Config.boolean("T3CODE_DESKTOP_SKIP_BUILD").pipe(Config.withDefault(() => false)), | ||
| 136 | keepStage: Config.boolean("T3CODE_DESKTOP_KEEP_STAGE").pipe(Config.withDefault(() => false)), | ||
| 137 | signed: Config.boolean("T3CODE_DESKTOP_SIGNED").pipe(Config.withDefault(() => false)), | ||
| 138 | verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(() => false)), | ||
| 139 | }); | ||
| 140 | |||
| 141 | const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) => | ||
| 142 | Option.getOrElse(Option.filter(flag, Boolean), () => envValue); | ||
| 143 | const mergeOptions = <A,>(a: Option.Option<A>, b: Option.Option<A>, defaultValue: A) => | ||
| 144 | Option.getOrElse(a, () => Option.getOrElse(b, () => defaultValue)); | ||
| 145 | |||
| 146 | const resolveBuildOptions = Effect.fn(function* (input: BuildCliInput) { | ||
| 147 | const path = yield* Path.Path; | ||
| 148 | const repoRoot = yield* RepoRoot; | ||
| 149 | const env = yield* BuildEnvConfig.asEffect(); | ||
| 150 | |||
| 151 | const platform = mergeOptions( | ||
| 152 | input.platform, | ||
| 153 | env.platform, | ||
| 154 | detectHostBuildPlatform(process.platform), | ||
| 155 | ); | ||
| 156 | |||
| 157 | if (!platform) { | ||
| 158 | return yield* new BuildScriptError({ | ||
| 159 | message: `Unsupported host platform '${process.platform}'.`, | ||
| 160 | }); | ||
| 161 | } | ||
| 162 | |||
| 163 | const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget); | ||
| 164 | const arch = mergeOptions(input.arch, env.arch, getDefaultArch(platform)); | ||
| 165 | const version = mergeOptions(input.buildVersion, env.version, undefined); | ||
| 166 | const outputDir = path.resolve(repoRoot, mergeOptions(input.outputDir, env.outputDir, "release")); | ||
| 167 | |||
| 168 | const skipBuild = resolveBooleanFlag(input.skipBuild, env.skipBuild); | ||
| 169 | const keepStage = resolveBooleanFlag(input.keepStage, env.keepStage); | ||
| 170 | const signed = resolveBooleanFlag(input.signed, env.signed); | ||
| 171 | const verbose = resolveBooleanFlag(input.verbose, env.verbose); | ||
| 172 | |||
| 173 | return { | ||
| 174 | platform, | ||
| 175 | target, | ||
| 176 | arch, | ||
| 177 | version, | ||
| 178 | outputDir, | ||
| 179 | skipBuild, | ||
| 180 | keepStage, | ||
| 181 | signed, | ||
| 182 | verbose, | ||
| 183 | } satisfies ResolvedBuildOptions; | ||
| 184 | }); | ||
| 185 | |||
| 186 | const commandOutputOptions = (verbose: boolean) => | ||
| 187 | ({ | ||
| 188 | stdout: verbose ? "inherit" : "ignore", | ||
| 189 | stderr: "inherit", | ||
| 190 | }) as const; | ||
| 191 | |||
| 192 | const runCommand = Effect.fn(function* (command: ChildProcess.Command) { | ||
| 193 | const exitCode = yield* ChildProcess.exitCode(command); | ||
| 194 | |||
| 195 | if (exitCode !== 0) { | ||
| 196 | return yield* new BuildScriptError({ | ||
| 197 | message: "Command exited with non-zero exit code", | ||
| 198 | cause: command, | ||
| 199 | }); | ||
| 200 | } | ||
| 201 | }); | ||
| 202 | |||
| 203 | function generateMacIconSet( | ||
| 204 | sourcePng: string, | ||
| 205 | targetIcns: string, | ||
| 206 | tmpRoot: string, | ||
| 207 | path: Path.Path, | ||
| 208 | verbose: boolean, | ||
| 209 | ) { | ||
| 210 | return Effect.gen(function* () { | ||
| 211 | const fs = yield* FileSystem.FileSystem; | ||
| 212 | const iconsetDir = path.join(tmpRoot, "icon.iconset"); | ||
| 213 | yield* fs.makeDirectory(iconsetDir, { recursive: true }); | ||
| 214 | |||
| 215 | const iconSizes = [16, 32, 128, 256, 512] as const; | ||
| 216 | for (const size of iconSizes) { | ||
| 217 | yield* runCommand( | ||
| 218 | ChildProcess.make({ | ||
| 219 | ...commandOutputOptions(verbose), | ||
| 220 | })`sips -z ${size} ${size} ${sourcePng} --out ${path.join(iconsetDir, `icon_${size}x${size}.png`)}`, | ||
| 221 | ); | ||
| 222 | |||
| 223 | const retinaSize = size * 2; | ||
| 224 | yield* runCommand( | ||
| 225 | ChildProcess.make({ | ||
| 226 | ...commandOutputOptions(verbose), | ||
| 227 | })`sips -z ${retinaSize} ${retinaSize} ${sourcePng} --out ${path.join(iconsetDir, `icon_${size}x${size}@2x.png`)}`, | ||
| 228 | ); | ||
| 229 | } | ||
| 230 | |||
| 231 | yield* runCommand( | ||
| 232 | ChildProcess.make({ | ||
| 233 | ...commandOutputOptions(verbose), | ||
| 234 | })`iconutil -c icns ${iconsetDir} -o ${targetIcns}`, | ||
| 235 | ); | ||
| 236 | }); | ||
| 237 | } | ||
| 238 | |||
| 239 | function stageMacIcons(stageResourcesDir: string, verbose: boolean) { | ||
| 240 | return Effect.gen(function* () { | ||
| 241 | const fs = yield* FileSystem.FileSystem; | ||
| 242 | const path = yield* Path.Path; | ||
| 243 | const iconSource = yield* IconSource; | ||
| 244 | if (!(yield* fs.exists(iconSource))) { | ||
| 245 | return yield* new BuildScriptError({ | ||
| 246 | message: `Production icon source is missing at ${iconSource}`, | ||
| 247 | }); | ||
| 248 | } | ||
| 249 | |||
| 250 | const tmpRoot = yield* fs.makeTempDirectoryScoped({ | ||
| 251 | prefix: "t3code-icon-build-", | ||
| 252 | }); | ||
| 253 | |||
| 254 | const iconPngPath = path.join(stageResourcesDir, "icon.png"); | ||
| 255 | const iconIcnsPath = path.join(stageResourcesDir, "icon.icns"); | ||
| 256 | |||
| 257 | yield* runCommand( | ||
| 258 | ChildProcess.make({ | ||
| 259 | ...commandOutputOptions(verbose), | ||
| 260 | })`sips -z 512 512 ${iconSource} --out ${iconPngPath}`, | ||
| 261 | ); | ||
| 262 | |||
| 263 | yield* generateMacIconSet(iconSource, iconIcnsPath, tmpRoot, path, verbose); | ||
| 264 | }); | ||
| 265 | } | ||
| 266 | |||
| 267 | function validateBundledClientAssets(clientDir: string) { | ||
| 268 | return Effect.gen(function* () { | ||
| 269 | const fs = yield* FileSystem.FileSystem; | ||
| 270 | const path = yield* Path.Path; | ||
| 271 | const indexPath = path.join(clientDir, "index.html"); | ||
| 272 | const indexHtml = yield* fs.readFileString(indexPath); | ||
| 273 | const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map( | ||
| 274 | (match) => match[1], | ||
| 275 | ); | ||
| 276 | const missing: string[] = []; | ||
| 277 | |||
| 278 | for (const ref of refs) { | ||
| 279 | const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? ""; | ||
| 280 | if (!normalizedRef) continue; | ||
| 281 | if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue; | ||
| 282 | if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue; | ||
| 283 | |||
| 284 | const ext = path.extname(normalizedRef); | ||
| 285 | if (!ext) continue; | ||
| 286 | |||
| 287 | const relativePath = normalizedRef.replace(/^\/+/, ""); | ||
| 288 | const assetPath = path.join(clientDir, relativePath); | ||
| 289 | if (!(yield* fs.exists(assetPath))) { | ||
| 290 | missing.push(normalizedRef); | ||
| 291 | } | ||
| 292 | } | ||
| 293 | |||
| 294 | if (missing.length > 0) { | ||
| 295 | const preview = missing.slice(0, 6).join(", "); | ||
| 296 | const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : ""; | ||
| 297 | return yield* new BuildScriptError({ | ||
| 298 | message: `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`, | ||
| 299 | }); | ||
| 300 | } | ||
| 301 | }); | ||
| 302 | } | ||
| 303 | |||
| 304 | function resolveCatalogDependencies( | ||
| 305 | dependencies: Record<string, unknown>, | ||
| 306 | catalog: Record<string, unknown>, | ||
| 307 | dependencySourceLabel: string, | ||
| 308 | ): Record<string, unknown> { | ||
| 309 | return Object.fromEntries( | ||
| 310 | Object.entries(dependencies).map(([dependencyName, spec]) => { | ||
| 311 | if (typeof spec !== "string" || !spec.startsWith("catalog:")) { | ||
| 312 | return [dependencyName, spec]; | ||
| 313 | } | ||
| 314 | |||
| 315 | const catalogKey = spec.slice("catalog:".length).trim(); | ||
| 316 | const lookupKey = catalogKey.length > 0 ? catalogKey : dependencyName; | ||
| 317 | const resolvedSpec = catalog[lookupKey]; | ||
| 318 | if (typeof resolvedSpec !== "string" || resolvedSpec.length === 0) { | ||
| 319 | throw new BuildScriptError({ | ||
| 320 | message: `Unable to resolve '${spec}' for ${dependencySourceLabel} dependency '${dependencyName}'. Expected key '${lookupKey}' in root workspace catalog.`, | ||
| 321 | }); | ||
| 322 | } | ||
| 323 | |||
| 324 | return [dependencyName, resolvedSpec]; | ||
| 325 | }), | ||
| 326 | ); | ||
| 327 | } | ||
| 328 | |||
| 329 | const createBuildConfig = Effect.fn(function* ( | ||
| 330 | platform: typeof BuildPlatform.Type, | ||
| 331 | target: string, | ||
| 332 | productName: string, | ||
| 333 | signed: boolean, | ||
| 334 | ) { | ||
| 335 | const buildConfig: Record<string, unknown> = { | ||
| 336 | appId: "com.t3tools.t3code", | ||
| 337 | productName, | ||
| 338 | artifactName: "T3-Code-${version}-${arch}.${ext}", | ||
| 339 | directories: { | ||
| 340 | buildResources: "apps/desktop/resources", | ||
| 341 | }, | ||
| 342 | }; | ||
| 343 | |||
| 344 | if (platform === "mac") { | ||
| 345 | buildConfig.mac = { | ||
| 346 | target: [target], | ||
| 347 | icon: "icon.icns", | ||
| 348 | category: "public.app-category.developer-tools", | ||
| 349 | }; | ||
| 350 | } | ||
| 351 | |||
| 352 | if (platform === "linux") { | ||
| 353 | buildConfig.linux = { | ||
| 354 | target: [target], | ||
| 355 | icon: "icon.png", | ||
| 356 | category: "Development", | ||
| 357 | }; | ||
| 358 | } | ||
| 359 | |||
| 360 | if (platform === "win") { | ||
| 361 | const winConfig: Record<string, unknown> = { | ||
| 362 | target: [target], | ||
| 363 | icon: "icon.ico", | ||
| 364 | }; | ||
| 365 | if (signed) { | ||
| 366 | winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig; | ||
| 367 | } | ||
| 368 | buildConfig.win = winConfig; | ||
| 369 | } | ||
| 370 | |||
| 371 | return buildConfig; | ||
| 372 | }); | ||
| 373 | |||
| 374 | const assertPlatformBuildResources = Effect.fn(function* ( | ||
| 375 | platform: typeof BuildPlatform.Type, | ||
| 376 | stageResourcesDir: string, | ||
| 377 | verbose: boolean, | ||
| 378 | ) { | ||
| 379 | const fs = yield* FileSystem.FileSystem; | ||
| 380 | const path = yield* Path.Path; | ||
| 381 | |||
| 382 | if (platform === "mac") { | ||
| 383 | yield* stageMacIcons(stageResourcesDir, verbose); | ||
| 384 | return; | ||
| 385 | } | ||
| 386 | |||
| 387 | if (platform === "linux") { | ||
| 388 | const iconPath = path.join(stageResourcesDir, "icon.png"); | ||
| 389 | if (!(yield* fs.exists(iconPath))) { | ||
| 390 | return yield* new BuildScriptError({ | ||
| 391 | message: `Missing Linux icon at ${iconPath}`, | ||
| 392 | }); | ||
| 393 | } | ||
| 394 | return; | ||
| 395 | } | ||
| 396 | |||
| 397 | if (platform === "win") { | ||
| 398 | const iconPath = path.join(stageResourcesDir, "icon.ico"); | ||
| 399 | if (!(yield* fs.exists(iconPath))) { | ||
| 400 | return yield* new BuildScriptError({ | ||
| 401 | message: `Missing Windows icon at ${iconPath}`, | ||
| 402 | }); | ||
| 403 | } | ||
| 404 | } | ||
| 405 | }); | ||
| 406 | |||
| 407 | const buildDesktopArtifact = Effect.fn(function* (options: ResolvedBuildOptions) { | ||
| 408 | const repoRoot = yield* RepoRoot; | ||
| 409 | const path = yield* Path.Path; | ||
| 410 | const fs = yield* FileSystem.FileSystem; | ||
| 411 | |||
| 412 | const platformConfig = PLATFORM_CONFIG[options.platform]; | ||
| 413 | if (!platformConfig) { | ||
| 414 | return yield* new BuildScriptError({ | ||
| 415 | message: `Unsupported platform '${options.platform}'.`, | ||
| 416 | }); | ||
| 417 | } | ||
| 418 | |||
| 419 | const electronVersion = desktopPackageJson.dependencies.electron; | ||
| 420 | |||
| 421 | const dependencies = serverPackageJson.dependencies; | ||
| 422 | if (!dependencies || Object.keys(dependencies).length === 0) { | ||
| 423 | return yield* new BuildScriptError({ | ||
| 424 | message: "Could not resolve production dependencies from apps/server/package.json.", | ||
| 425 | }); | ||
| 426 | } | ||
| 427 | |||
| 428 | const resolvedDependencies = yield* Effect.try({ | ||
| 429 | try: () => | ||
| 430 | resolveCatalogDependencies(dependencies, rootPackageJson.workspaces.catalog, "apps/server"), | ||
| 431 | catch: (cause) => | ||
| 432 | new BuildScriptError({ | ||
| 433 | message: "Could not resolve production dependencies from apps/server/package.json.", | ||
| 434 | cause, | ||
| 435 | }), | ||
| 436 | }); | ||
| 437 | |||
| 438 | const appVersion = options.version ?? serverPackageJson.version; | ||
| 439 | const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped; | ||
| 440 | const stageRoot = yield* mkdir({ | ||
| 441 | prefix: `t3code-desktop-${options.platform}-stage-`, | ||
| 442 | }); | ||
| 443 | |||
| 444 | const stageAppDir = path.join(stageRoot, "app"); | ||
| 445 | const stageResourcesDir = path.join(stageAppDir, "apps/desktop/resources"); | ||
| 446 | const distDirs = { | ||
| 447 | desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"), | ||
| 448 | desktopResources: path.join(repoRoot, "apps/desktop/resources"), | ||
| 449 | serverDist: path.join(repoRoot, "apps/server/dist"), | ||
| 450 | }; | ||
| 451 | const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html"); | ||
| 452 | |||
| 453 | if (!options.skipBuild) { | ||
| 454 | yield* Effect.log("[desktop-artifact] Building desktop/server/web artifacts..."); | ||
| 455 | yield* runCommand( | ||
| 456 | ChildProcess.make({ | ||
| 457 | cwd: repoRoot, | ||
| 458 | ...commandOutputOptions(options.verbose), | ||
| 459 | })`bun run build:desktop`, | ||
| 460 | ); | ||
| 461 | } | ||
| 462 | |||
| 463 | for (const [label, dir] of Object.entries(distDirs)) { | ||
| 464 | if (!(yield* fs.exists(dir))) { | ||
| 465 | return yield* new BuildScriptError({ | ||
| 466 | message: `Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`, | ||
| 467 | }); | ||
| 468 | } | ||
| 469 | } | ||
| 470 | |||
| 471 | if (!(yield* fs.exists(bundledClientEntry))) { | ||
| 472 | return yield* new BuildScriptError({ | ||
| 473 | message: `Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`, | ||
| 474 | }); | ||
| 475 | } | ||
| 476 | |||
| 477 | yield* validateBundledClientAssets(path.dirname(bundledClientEntry)); | ||
| 478 | |||
| 479 | yield* fs.makeDirectory(path.join(stageAppDir, "apps/desktop"), { recursive: true }); | ||
| 480 | yield* fs.makeDirectory(path.join(stageAppDir, "apps/server"), { recursive: true }); | ||
| 481 | |||
| 482 | yield* Effect.log("[desktop-artifact] Staging release app..."); | ||
| 483 | yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron")); | ||
| 484 | yield* fs.copy(distDirs.desktopResources, stageResourcesDir); | ||
| 485 | yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist")); | ||
| 486 | |||
| 487 | yield* assertPlatformBuildResources(options.platform, stageResourcesDir, options.verbose); | ||
| 488 | |||
| 489 | const stagePackageJson: StagePackageJson = { | ||
| 490 | name: "t3-code-desktop", | ||
| 491 | version: appVersion, | ||
| 492 | private: true, | ||
| 493 | description: "T3 Code desktop build", | ||
| 494 | author: "T3 Tools", | ||
| 495 | main: "apps/desktop/dist-electron/main.js", | ||
| 496 | build: yield* createBuildConfig( | ||
| 497 | options.platform, | ||
| 498 | options.target, | ||
| 499 | desktopPackageJson.productName ?? "T3 Code", | ||
| 500 | options.signed, | ||
| 501 | ), | ||
| 502 | dependencies: resolvedDependencies, | ||
| 503 | devDependencies: { | ||
| 504 | electron: electronVersion, | ||
| 505 | }, | ||
| 506 | }; | ||
| 507 | |||
| 508 | yield* fs.writeFileString( | ||
| 509 | path.join(stageAppDir, "package.json"), | ||
| 510 | `${JSON.stringify(stagePackageJson, null, 2)}\n`, | ||
| 511 | ); | ||
| 512 | |||
| 513 | yield* Effect.log("[desktop-artifact] Installing staged production dependencies..."); | ||
| 514 | yield* runCommand( | ||
| 515 | ChildProcess.make({ | ||
| 516 | cwd: stageAppDir, | ||
| 517 | ...commandOutputOptions(options.verbose), | ||
| 518 | })`bun install --production`, | ||
| 519 | ); | ||
| 520 | |||
| 521 | const buildEnv: NodeJS.ProcessEnv = { | ||
| 522 | ...process.env, | ||
| 523 | }; | ||
| 524 | if (!options.signed) { | ||
| 525 | buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; | ||
| 526 | } | ||
| 527 | |||
| 528 | yield* Effect.log( | ||
| 529 | `[desktop-artifact] Building ${options.platform}/${options.target} (arch=${options.arch}, version=${appVersion})...`, | ||
| 530 | ); | ||
| 531 | yield* runCommand( | ||
| 532 | ChildProcess.make({ | ||
| 533 | cwd: stageAppDir, | ||
| 534 | env: buildEnv, | ||
| 535 | ...commandOutputOptions(options.verbose), | ||
| 536 | })`bunx --bun electron-builder ${platformConfig.cliFlag} ${options.target} --${options.arch} --publish never`, | ||
| 537 | ); | ||
| 538 | |||
| 539 | const stageDistDir = path.join(stageAppDir, "dist"); | ||
| 540 | if (!(yield* fs.exists(stageDistDir))) { | ||
| 541 | return yield* new BuildScriptError({ | ||
| 542 | message: `Build completed but dist directory was not found at ${stageDistDir}`, | ||
| 543 | }); | ||
| 544 | } | ||
| 545 | |||
| 546 | const stageEntries = yield* fs.readDirectory(stageDistDir); | ||
| 547 | yield* fs.makeDirectory(options.outputDir, { recursive: true }); | ||
| 548 | |||
| 549 | const copiedArtifacts: string[] = []; | ||
| 550 | for (const entry of stageEntries) { | ||
| 551 | const from = path.join(stageDistDir, entry); | ||
| 552 | const stat = yield* fs.stat(from).pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 553 | if (!stat || stat.type !== "File") continue; | ||
| 554 | |||
| 555 | const to = path.join(options.outputDir, entry); | ||
| 556 | yield* fs.copyFile(from, to); | ||
| 557 | copiedArtifacts.push(to); | ||
| 558 | } | ||
| 559 | |||
| 560 | if (copiedArtifacts.length === 0) { | ||
| 561 | return yield* new BuildScriptError({ | ||
| 562 | message: `Build completed but no files were produced in ${stageDistDir}`, | ||
| 563 | }); | ||
| 564 | } | ||
| 565 | |||
| 566 | yield* Effect.log("[desktop-artifact] Done. Artifacts:").pipe( | ||
| 567 | Effect.annotateLogs({ artifacts: copiedArtifacts }), | ||
| 568 | ); | ||
| 569 | }); | ||
| 570 | |||
| 571 | const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { | ||
| 572 | platform: Flag.choice("platform", BuildPlatform.literals).pipe( | ||
| 573 | Flag.withDescription("Build platform (env: T3CODE_DESKTOP_PLATFORM)."), | ||
| 574 | Flag.optional, | ||
| 575 | ), | ||
| 576 | target: Flag.string("target").pipe( | ||
| 577 | Flag.withDescription( | ||
| 578 | "Artifact target, for example dmg/AppImage/nsis (env: T3CODE_DESKTOP_TARGET).", | ||
| 579 | ), | ||
| 580 | Flag.optional, | ||
| 581 | ), | ||
| 582 | arch: Flag.choice("arch", BuildArch.literals).pipe( | ||
| 583 | Flag.withDescription("Build arch, for example arm64/x64/universal (env: T3CODE_DESKTOP_ARCH)."), | ||
| 584 | Flag.optional, | ||
| 585 | ), | ||
| 586 | buildVersion: Flag.string("build-version").pipe( | ||
| 587 | Flag.withDescription("Artifact version metadata (env: T3CODE_DESKTOP_VERSION)."), | ||
| 588 | Flag.optional, | ||
| 589 | ), | ||
| 590 | outputDir: Flag.string("output-dir").pipe( | ||
| 591 | Flag.withDescription("Output directory for artifacts (env: T3CODE_DESKTOP_OUTPUT_DIR)."), | ||
| 592 | Flag.optional, | ||
| 593 | ), | ||
| 594 | skipBuild: Flag.boolean("skip-build").pipe( | ||
| 595 | Flag.withDescription( | ||
| 596 | "Skip `bun run build:desktop` and use existing dist artifacts (env: T3CODE_DESKTOP_SKIP_BUILD).", | ||
| 597 | ), | ||
| 598 | Flag.optional, | ||
| 599 | ), | ||
| 600 | keepStage: Flag.boolean("keep-stage").pipe( | ||
| 601 | Flag.withDescription("Keep temporary staging files (env: T3CODE_DESKTOP_KEEP_STAGE)."), | ||
| 602 | Flag.optional, | ||
| 603 | ), | ||
| 604 | signed: Flag.boolean("signed").pipe( | ||
| 605 | Flag.withDescription( | ||
| 606 | "Enable signing/notarization discovery; Windows uses Azure Trusted Signing (env: T3CODE_DESKTOP_SIGNED).", | ||
| 607 | ), | ||
| 608 | Flag.optional, | ||
| 609 | ), | ||
| 610 | verbose: Flag.boolean("verbose").pipe( | ||
| 611 | Flag.withDescription("Stream subprocess stdout (env: T3CODE_DESKTOP_VERBOSE)."), | ||
| 612 | Flag.optional, | ||
| 613 | ), | ||
| 614 | }).pipe( | ||
| 615 | Command.withDescription("Build a desktop artifact for T3 Code."), | ||
| 616 | Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)), | ||
| 617 | ); | ||
| 618 | |||
| 619 | Command.run(buildDesktopArtifactCli, { version: "0.0.0" }).pipe( | ||
| 620 | Effect.scoped, | ||
| 621 | Effect.provide(Logger.layer([Logger.consolePretty()])), | ||
| 622 | Effect.provide(NodeServices.layer), | ||
| 623 | NodeRuntime.runMain, | ||
| 624 | ); | ||