1#!/usr/bin/env node
2import { fingerprintForkRuntime, fingerprintForkServerRuntime } from "./lib/fork-runtime.ts";
3import { FORK_RUNTIME_MANIFEST_FILE } from "@t3tools/shared/forkUpdateDirectory";
4// @effect-diagnostics nodeBuiltinImport:off - Node's typed junction API avoids Windows symlink privileges while keeping the probe isolated.
5
6import * as NodeFSP from "node:fs/promises";
7import * as NodeCrypto from "node:crypto";
8import * as NodeModule from "node:module";
9// plist is CommonJS; Node cannot load its named exports from an ES module.
10import Plist from "plist";
11
12import {
13 createPackageWithOptions,
14 extractAll,
15 getRawHeader,
16 statFile,
17 type DirectoryRecord,
18} from "@electron/asar";
19
20import { fromYaml } from "@t3tools/shared/schemaYaml";
21import * as HostProcess from "@t3tools/shared/HostProcess";
22import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth";
23import { resolveSpawnCommand } from "@t3tools/shared/shell";
24import rootPackageJson from "../package.json" with { type: "json" };
25import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" };
26import gnomeCaptureBundle from "../apps/desktop/gnome-extension/bundle.json" with { type: "json" };
27import serverPackageJson from "../apps/server/package.json" with { type: "json" };
28
29import { applyWebBrandAssets } from "./apply-web-brand-assets.ts";
30import {
31 BRAND_ASSET_PATHS,
32 resolveWebAssetBrandForChannel,
33 type WebAssetBrand,
34} from "./lib/brand-assets.ts";
35import { getDefaultBuildArch } from "./lib/build-target-arch.ts";
36import {
37 findInlinedExternalPackages,
38 selectCliRuntimeExternalDependencies,
39} from "./lib/cli-external-packages.ts";
40import { loadRepoEnv } from "./lib/public-config.ts";
41import { selectDesktopRuntimeExternalDependencies } from "./lib/desktop-external-packages.ts";
42import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts";
43
44import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
45import * as NodeServices from "@effect/platform-node/NodeServices";
46import * as Config from "effect/Config";
47import * as Duration from "effect/Duration";
48import * as Effect from "effect/Effect";
49import * as FileSystem from "effect/FileSystem";
50import * as Layer from "effect/Layer";
51import * as Logger from "effect/Logger";
52import * as Option from "effect/Option";
53import type { PlatformError } from "effect/PlatformError";
54import * as Path from "effect/Path";
55import * as Schema from "effect/Schema";
56import * as Stream from "effect/Stream";
57import { Command, Flag } from "effect/cli";
58import { ChildProcess, ChildProcessSpawner } from "effect/process";
59
60const LINUX_ICON_SIZES = [16, 22, 24, 32, 48, 64, 128, 256, 512] as const;
61const DESKTOP_APP_ID = "com.t3tools.t3code";
62const APPLE_TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/u;
63
64const BuildPlatform = Schema.Literals(["mac", "linux", "win"]);
65const BuildArch = Schema.Literals(["arm64", "x64", "universal"]);
66
67const WorkspaceConfig = Schema.Struct({
68 catalog: Schema.optional(Schema.Record(Schema.String, Schema.String)),
69 overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)),
70 patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
71 allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
72});
73type WorkspaceConfig = typeof WorkspaceConfig.Type;
74
75const StageWorkspaceConfig = Schema.Struct({
76 supportedArchitectures: Schema.Struct({
77 os: Schema.Array(Schema.String),
78 cpu: Schema.Array(Schema.String),
79 libc: Schema.optional(Schema.Array(Schema.String)),
80 }),
81 // pnpm 11 only reads these from pnpm-workspace.yaml (not package.json#pnpm).
82 // Without allowBuilds the staged `vp install --prod` fails with
83 // ERR_PNPM_IGNORED_BUILDS for packages that have lifecycle scripts.
84 allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
85 patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
86 overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)),
87 packageExtensions: Schema.optional(
88 Schema.Record(
89 Schema.String,
90 Schema.Struct({
91 peerDependenciesMeta: Schema.Record(
92 Schema.String,
93 Schema.Struct({ optional: Schema.Boolean }),
94 ),
95 }),
96 ),
97 ),
98 nodeLinker: Schema.optional(Schema.Literals(["hoisted"])),
99});
100type StageWorkspaceConfig = typeof StageWorkspaceConfig.Type;
101
102// electron-webauthn declares TypeScript as a peer only for its typings. pnpm
103// auto-installs missing peers, which would ship a compiler inside the app.
104const STAGE_PACKAGE_EXTENSIONS = {
105 "electron-webauthn": { peerDependenciesMeta: { typescript: { optional: true } } },
106 "@electron-webauthn/macos": { peerDependenciesMeta: { typescript: { optional: true } } },
107} as const;
108
109const RepoRoot = Effect.service(Path.Path).pipe(
110 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
111);
112const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
113const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig));
114const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig));
115
116// Decodes the repo's pnpm-workspace.yaml. A PR CI test calls this too, so a bad
117// entry fails the PR instead of the nightly desktop build.
118export const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () {
119 const fs = yield* FileSystem.FileSystem;
120 const path = yield* Path.Path;
121 const repoRoot = yield* RepoRoot;
122 const workspaceYaml = yield* fs.readFileString(path.join(repoRoot, "pnpm-workspace.yaml"));
123 return yield* decodeWorkspaceConfig(workspaceYaml);
124});
125
126interface DesktopBuildIconAssets {
127 readonly macIconPng: string;
128 readonly linuxIconPng: string;
129 readonly windowsIconIco: string;
130}
131
132interface PlatformConfig {
133 readonly cliFlag: "--mac" | "--linux" | "--win";
134 readonly defaultTarget: string;
135 readonly archChoices: ReadonlyArray<typeof BuildArch.Type>;
136}
137
138export function resolveResourceMonitorRustTargets(
139 platform: typeof BuildPlatform.Type,
140 arch: typeof BuildArch.Type,
141): ReadonlyArray<string> {
142 if (platform === "mac") {
143 if (arch === "universal") {
144 return ["aarch64-apple-darwin", "x86_64-apple-darwin"];
145 }
146 return [arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"];
147 }
148 if (platform === "linux") {
149 return [arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"];
150 }
151 return [arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc"];
152}
153
154export function resourceMonitorExecutableName(platform: typeof BuildPlatform.Type): string {
155 return platform === "win" ? "t3-resource-monitor.exe" : "t3-resource-monitor";
156}
157
158const PLATFORM_CONFIG: Record<typeof BuildPlatform.Type, PlatformConfig> = {
159 mac: {
160 cliFlag: "--mac",
161 defaultTarget: "dmg",
162 archChoices: ["arm64", "x64", "universal"],
163 },
164 linux: {
165 cliFlag: "--linux",
166 defaultTarget: "AppImage",
167 archChoices: ["x64", "arm64"],
168 },
169 win: {
170 cliFlag: "--win",
171 defaultTarget: "nsis",
172 archChoices: ["x64", "arm64"],
173 },
174};
175
176interface BuildCliInput {
177 readonly platform: Option.Option<typeof BuildPlatform.Type>;
178 readonly target: Option.Option<string>;
179 readonly arch: Option.Option<typeof BuildArch.Type>;
180 readonly buildVersion: Option.Option<string>;
181 readonly outputDir: Option.Option<string>;
182 readonly skipBuild: Option.Option<boolean>;
183 readonly keepStage: Option.Option<boolean>;
184 readonly signed: Option.Option<boolean>;
185 readonly verbose: Option.Option<boolean>;
186 readonly mockUpdates: Option.Option<boolean>;
187 readonly mockUpdateServerPort: Option.Option<number>;
188 readonly wslRuntime: Option.Option<string>;
189}
190
191function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined {
192 if (hostPlatform === "darwin") return "mac";
193 if (hostPlatform === "linux") return "linux";
194 if (hostPlatform === "win32") return "win";
195 return undefined;
196}
197
198const getDefaultArch = Effect.fn("getDefaultArch")(function* (platform: typeof BuildPlatform.Type) {
199 const config = PLATFORM_CONFIG[platform];
200 if (!config) {
201 return "x64";
202 }
203
204 return yield* getDefaultBuildArch(platform, config);
205});
206
207export class MacPasskeySigningConfigurationResolutionError extends Schema.TaggedError<MacPasskeySigningConfigurationResolutionError>()(
208 "MacPasskeySigningConfigurationResolutionError",
209 {
210 cause: Schema.Defect(),
211 },
212) {
213 static fromCause(
214 cause: unknown,
215 ): MacPasskeySigningConfigurationError | MacPasskeySigningConfigurationResolutionError {
216 return isMacPasskeySigningConfigurationError(cause)
217 ? cause
218 : new MacPasskeySigningConfigurationResolutionError({ cause });
219 }
220
221 override get message(): string {
222 return "Failed to resolve macOS passkey signing configuration.";
223 }
224}
225
226export class KeyringNativePackageMissingError extends Schema.TaggedError<KeyringNativePackageMissingError>()(
227 "KeyringNativePackageMissingError",
228 {
229 packageName: Schema.String,
230 binaryFileName: Schema.String,
231 packageEntryPath: Schema.String,
232 platform: BuildPlatform,
233 arch: BuildArch,
234 cause: Schema.Defect(),
235 },
236) {
237 override get message(): string {
238 return `Keyring native package is missing: ${this.packageName}`;
239 }
240}
241
242export class ClerkPasskeyNativePackageMissingError extends Schema.TaggedError<ClerkPasskeyNativePackageMissingError>()(
243 "ClerkPasskeyNativePackageMissingError",
244 {
245 packageName: Schema.String,
246 binaryFileName: Schema.String,
247 packageEntryPath: Schema.String,
248 platform: BuildPlatform,
249 arch: BuildArch,
250 cause: Schema.Defect(),
251 },
252) {
253 override get message(): string {
254 return `Clerk passkey native package is missing: ${this.packageName}`;
255 }
256}
257
258export class UnsupportedHostBuildPlatformError extends Schema.TaggedError<UnsupportedHostBuildPlatformError>()(
259 "UnsupportedHostBuildPlatformError",
260 {
261 hostPlatform: Schema.String,
262 },
263) {
264 override get message(): string {
265 return `Unsupported host platform '${this.hostPlatform}'.`;
266 }
267}
268
269export class UnsupportedDesktopBuildArchitectureError extends Schema.TaggedError<UnsupportedDesktopBuildArchitectureError>()(
270 "UnsupportedDesktopBuildArchitectureError",
271 {
272 platform: BuildPlatform,
273 arch: BuildArch,
274 supportedArchitectures: Schema.Array(BuildArch),
275 },
276) {
277 override get message(): string {
278 return `Unsupported architecture '${this.arch}' for ${this.platform}.`;
279 }
280}
281
282const InvalidMockUpdateServerPortReason = Schema.Literals([
283 "not-numeric",
284 "not-integer",
285 "out-of-range",
286]);
287
288export class InvalidMockUpdateServerPortError extends Schema.TaggedError<InvalidMockUpdateServerPortError>()(
289 "InvalidMockUpdateServerPortError",
290 {
291 reason: InvalidMockUpdateServerPortReason,
292 inputLength: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
293 cause: Schema.Defect(),
294 },
295) {
296 override get message(): string {
297 return "Invalid mock update server port.";
298 }
299
300 static fromConfigValue(configuredPort: string, cause: unknown) {
301 return new InvalidMockUpdateServerPortError({
302 reason: invalidMockUpdateServerPortReason(configuredPort),
303 inputLength: configuredPort.length,
304 cause,
305 });
306 }
307}
308
309export class BuildCommandFailedError extends Schema.TaggedError<BuildCommandFailedError>()(
310 "BuildCommandFailedError",
311 {
312 command: Schema.String,
313 exitCode: Schema.Int,
314 stdoutTail: Schema.optionalKey(Schema.String),
315 stderrTail: Schema.optionalKey(Schema.String),
316 },
317) {
318 override get message(): string {
319 const outputSections = [
320 `Command: ${this.command}`,
321 formatOutputSection("stdout", this.stdoutTail ?? ""),
322 formatOutputSection("stderr", this.stderrTail ?? ""),
323 ].filter((section): section is string => section !== undefined);
324 const outputSuffix = outputSections.length > 0 ? `\n\n${outputSections.join("\n\n")}` : "";
325 return `Command exited with non-zero exit code (${this.exitCode})${outputSuffix}`;
326 }
327}
328
329export const LINUX_DESKTOP_BUILD_PREREQUISITES = [
330 { id: "cargo", description: "Rust compiler and Cargo", packages: ["cargo", "rustc"] },
331 { id: "rust-target", description: "Requested Rust standard library", packages: [] },
332 { id: "cc", description: "C/C++ build toolchain", packages: ["build-essential"] },
333 { id: "make", description: "Make", packages: ["build-essential"] },
334 {
335 id: "libsecret",
336 description: "libsecret development headers and pkg-config",
337 packages: ["libsecret-1-dev", "pkg-config"],
338 },
339 { id: "imagemagick", description: "ImageMagick", packages: ["imagemagick"] },
340] as const;
341
342export class LinuxDesktopBuildPrerequisitesMissingError extends Schema.TaggedError<LinuxDesktopBuildPrerequisitesMissingError>()(
343 "LinuxDesktopBuildPrerequisitesMissingError",
344 {
345 missing: Schema.Array(Schema.String),
346 rustTarget: Schema.String,
347 },
348) {
349 override get message(): string {
350 const missingRequirements = LINUX_DESKTOP_BUILD_PREREQUISITES.filter((requirement) =>
351 this.missing.includes(requirement.id),
352 );
353 const details = missingRequirements
354 .map((requirement) =>
355 requirement.packages.length > 0
356 ? ` - ${requirement.description} (${requirement.packages.join(", ")})`
357 : ` - ${requirement.description}`,
358 )
359 .join("\n");
360 const packages = [
361 ...new Set(missingRequirements.flatMap((requirement) => requirement.packages)),
362 ];
363 return [
364 "Linux desktop build prerequisites are missing:",
365 details,
366 "",
367 ...(packages.length > 0
368 ? ["On Ubuntu/Debian, install them with:", ` sudo apt-get install ${packages.join(" ")}`]
369 : []),
370 ...(this.missing.includes("rust-target")
371 ? ["Add the requested Rust target with:", ` rustup target add ${this.rustTarget}`]
372 : []),
373 "",
374 "For other distributions, see docs/operations/development.md#linux-appimage-prerequisites.",
375 "Then rerun `vp run dist:desktop:linux`.",
376 ].join("\n");
377 }
378}
379
380const MAC_DESKTOP_BUILD_PREREQUISITES = [
381 { id: "rust", description: "Rust/Cargo and the requested Rust target" },
382 { id: "clang", description: "Xcode Command Line Tools (clang)" },
383 { id: "make", description: "Xcode Command Line Tools (make)" },
384 { id: "sips", description: "macOS image tool (sips)" },
385 { id: "iconutil", description: "macOS icon tool (iconutil)" },
386 { id: "lipo", description: "Xcode universal-binary tool (lipo)" },
387] as const;
388
389export class MacDesktopBuildPrerequisitesMissingError extends Schema.TaggedError<MacDesktopBuildPrerequisitesMissingError>()(
390 "MacDesktopBuildPrerequisitesMissingError",
391 {
392 missing: Schema.Array(Schema.String),
393 rustTargets: Schema.Array(Schema.String),
394 },
395) {
396 override get message(): string {
397 const details = MAC_DESKTOP_BUILD_PREREQUISITES.filter((requirement) =>
398 this.missing.includes(requirement.id),
399 )
400 .map((requirement) => ` - ${requirement.description}`)
401 .join("\n");
402 return [
403 "macOS desktop build prerequisites are missing:",
404 details,
405 "",
406 "Install Apple's build tools with:",
407 " xcode-select --install",
408 "Install Rust from https://rustup.rs, then add the requested target(s):",
409 ` rustup target add ${this.rustTargets.join(" ")}`,
410 "",
411 "Then rerun the desktop artifact command.",
412 ].join("\n");
413 }
414}
415
416const WINDOWS_DESKTOP_BUILD_PREREQUISITES = [
417 { id: "rust", description: "Rust/Cargo and the requested MSVC Rust target" },
418 { id: "python", description: "Python 3 for node-gyp" },
419 {
420 id: "msvc",
421 description: "Visual Studio Build Tools with C++, Windows SDK, and Spectre libraries",
422 },
423 { id: "tar", description: "tar to inspect the bundled WSL runtime archive" },
424] as const;
425
426export class WindowsDesktopBuildPrerequisitesMissingError extends Schema.TaggedError<WindowsDesktopBuildPrerequisitesMissingError>()(
427 "WindowsDesktopBuildPrerequisitesMissingError",
428 {
429 missing: Schema.Array(Schema.String),
430 rustTarget: Schema.String,
431 },
432) {
433 override get message(): string {
434 const details = WINDOWS_DESKTOP_BUILD_PREREQUISITES.filter((requirement) =>
435 this.missing.includes(requirement.id),
436 )
437 .map((requirement) => ` - ${requirement.description}`)
438 .join("\n");
439 return [
440 "Windows desktop build prerequisites are missing:",
441 details,
442 "",
443 "Install Rust from https://rustup.rs and add the requested target:",
444 ` rustup target add ${this.rustTarget}`,
445 "Install Python 3 and the Visual Studio Build Tools components listed in",
446 "docs/operations/development.md#windows-installer-prerequisites.",
447 "",
448 "Then rerun the desktop artifact command.",
449 ].join("\n");
450 }
451}
452
453export class ResourceMonitorBuildOutputMissingError extends Schema.TaggedError<ResourceMonitorBuildOutputMissingError>()(
454 "ResourceMonitorBuildOutputMissingError",
455 {
456 binaryPath: Schema.String,
457 rustTarget: Schema.String,
458 platform: BuildPlatform,
459 arch: BuildArch,
460 },
461) {
462 override get message(): string {
463 return `Resource monitor build for ${this.rustTarget} did not produce ${this.binaryPath}.`;
464 }
465}
466
467const desktopIconPlatformNames = {
468 mac: "macOS",
469 linux: "Linux",
470 win: "Windows",
471} satisfies Record<typeof BuildPlatform.Type, string>;
472
473export class LinuxBrowserSecretHostError extends Schema.TaggedError<LinuxBrowserSecretHostError>()(
474 "LinuxBrowserSecretHostError",
475 { hostPlatform: Schema.String },
476) {
477 override get message(): string {
478 return `Linux desktop builds must run on a Linux host: the browser secret helper links against libsecret and cannot be built on '${this.hostPlatform}'.`;
479 }
480}
481
482export class DesktopIconSourceMissingError extends Schema.TaggedError<DesktopIconSourceMissingError>()(
483 "DesktopIconSourceMissingError",
484 {
485 platform: BuildPlatform,
486 sourcePath: Schema.String,
487 },
488) {
489 override get message(): string {
490 return `Desktop ${desktopIconPlatformNames[this.platform]} icon source is missing at ${this.sourcePath}`;
491 }
492}
493
494export class DesktopDmgBackgroundSourceMissingError extends Schema.TaggedError<DesktopDmgBackgroundSourceMissingError>()(
495 "DesktopDmgBackgroundSourceMissingError",
496 {
497 channel: Schema.Literals(["latest", "nightly"]),
498 sourcePath: Schema.String,
499 },
500) {
501 override get message(): string {
502 return `Desktop ${this.channel} DMG background source is missing at ${this.sourcePath}`;
503 }
504}
505
506export class BundledClientAssetsMissingError extends Schema.TaggedError<BundledClientAssetsMissingError>()(
507 "BundledClientAssetsMissingError",
508 {
509 indexPath: Schema.String,
510 missingFiles: Schema.Array(Schema.String),
511 },
512) {
513 override get message(): string {
514 const preview = this.missingFiles.slice(0, 6).join(", ");
515 const suffix = this.missingFiles.length > 6 ? ` (+${this.missingFiles.length - 6} more)` : "";
516 return `Bundled client references missing files in ${this.indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`;
517 }
518}
519
520export class UnsupportedDesktopBuildPlatformError extends Schema.TaggedError<UnsupportedDesktopBuildPlatformError>()(
521 "UnsupportedDesktopBuildPlatformError",
522 {
523 platform: Schema.String,
524 },
525) {
526 override get message(): string {
527 return `Unsupported platform '${this.platform}'.`;
528 }
529}
530
531const dependencyResolutionDescriptions = {
532 "server-production": "production dependencies",
533 "workspace-overrides": "overrides",
534 "desktop-runtime": "desktop runtime dependencies",
535} as const;
536const DependencyResolutionKind = Schema.Literals([
537 "server-production",
538 "workspace-overrides",
539 "desktop-runtime",
540]);
541
542export class DesktopBuildDependencyResolutionError extends Schema.TaggedError<DesktopBuildDependencyResolutionError>()(
543 "DesktopBuildDependencyResolutionError",
544 {
545 kind: DependencyResolutionKind,
546 manifestPath: Schema.String,
547 cause: Schema.Defect(),
548 },
549) {
550 override get message(): string {
551 return `Could not resolve ${dependencyResolutionDescriptions[this.kind]} from ${this.manifestPath}.`;
552 }
553}
554
555export class MissingServerProductionDependenciesError extends Schema.TaggedError<MissingServerProductionDependenciesError>()(
556 "MissingServerProductionDependenciesError",
557 {
558 manifestPath: Schema.String,
559 },
560) {
561 override get message(): string {
562 return `Could not resolve production dependencies from ${this.manifestPath}.`;
563 }
564}
565
566const DesktopBuildInputArtifact = Schema.Literals([
567 "desktop-dist",
568 "desktop-resources",
569 "server-dist",
570 "bundled-server-client",
571]);
572type DesktopBuildInputArtifact = typeof DesktopBuildInputArtifact.Type;
573const desktopBuildInputArtifactNames = {
574 "desktop-dist": "desktopDist",
575 "desktop-resources": "desktopResources",
576 "server-dist": "serverDist",
577 "bundled-server-client": "bundled server client",
578} satisfies Record<DesktopBuildInputArtifact, string>;
579
580/**
581 * Imported by every server module, so it is inlined in any correctly bundled
582 * build. Its absence means the bundle went back to externalizing its
583 * dependencies, which the sidecar's selected runtime closure does not cover.
584 */
585const BUNDLE_SELF_CONTAINED_SENTINEL = "effect";
586
587const BUNDLE_SELF_CHECK_TIMEOUT = Duration.seconds(120);
588const WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT = Duration.seconds(30);
589
590const WINDOWS_PRIMARY_FFF_PROBE_SOURCE = `
591const { join } = await import("node:path");
592const { pathToFileURL } = await import("node:url");
593const { FileFinder } = await import(pathToFileURL(process.argv[1]).href);
594const probeRoot = process.argv[2];
595const result = FileFinder.create({
596 basePath: probeRoot,
597 frecencyDbPath: join(probeRoot, "frecency.mdb"),
598 historyDbPath: join(probeRoot, "history.mdb"),
599 disableWatch: true,
600 disableMmapCache: true,
601 disableContentIndexing: true,
602});
603if (!result.ok) throw new Error(result.error);
604result.value.destroy();
605`;
606
607export class ExternalizedBundleError extends Schema.TaggedError<ExternalizedBundleError>()(
608 "ExternalizedBundleError",
609 { sentinel: Schema.String, inlinedPackageCount: Schema.Number },
610) {
611 override get message(): string {
612 return `The server bundle did not inline "${this.sentinel}" (${this.inlinedPackageCount} packages inlined). The bundle is meant to be self-contained apart from the runtime externals; if its dependencies are external again they will be absent from the sidecar, and the backend will fail with ERR_MODULE_NOT_FOUND. Check the deps.alwaysBundle wiring in apps/server/vite.config.ts.`;
613 }
614}
615
616export class BundleNotSelfContainedError extends Schema.TaggedError<BundleNotSelfContainedError>()(
617 "BundleNotSelfContainedError",
618 { exitCode: Schema.Number, output: Schema.String },
619) {
620 override get message(): string {
621 return `The packaged server bundle could not load from the isolated, extracted sidecar (exit ${this.exitCode}). Anything it imports that is neither a Node built-in nor in the selected runtime-external closure is unavailable to both backends. Output:
622${this.output}`;
623 }
624}
625
626export class InlinedNativePackageError extends Schema.TaggedError<InlinedNativePackageError>()(
627 "InlinedNativePackageError",
628 { packages: Schema.Array(Schema.String) },
629) {
630 override get message(): string {
631 return `The server bundle inlined packages that load native binaries: ${this.packages.join(", ")}. A node-gyp-build style loader resolves prebuilds relative to its own file, so inlined into a chunk it finds none and the importer quietly falls back to a slower JS path. Add them to CLI_RUNTIME_EXTERNAL_PREFIXES in scripts/lib/cli-external-packages.ts so they stay external and are staged in the sidecar.`;
632 }
633}
634
635export class InlinedExternalPackageError extends Schema.TaggedError<InlinedExternalPackageError>()(
636 "InlinedExternalPackageError",
637 { packages: Schema.Array(Schema.String) },
638) {
639 override get message(): string {
640 return `The server bundle inlined packages that must stay external: ${this.packages.join(", ")}. These are native addons or their loaders; inlined, they resolve prebuilds relative to the bundle and silently lose native acceleration. Check the deps.neverBundle wiring in apps/server/vite.config.ts.`;
641 }
642}
643
644export class MissingDesktopBuildInputError extends Schema.TaggedError<MissingDesktopBuildInputError>()(
645 "MissingDesktopBuildInputError",
646 {
647 artifact: DesktopBuildInputArtifact,
648 artifactPath: Schema.String,
649 buildCommand: Schema.Literal("vp run build:desktop"),
650 },
651) {
652 override get message(): string {
653 return `Missing ${desktopBuildInputArtifactNames[this.artifact]} at ${this.artifactPath}. Run '${this.buildCommand}' first.`;
654 }
655}
656
657export class MacProvisioningProfileNotFoundError extends Schema.TaggedError<MacProvisioningProfileNotFoundError>()(
658 "MacProvisioningProfileNotFoundError",
659 {
660 provisioningProfilePath: Schema.String,
661 },
662) {
663 override get message(): string {
664 return `macOS provisioning profile not found: ${this.provisioningProfilePath}`;
665 }
666}
667
668export class DesktopBuildDistDirectoryMissingError extends Schema.TaggedError<DesktopBuildDistDirectoryMissingError>()(
669 "DesktopBuildDistDirectoryMissingError",
670 {
671 distPath: Schema.String,
672 platform: BuildPlatform,
673 arch: BuildArch,
674 },
675) {
676 override get message(): string {
677 return `Build completed but dist directory was not found at ${this.distPath}`;
678 }
679}
680
681export class DesktopBuildNoArtifactsProducedError extends Schema.TaggedError<DesktopBuildNoArtifactsProducedError>()(
682 "DesktopBuildNoArtifactsProducedError",
683 {
684 distPath: Schema.String,
685 platform: BuildPlatform,
686 arch: BuildArch,
687 },
688) {
689 override get message(): string {
690 return `Build completed but no files were produced in ${this.distPath}`;
691 }
692}
693
694export class WslRuntimeArchiveMissingError extends Schema.TaggedError<WslRuntimeArchiveMissingError>()(
695 "WslRuntimeArchiveMissingError",
696 {
697 archivePath: Schema.String,
698 },
699) {
700 override get message(): string {
701 return `WSL runtime archive not found at ${this.archivePath}.`;
702 }
703}
704
705export class WindowsServerSidecarPackError extends Schema.TaggedError<WindowsServerSidecarPackError>()(
706 "WindowsServerSidecarPackError",
707 {
708 asarPath: Schema.String,
709 cause: Schema.optionalKey(Schema.Defect()),
710 },
711) {
712 override get message(): string {
713 return `Failed to pack the Windows server sidecar at ${this.asarPath}.`;
714 }
715}
716
717export class WindowsPrimaryNativeProbeError extends Schema.TaggedError<WindowsPrimaryNativeProbeError>()(
718 "WindowsPrimaryNativeProbeError",
719 {
720 executablePath: Schema.String,
721 exitCode: Schema.Number,
722 output: Schema.String,
723 },
724) {
725 override get message(): string {
726 return `The packaged Windows primary could not load fff from server.asar (exit ${this.exitCode}). Output:\n${this.output}`;
727 }
728}
729
730const WindowsPackagedPayloadValidationReason = Schema.Literals([
731 "packaged-app-missing",
732 "sidecar-missing",
733 "sidecar-invalid",
734 "unpacked-native-missing",
735 "resource-monitor-missing",
736 "wsl-runtime-missing",
737 "wsl-runtime-invalid",
738 "file-limit-exceeded",
739]);
740
741export class WindowsPackagedPayloadValidationError extends Schema.TaggedError<WindowsPackagedPayloadValidationError>()(
742 "WindowsPackagedPayloadValidationError",
743 {
744 reason: WindowsPackagedPayloadValidationReason,
745 packagedAppDir: Schema.String,
746 missingFiles: Schema.optionalKey(Schema.Array(Schema.String)),
747 fileCount: Schema.optionalKey(Schema.Int),
748 fileLimit: Schema.optionalKey(Schema.Int),
749 cause: Schema.optionalKey(Schema.Defect()),
750 },
751) {
752 override get message(): string {
753 if (this.reason === "file-limit-exceeded") {
754 return `Windows packaged payload contains ${String(this.fileCount)} files; expected at most ${String(this.fileLimit)}.`;
755 }
756 if (this.reason === "unpacked-native-missing") {
757 return `Windows server sidecar is missing ${String(this.missingFiles?.length ?? 0)} unpacked native files.`;
758 }
759 if (this.reason === "resource-monitor-missing") {
760 return "Windows packaged payload is missing the resource monitor executable.";
761 }
762 if (this.reason === "wsl-runtime-missing") {
763 return "Windows packaged payload is missing the WSL runtime archive or SHA-256 sidecar.";
764 }
765 if (this.reason === "wsl-runtime-invalid") {
766 return "Windows packaged payload contains an invalid WSL runtime archive.";
767 }
768 if (this.reason === "sidecar-invalid") {
769 return "Windows packaged payload contains an invalid server.asar sidecar.";
770 }
771 if (this.reason === "sidecar-missing") {
772 return "Windows packaged payload is missing resources/server.asar.";
773 }
774 return `Windows packaged application directory was not found at ${this.packagedAppDir}.`;
775 }
776}
777
778export class LinuxIconResizeError extends Schema.TaggedError<LinuxIconResizeError>()(
779 "LinuxIconResizeError",
780 {
781 operation: Schema.Literal("resize"),
782 iconSize: Schema.Int,
783 primaryTool: Schema.Literal("magick"),
784 fallbackTool: Schema.Literal("convert"),
785 cause: Schema.Defect(),
786 },
787) {
788 override get message(): string {
789 return `Failed to ${this.operation} the Linux desktop icon to ${this.iconSize}x${this.iconSize} with \`${this.primaryTool}\` or \`${this.fallbackTool}\`. Install ImageMagick so either tool is available.`;
790 }
791}
792
793const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
794 stream.pipe(
795 Stream.decodeText(),
796 Stream.runFold(
797 () => "",
798 (acc, chunk) => acc + chunk,
799 ),
800 );
801
802const COMMAND_OUTPUT_TAIL_LENGTH = 20_000;
803
804function appendOutputTail(acc: string, chunk: string): string {
805 const next = acc + chunk;
806 return next.length > COMMAND_OUTPUT_TAIL_LENGTH ? next.slice(-COMMAND_OUTPUT_TAIL_LENGTH) : next;
807}
808
809function formatOutputSection(label: string, output: string): string | undefined {
810 const trimmed = output.trim();
811 if (!trimmed) return undefined;
812 return `${label} tail:\n${trimmed}`;
813}
814
815const collectCommandStream = <E>(
816 stream: Stream.Stream<Uint8Array, E>,
817 output: NodeJS.WriteStream,
818 verbose: boolean,
819): Effect.Effect<string, E> =>
820 stream.pipe(
821 Stream.decodeText(),
822 Stream.runFoldEffect(
823 () => "",
824 (acc, chunk) =>
825 Effect.as(
826 verbose ? Effect.sync(() => output.write(chunk)) : Effect.void,
827 appendOutputTail(acc, chunk),
828 ),
829 ),
830 );
831
832const spawnAndCollectOutput = Effect.fn("spawnAndCollectOutput")(function* (
833 command: ChildProcess.Command,
834) {
835 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
836 const child = yield* spawner.spawn(command);
837
838 const [stdout, stderr, exitCode] = yield* Effect.all(
839 [
840 collectStreamAsString(child.stdout),
841 collectStreamAsString(child.stderr),
842 child.exitCode.pipe(Effect.map(Number)),
843 ],
844 { concurrency: "unbounded" },
845 );
846
847 return { stdout, stderr, exitCode } as const;
848});
849
850const resolveCommitHash = Effect.fn("resolveCommitHash")(function* (repoRoot: string) {
851 const configuredHash = yield* Config.String("T3CODE_COMMIT_HASH").pipe(Config.option);
852 const output = yield* Option.match(configuredHash, {
853 onSome: Effect.succeed,
854 onNone: () =>
855 spawnAndCollectOutput(
856 ChildProcess.make("git", ["rev-parse", "--short=12", "HEAD"], { cwd: repoRoot }),
857 ).pipe(
858 Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")),
859 Effect.orElseSucceed(() => ""),
860 ),
861 });
862 const hash = output.trim();
863 if (!/^[0-9a-f]{7,40}$/i.test(hash)) {
864 return "unknown";
865 }
866 return hash.toLowerCase();
867});
868
869const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* () {
870 const fs = yield* FileSystem.FileSystem;
871 const path = yield* Path.Path;
872 const hostPlatform = yield* HostProcess.Platform;
873 const env = yield* Config.all({
874 configuredPython: Config.String("npm_config_python").pipe(
875 Config.orElse(() => Config.String("PYTHON")),
876 Config.option,
877 ),
878 localAppData: Config.String("LOCALAPPDATA").pipe(Config.option),
879 });
880 const isPython3 = (candidate: string) =>
881 spawnAndCollectOutput(
882 ChildProcess.make(candidate, [
883 "-c",
884 "import sys; raise SystemExit(0 if sys.version_info.major == 3 else 1)",
885 ]),
886 ).pipe(
887 Effect.map((result) => result.exitCode === 0),
888 Effect.orElseSucceed(() => false),
889 );
890 const configured = Option.getOrUndefined(env.configuredPython);
891 if (configured && (yield* fs.exists(configured)) && (yield* isPython3(configured))) {
892 return configured;
893 }
894
895 if (hostPlatform === "win32") {
896 const localAppData = Option.getOrUndefined(env.localAppData);
897 if (localAppData) {
898 for (const version of ["Python313", "Python312", "Python311", "Python310"]) {
899 const candidate = path.join(localAppData, "Programs", "Python", version, "python.exe");
900 if ((yield* fs.exists(candidate)) && (yield* isPython3(candidate))) {
901 return candidate;
902 }
903 }
904 }
905 }
906
907 const probe = yield* spawnAndCollectOutput(
908 ChildProcess.make("python", ["-c", "import sys;print(sys.executable)"]),
909 ).pipe(
910 Effect.orElseSucceed(() => ({
911 stdout: "",
912 stderr: "",
913 exitCode: 1,
914 })),
915 );
916
917 if (probe.exitCode !== 0) {
918 return undefined;
919 }
920
921 const executable = probe.stdout.trim();
922 if (!executable || !(yield* fs.exists(executable)) || !(yield* isPython3(executable))) {
923 return undefined;
924 }
925
926 return executable;
927});
928
929interface ResolvedBuildOptions {
930 readonly platform: typeof BuildPlatform.Type;
931 readonly target: string;
932 readonly arch: typeof BuildArch.Type;
933 readonly version: string | undefined;
934 readonly outputDir: string;
935 readonly skipBuild: boolean;
936 readonly keepStage: boolean;
937 readonly signed: boolean;
938 readonly verbose: boolean;
939 readonly mockUpdates: boolean;
940 readonly mockUpdateServerPort: number | undefined;
941 readonly wslRuntime: string | undefined;
942}
943
944interface StagePackageJson {
945 readonly name: string;
946 readonly version: string;
947 readonly buildVersion: string;
948 readonly t3codeCommitHash: string;
949 readonly t3codeWebAuthn?: MacWebAuthnEntitlements;
950 readonly private: true;
951 readonly packageManager: string;
952 readonly description: string;
953 readonly license: string;
954 readonly homepage: string;
955 readonly author: string;
956 readonly main: string;
957 readonly build: Record<string, unknown>;
958 readonly dependencies: Record<string, unknown>;
959 readonly devDependencies: {
960 readonly electron: string;
961 };
962}
963
964export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const;
965export const DESKTOP_ELECTRON_LANGUAGES = ["en-US"] as const;
966export const DESKTOP_FILE_EXCLUSIONS = [
967 // Cursor finds platform assets by walking up from argv[1]. Keep them outside
968 // asar so spawning helpers and loading native addons both use real paths.
969 "!**/node_modules/@cursor/sdk-*/**/*",
970 "!apps/desktop/prod-resources/cursor-sdk",
971 "!apps/desktop/prod-resources/cursor-sdk/**/*",
972 // The Knife always passes the user's installed Claude executable to the SDK,
973 // so the SDK's optional platform packages (each a ~200MB bundled executable)
974 // are dead weight. The trailing dash keeps the SDK's own JS package.
975 "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*",
976 // Nothing in the packaged app enables source maps or serves them: the web
977 // client's maps alone were 50 MB of app.asar that no request ever read.
978 "!**/*.map",
979 "!**/*.d.cts",
980 "!apps/desktop/resources/browser-secret",
981 "!apps/desktop/resources/browser-secret/**/*",
982 "!apps/desktop/prod-resources/browser-secret",
983 "!apps/desktop/prod-resources/browser-secret/**/*",
984 // Windows stages the server sidecar below prod-resources so electron-builder
985 // can copy it using project-relative extraResources matchers. Keep those
986 // staging inputs out of app.asar; they are emitted once at resources/.
987 "!apps/desktop/prod-resources/windows-server",
988 "!apps/desktop/prod-resources/windows-server/**/*",
989 "!apps/desktop/prod-resources/wsl-runtime.tar.gz",
990 "!apps/desktop/prod-resources/wsl-runtime.tar.gz.sha256",
991 "!apps/desktop/gnome-extension",
992 "!apps/desktop/gnome-extension/**/*",
993] as const;
994// Windows terminal helpers cannot run on macOS and slow signing and notarization.
995export const MAC_FILE_EXCLUSIONS = [
996 "!**/node_modules/node-pty/prebuilds/win32-*/**/*",
997 "!**/node_modules/node-pty/third_party/conpty/**/*",
998] as const;
999// Linux builds node-pty from source, so every prebuild in the package is for
1000// another platform (58 MB of it Windows debug symbols).
1001export const LINUX_FILE_EXCLUSIONS = [
1002 ...MAC_FILE_EXCLUSIONS,
1003 "!**/node_modules/node-pty/prebuilds/darwin-*/**/*",
1004] as const;
1005
1006// node-pty publishes both Darwin prebuilds in one package. Single-architecture
1007// apps only need the native target; universal apps need both. An omitted arch
1008// preserves the existing common exclusions for callers that only inspect the
1009// generic platform config.
1010export function resolveMacFileExclusions(arch?: typeof BuildArch.Type) {
1011 if (arch === undefined || arch === "universal") {
1012 return [...MAC_FILE_EXCLUSIONS];
1013 }
1014
1015 const unusedArch = arch === "arm64" ? "x64" : "arm64";
1016 return [...MAC_FILE_EXCLUSIONS, `!**/node_modules/node-pty/prebuilds/darwin-${unusedArch}/**/*`];
1017}
1018// Windows ships the server tree (bundle + node_modules) as a separate
1019// resources/server.asar sidecar instead of loose files: the NSIS installer
1020// then extracts a handful of large archives instead of thousands of small
1021// files, which dominates install (and update) time. The Windows primary runs
1022// the server from inside server.asar via the asar-aware ELECTRON_RUN_AS_NODE
1023// runtime. WSL does not use this sidecar: it runs the Linux CLI archive
1024// embedded as resources/wsl-runtime.tar.gz (see WSL_RUNTIME_ARCHIVE_NAME).
1025export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar";
1026// dlopen/spawn need real files, so native modules, shared libraries, and
1027// helper executables live in each archive's .unpacked sibling (the standard
1028// asar redirect convention). Everything else stays packed.
1029export const WINDOWS_NATIVE_ASAR_UNPACK_GLOB =
1030 "{**/*.node,**/*.dll,**/*.exe,**/*.so,**/*.so.*,**/*.dylib}";
1031// The server sidecar unpacks .node files separately, keeping only Windows ones.
1032export const WINDOWS_SERVER_ASAR_UNPACK_GLOBS = [
1033 "**/*.dll",
1034 "**/*.exe",
1035 "**/*.so",
1036 "**/*.so.*",
1037 "**/*.dylib",
1038] as const;
1039// Mirrors DESKTOP_FILE_EXCLUSIONS for the hand-packed sidecar: the Claude SDK
1040// platform packages are dead weight (see above), and node_modules/.bin shims
1041// are never spawned at runtime (and are symlinks on POSIX build hosts, which
1042// the asar extraction path deliberately does not support).
1043export const WINDOWS_SERVER_ASAR_IGNORE_GLOBS = [
1044 "**/node_modules/@cursor/sdk-*",
1045 "**/node_modules/@cursor/sdk-*/**",
1046 "**/node_modules/@anthropic-ai/claude-agent-sdk-*",
1047 "**/node_modules/@anthropic-ai/claude-agent-sdk-*/**",
1048 "**/node_modules/.bin",
1049 "**/node_modules/.bin/**",
1050 "**/*.map",
1051] as const;
1052
1053export function resolveWindowsServerAsarIgnoreGlobs(arch: typeof BuildArch.Type) {
1054 const unusedArch = arch === "arm64" ? "x64" : "arm64";
1055 const unusedPrebuild = `**/node_modules/node-pty/prebuilds/win32-${unusedArch}`;
1056 const unusedConpty = `**/node_modules/node-pty/third_party/conpty/*/win10-${unusedArch}`;
1057
1058 return [
1059 ...WINDOWS_SERVER_ASAR_IGNORE_GLOBS,
1060 unusedPrebuild,
1061 `${unusedPrebuild}/**`,
1062 unusedConpty,
1063 `${unusedConpty}/**`,
1064 ];
1065}
1066
1067export const WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT = 80;
1068export const WINDOWS_SERVER_RESOURCE_SOURCE_DIR = "apps/desktop/prod-resources/windows-server";
1069export const WINDOWS_SERVER_EXTRA_RESOURCES = [
1070 {
1071 // Copy the archive and its .unpacked sibling from one parent directory.
1072 // Mapping the .unpacked directory as an independent FileSet silently
1073 // omitted it from Windows packages even though electron-builder copied
1074 // the adjacent archive.
1075 from: WINDOWS_SERVER_RESOURCE_SOURCE_DIR,
1076 to: ".",
1077 filter: [WINDOWS_SERVER_ASAR_RESOURCE, `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked/**/*`],
1078 },
1079] as const;
1080export const WSL_RUNTIME_ARCHIVE_NAME = "wsl-runtime.tar.gz";
1081export const WSL_RUNTIME_ARCHIVE_HASH_NAME = `${WSL_RUNTIME_ARCHIVE_NAME}.sha256`;
1082export const WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE = {
1083 from: `apps/desktop/prod-resources/${WSL_RUNTIME_ARCHIVE_NAME}`,
1084 to: WSL_RUNTIME_ARCHIVE_NAME,
1085} as const;
1086export const WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE = {
1087 from: `apps/desktop/prod-resources/${WSL_RUNTIME_ARCHIVE_HASH_NAME}`,
1088 to: WSL_RUNTIME_ARCHIVE_HASH_NAME,
1089} as const;
1090
1091// The WSL runtime is the Linux CLI release archive (t3-<version>-linux-<arch>
1092// .tar.gz, built by scripts/build-cli-archive.ts) copied in verbatim, so WSL
1093// runs the exact bytes a Linux user downloads. This one predicate decides both
1094// whether the archive is staged and whether the packaging config ships it:
1095// listing an extraResource whose source was never written fails electron-builder.
1096export const bundlesWslRuntime = (input: {
1097 readonly platform: typeof BuildPlatform.Type;
1098 readonly runtimeArchivePath: string | undefined;
1099}): boolean => input.platform === "win" && input.runtimeArchivePath !== undefined;
1100
1101export const WSL_RUNTIME_EXTRA_RESOURCES = [
1102 WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE,
1103 WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE,
1104] as const;
1105export const DESKTOP_EXTRA_RESOURCES = [
1106 {
1107 from: "apps/desktop/prod-resources/cursor-sdk",
1108 to: "node_modules/@cursor",
1109 },
1110 {
1111 from: "apps/desktop/prod-resources/resource-monitor",
1112 to: "resource-monitor",
1113 },
1114] as const;
1115export const LINUX_CAPTURE_EXTRA_RESOURCES = [
1116 {
1117 from: "apps/desktop/prod-resources/hyprland-capture",
1118 to: "hyprland-capture",
1119 },
1120 {
1121 from: "apps/desktop/prod-resources/kde-capture",
1122 to: "kde-capture",
1123 },
1124 {
1125 from: "apps/desktop/gnome-extension",
1126 to: "gnome-extension",
1127 filter: gnomeCaptureBundle.files,
1128 },
1129] as const;
1130export const LINUX_BROWSER_SECRET_EXTRA_RESOURCES = [
1131 { from: "apps/desktop/prod-resources/browser-secret", to: "browser-secret" },
1132] as const;
1133
1134export interface MacPasskeySigningConfiguration {
1135 readonly appId: string;
1136 readonly teamId: string;
1137 readonly rpDomains: readonly string[];
1138 readonly provisioningProfilePath: string;
1139}
1140
1141export const InvalidMacPasskeyRpDomainReason = Schema.Literals([
1142 "empty",
1143 "scheme-not-allowed",
1144 "parse-failed",
1145 "credentials-not-allowed",
1146 "port-not-allowed",
1147 "path-not-allowed",
1148 "query-not-allowed",
1149 "fragment-not-allowed",
1150 "hostname-mismatch",
1151]);
1152export type InvalidMacPasskeyRpDomainReason = typeof InvalidMacPasskeyRpDomainReason.Type;
1153
1154export class InvalidMacPasskeyRpDomainError extends Schema.TaggedError<InvalidMacPasskeyRpDomainError>()(
1155 "InvalidMacPasskeyRpDomainError",
1156 {
1157 reason: InvalidMacPasskeyRpDomainReason,
1158 inputLength: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
1159 cause: Schema.optionalKey(Schema.Defect()),
1160 },
1161) {
1162 override get message(): string {
1163 return `Invalid passkey RP domain (${this.reason}).`;
1164 }
1165}
1166
1167export class InvalidAppleTeamIdError extends Schema.TaggedError<InvalidAppleTeamIdError>()(
1168 "InvalidAppleTeamIdError",
1169 {
1170 teamId: Schema.String,
1171 },
1172) {
1173 override get message(): string {
1174 return `T3CODE_APPLE_TEAM_ID '${this.teamId}' must be a 10-character Apple Developer Team ID.`;
1175 }
1176}
1177
1178export class MissingMacPasskeyProvisioningProfileError extends Schema.TaggedError<MissingMacPasskeyProvisioningProfileError>()(
1179 "MissingMacPasskeyProvisioningProfileError",
1180 {},
1181) {
1182 override get message(): string {
1183 return "T3CODE_MACOS_PROVISIONING_PROFILE must point to an Associated Domains provisioning profile.";
1184 }
1185}
1186
1187export class MissingMacPasskeyDomainConfigurationError extends Schema.TaggedError<MissingMacPasskeyDomainConfigurationError>()(
1188 "MissingMacPasskeyDomainConfigurationError",
1189 {},
1190) {
1191 override get message(): string {
1192 return "T3CODE_CLERK_PUBLISHABLE_KEY or T3CODE_CLERK_PASSKEY_RP_DOMAINS is required for signed macOS passkey builds.";
1193 }
1194}
1195
1196export class InvalidMacPasskeyPublishableKeyError extends Schema.TaggedError<InvalidMacPasskeyPublishableKeyError>()(
1197 "InvalidMacPasskeyPublishableKeyError",
1198 {
1199 cause: Schema.Defect(),
1200 },
1201) {
1202 override get message(): string {
1203 return "T3CODE_CLERK_PUBLISHABLE_KEY is invalid.";
1204 }
1205}
1206
1207export class MissingMacPasskeyRpDomainError extends Schema.TaggedError<MissingMacPasskeyRpDomainError>()(
1208 "MissingMacPasskeyRpDomainError",
1209 {},
1210) {
1211 override get message(): string {
1212 return "At least one Clerk passkey RP domain is required.";
1213 }
1214}
1215
1216export const MacPasskeySigningConfigurationError = Schema.Union([
1217 InvalidMacPasskeyRpDomainError,
1218 InvalidAppleTeamIdError,
1219 MissingMacPasskeyProvisioningProfileError,
1220 MissingMacPasskeyDomainConfigurationError,
1221 InvalidMacPasskeyPublishableKeyError,
1222 MissingMacPasskeyRpDomainError,
1223]);
1224export type MacPasskeySigningConfigurationError = typeof MacPasskeySigningConfigurationError.Type;
1225export const isMacPasskeySigningConfigurationError = Schema.is(MacPasskeySigningConfigurationError);
1226
1227function normalizePasskeyRpDomain(value: string): string {
1228 const normalized = value.trim().toLowerCase();
1229 const inputLength = value.length;
1230 if (normalized.length === 0) {
1231 throw new InvalidMacPasskeyRpDomainError({ reason: "empty", inputLength });
1232 }
1233 if (/^[a-z][a-z\d+.-]*:\/\//u.test(normalized)) {
1234 throw new InvalidMacPasskeyRpDomainError({
1235 reason: "scheme-not-allowed",
1236 inputLength,
1237 });
1238 }
1239
1240 let parsed: URL;
1241 try {
1242 parsed = new URL(`https://${normalized}`);
1243 } catch (cause) {
1244 throw new InvalidMacPasskeyRpDomainError({ reason: "parse-failed", inputLength, cause });
1245 }
1246
1247 let reason: InvalidMacPasskeyRpDomainReason | undefined;
1248 if (parsed.username.length > 0 || parsed.password.length > 0) {
1249 reason = "credentials-not-allowed";
1250 } else if (parsed.port.length > 0) {
1251 reason = "port-not-allowed";
1252 } else if (parsed.pathname !== "/") {
1253 reason = "path-not-allowed";
1254 } else if (parsed.search.length > 0) {
1255 reason = "query-not-allowed";
1256 } else if (parsed.hash.length > 0) {
1257 reason = "fragment-not-allowed";
1258 } else if (parsed.host !== normalized) {
1259 reason = "hostname-mismatch";
1260 }
1261 if (reason) {
1262 throw new InvalidMacPasskeyRpDomainError({ reason, inputLength });
1263 }
1264
1265 return parsed.hostname;
1266}
1267
1268export function resolveMacPasskeySigningConfiguration(
1269 env: Readonly<Record<string, string | undefined>>,
1270): MacPasskeySigningConfiguration {
1271 const teamId = env.T3CODE_APPLE_TEAM_ID?.trim().toUpperCase() ?? "";
1272 if (!APPLE_TEAM_ID_PATTERN.test(teamId)) {
1273 throw new InvalidAppleTeamIdError({ teamId });
1274 }
1275
1276 const provisioningProfilePath = env.T3CODE_MACOS_PROVISIONING_PROFILE?.trim() ?? "";
1277 if (provisioningProfilePath.length === 0) {
1278 throw new MissingMacPasskeyProvisioningProfileError();
1279 }
1280
1281 const configuredRpDomains = env.T3CODE_CLERK_PASSKEY_RP_DOMAINS?.trim();
1282 let rpDomains: readonly string[];
1283 if (configuredRpDomains) {
1284 rpDomains = configuredRpDomains.split(",").map(normalizePasskeyRpDomain);
1285 } else {
1286 const publishableKey = env.T3CODE_CLERK_PUBLISHABLE_KEY?.trim();
1287 if (!publishableKey) {
1288 throw new MissingMacPasskeyDomainConfigurationError();
1289 }
1290 let hostname: string;
1291 try {
1292 hostname = clerkFrontendApiHostnameFromPublishableKey(publishableKey);
1293 } catch (cause) {
1294 throw new InvalidMacPasskeyPublishableKeyError({ cause });
1295 }
1296 rpDomains = [normalizePasskeyRpDomain(hostname)];
1297 }
1298
1299 const uniqueRpDomains = [...new Set(rpDomains)];
1300 if (uniqueRpDomains.length === 0) {
1301 throw new MissingMacPasskeyRpDomainError();
1302 }
1303
1304 return {
1305 appId: DESKTOP_APP_ID,
1306 teamId,
1307 rpDomains: uniqueRpDomains,
1308 provisioningProfilePath,
1309 };
1310}
1311
1312function escapeXml(value: string): string {
1313 return value
1314 .replaceAll("&", "&amp;")
1315 .replaceAll("<", "&lt;")
1316 .replaceAll(">", "&gt;")
1317 .replaceAll('"', "&quot;")
1318 .replaceAll("'", "&apos;");
1319}
1320
1321/**
1322 * Passkey entitlements for the in-app browser. Each is granted only when the
1323 * provisioning profile authorizes it: macOS refuses to launch an app that
1324 * claims a restricted entitlement its embedded profile does not carry.
1325 */
1326export interface MacWebAuthnEntitlements {
1327 /** Keychain group for Electron's Touch ID passkeys. */
1328 readonly touchIdKeychainAccessGroup: string | undefined;
1329 /** Apple's managed browser entitlement, which allows passkeys for any site. */
1330 readonly browserPasskeys: boolean;
1331}
1332
1333const BROWSER_PASSKEYS_ENTITLEMENT = "com.apple.developer.web-browser.public-key-credential";
1334
1335const ProvisioningProfilePlist = Schema.Struct({
1336 Entitlements: Schema.Struct({
1337 "keychain-access-groups": Schema.optional(Schema.Array(Schema.String)),
1338 [BROWSER_PASSKEYS_ENTITLEMENT]: Schema.optional(Schema.Boolean),
1339 }),
1340});
1341const isProvisioningProfilePlist = Schema.is(ProvisioningProfilePlist);
1342
1343/**
1344 * Reads the Entitlements dict of the XML plist a provisioning profile wraps in
1345 * its CMS envelope. Anything unreadable grants nothing.
1346 */
1347const readProfileEntitlements = (provisioningProfile: string) => {
1348 const start = provisioningProfile.indexOf("<?xml");
1349 const end = provisioningProfile.indexOf("</plist>", start);
1350 if (start === -1 || end === -1) return undefined;
1351 try {
1352 const profile: unknown = Plist.parse(provisioningProfile.slice(start, end + "</plist>".length));
1353 return isProvisioningProfilePlist(profile) ? profile.Entitlements : undefined;
1354 } catch {
1355 return undefined;
1356 }
1357};
1358
1359export function resolveMacWebAuthnEntitlements(
1360 provisioningProfile: string,
1361 configuration: Pick<MacPasskeySigningConfiguration, "appId" | "teamId">,
1362): MacWebAuthnEntitlements {
1363 const entitlements = readProfileEntitlements(provisioningProfile);
1364 const keychainAccessGroup = `${configuration.teamId}.${configuration.appId}.webauthn`;
1365 const keychainGroupAuthorized = (entitlements?.["keychain-access-groups"] ?? []).some((group) =>
1366 group.endsWith("*")
1367 ? keychainAccessGroup.startsWith(group.slice(0, -1))
1368 : group === keychainAccessGroup,
1369 );
1370 return {
1371 touchIdKeychainAccessGroup: keychainGroupAuthorized ? keychainAccessGroup : undefined,
1372 browserPasskeys: entitlements?.[BROWSER_PASSKEYS_ENTITLEMENT] === true,
1373 };
1374}
1375
1376export function renderMacPasskeyEntitlements(
1377 configuration: MacPasskeySigningConfiguration,
1378 webAuthn: MacWebAuthnEntitlements,
1379): string {
1380 const associatedDomains = configuration.rpDomains
1381 .map((domain) => ` <string>webcredentials:${escapeXml(domain)}</string>`)
1382 .join("\n");
1383 const keychainAccessGroups = webAuthn.touchIdKeychainAccessGroup
1384 ? `
1385 <key>keychain-access-groups</key>
1386 <array>
1387 <string>${escapeXml(webAuthn.touchIdKeychainAccessGroup)}</string>
1388 </array>`
1389 : "";
1390 const browserPasskeys = webAuthn.browserPasskeys
1391 ? `
1392 <key>${BROWSER_PASSKEYS_ENTITLEMENT}</key>
1393 <true/>`
1394 : "";
1395
1396 return `<?xml version="1.0" encoding="UTF-8"?>
1397<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1398<plist version="1.0">
1399 <dict>
1400 <key>com.apple.application-identifier</key>
1401 <string>${escapeXml(`${configuration.teamId}.${configuration.appId}`)}</string>
1402 <key>com.apple.developer.team-identifier</key>
1403 <string>${escapeXml(configuration.teamId)}</string>
1404 <key>com.apple.developer.associated-domains</key>
1405 <array>
1406${associatedDomains}
1407 </array>${keychainAccessGroups}${browserPasskeys}
1408 <key>com.apple.security.cs.allow-jit</key>
1409 <true/>
1410 <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
1411 <true/>
1412 <key>com.apple.security.cs.disable-library-validation</key>
1413 <true/>
1414 </dict>
1415</plist>
1416`;
1417}
1418
1419export function resolveFffNativeDependencies(
1420 platform: typeof BuildPlatform.Type,
1421 arch: typeof BuildArch.Type,
1422 version: string,
1423): Record<string, string> {
1424 const architectures = arch === "universal" ? (["arm64", "x64"] as const) : [arch];
1425
1426 if (platform === "mac") {
1427 return Object.fromEntries(
1428 architectures.map((architecture) => [`@ff-labs/fff-bin-darwin-${architecture}`, version]),
1429 );
1430 }
1431
1432 if (platform === "win") {
1433 return Object.fromEntries(
1434 architectures.map((architecture) => [`@ff-labs/fff-bin-win32-${architecture}`, version]),
1435 );
1436 }
1437
1438 return Object.fromEntries(
1439 architectures.flatMap((architecture) =>
1440 ["gnu", "musl"].map((libc) => [`@ff-labs/fff-bin-linux-${architecture}-${libc}`, version]),
1441 ),
1442 );
1443}
1444
1445// macOS and Linux run both processes from one app.asar, so the stage installs
1446// the union of what each bundle leaves external and nothing else.
1447export function resolveMergedStageDependencies(input: {
1448 readonly platform: "mac" | "linux";
1449 readonly serverDependencies: Record<string, string>;
1450 readonly desktopDependencies: Record<string, string>;
1451 readonly arch: typeof BuildArch.Type;
1452 readonly fffNodeVersion: string;
1453}) {
1454 return {
1455 ...selectCliRuntimeExternalDependencies(input.serverDependencies),
1456 ...input.desktopDependencies,
1457 ...resolveFffNativeDependencies(input.platform, input.arch, input.fffNodeVersion),
1458 };
1459}
1460
1461/** Cursor's helper lookup falls through the archive to this real resources tree. */
1462export const stageCursorSdkPlatformPackages = Effect.fn("stageCursorSdkPlatformPackages")(
1463 function* (nodeModulesDir: string, destination: string) {
1464 const fs = yield* FileSystem.FileSystem;
1465 const path = yield* Path.Path;
1466 yield* fs.makeDirectory(destination, { recursive: true });
1467 const sdkDirectory = path.join(nodeModulesDir, "@cursor/sdk");
1468 if (!(yield* fs.exists(sdkDirectory))) return;
1469 // pnpm's isolated layout puts optional packages beside the real SDK directory.
1470 const cursorDirectory = path.dirname(yield* fs.realPath(sdkDirectory));
1471 for (const name of yield* fs.readDirectory(cursorDirectory)) {
1472 if (!name.startsWith("sdk-")) continue;
1473 const source = yield* fs.realPath(path.join(cursorDirectory, name));
1474 yield* fs.copy(source, path.join(destination, name));
1475 }
1476 },
1477);
1478
1479export interface ClerkPasskeyNativeArtifact {
1480 readonly packageName: string;
1481 readonly binaryFileName: string;
1482}
1483
1484export function resolveClerkPasskeyNativeArtifacts(
1485 platform: typeof BuildPlatform.Type,
1486 arch: typeof BuildArch.Type,
1487): readonly ClerkPasskeyNativeArtifact[] {
1488 const architectures = arch === "universal" ? (["arm64", "x64"] as const) : [arch];
1489
1490 if (platform === "mac") {
1491 return architectures.map((architecture) => ({
1492 packageName: `@clerk/electron-passkeys-darwin-${architecture}`,
1493 binaryFileName: `electron-passkeys.darwin-${architecture}.node`,
1494 }));
1495 }
1496
1497 if (platform === "win") {
1498 return architectures.map((architecture) => ({
1499 packageName: `@clerk/electron-passkeys-win32-${architecture}-msvc`,
1500 binaryFileName: `electron-passkeys.win32-${architecture}-msvc.node`,
1501 }));
1502 }
1503
1504 return [];
1505}
1506
1507export function resolveKeyringNativeArtifacts(
1508 platform: typeof BuildPlatform.Type,
1509 arch: typeof BuildArch.Type,
1510): readonly ClerkPasskeyNativeArtifact[] {
1511 const architectures = arch === "universal" ? (["arm64", "x64"] as const) : [arch];
1512
1513 if (platform === "mac") {
1514 return architectures.map((architecture) => ({
1515 packageName: `@napi-rs/keyring-darwin-${architecture}`,
1516 binaryFileName: `keyring.darwin-${architecture}.node`,
1517 }));
1518 }
1519
1520 if (platform === "win") {
1521 return architectures.map((architecture) => ({
1522 packageName: `@napi-rs/keyring-win32-${architecture}-msvc`,
1523 binaryFileName: `keyring.win32-${architecture}-msvc.node`,
1524 }));
1525 }
1526
1527 return architectures.map((architecture) => ({
1528 packageName: `@napi-rs/keyring-linux-${architecture}-gnu`,
1529 binaryFileName: `keyring.linux-${architecture}-gnu.node`,
1530 }));
1531}
1532
1533/**
1534 * Same nesting problem as the Clerk passkey binaries: pnpm keeps the platform
1535 * package under `@napi-rs/keyring`, electron-builder only retains collected
1536 * top-level dependencies, and the generated loader checks for a sibling
1537 * `keyring.<platform>.node` before falling back to the package. Staging the
1538 * binary beside `index.js` lets that first branch win.
1539 */
1540const stageKeyringNativeBinaries = Effect.fn("stageKeyringNativeBinaries")(function* (
1541 stageAppDir: string,
1542 platform: typeof BuildPlatform.Type,
1543 arch: typeof BuildArch.Type,
1544) {
1545 const fs = yield* FileSystem.FileSystem;
1546 const path = yield* Path.Path;
1547 const packageEntryPath = yield* fs.realPath(
1548 path.join(stageAppDir, "node_modules", "@napi-rs", "keyring", "index.js"),
1549 );
1550 const packageDir = path.dirname(packageEntryPath);
1551 const packageRequire = NodeModule.createRequire(packageEntryPath);
1552
1553 for (const artifact of resolveKeyringNativeArtifacts(platform, arch)) {
1554 const sourcePath = yield* Effect.try({
1555 try: () => packageRequire.resolve(`${artifact.packageName}/${artifact.binaryFileName}`),
1556 catch: (cause) =>
1557 new KeyringNativePackageMissingError({
1558 packageName: artifact.packageName,
1559 binaryFileName: artifact.binaryFileName,
1560 packageEntryPath,
1561 platform,
1562 arch,
1563 cause,
1564 }),
1565 });
1566 yield* fs.copyFile(sourcePath, path.join(packageDir, artifact.binaryFileName));
1567 }
1568});
1569
1570// pnpm nests the architecture package under @clerk/electron-passkeys, while electron-builder only
1571// retains collected top-level dependencies. The SDK loader checks beside index.js first, so stage
1572// the binary there and let electron-builder's native-addon handling unpack it from the ASAR.
1573const stageClerkPasskeyNativeBinaries = Effect.fn("stageClerkPasskeyNativeBinaries")(function* (
1574 stageAppDir: string,
1575 platform: typeof BuildPlatform.Type,
1576 arch: typeof BuildArch.Type,
1577) {
1578 const fs = yield* FileSystem.FileSystem;
1579 const path = yield* Path.Path;
1580 const packageEntryPath = yield* fs.realPath(
1581 path.join(stageAppDir, "node_modules", "@clerk", "electron-passkeys", "index.js"),
1582 );
1583 const packageDir = path.dirname(packageEntryPath);
1584 const packageRequire = NodeModule.createRequire(packageEntryPath);
1585
1586 for (const artifact of resolveClerkPasskeyNativeArtifacts(platform, arch)) {
1587 const sourcePath = yield* Effect.try({
1588 try: () => packageRequire.resolve(artifact.packageName),
1589 catch: (cause) =>
1590 new ClerkPasskeyNativePackageMissingError({
1591 packageName: artifact.packageName,
1592 binaryFileName: artifact.binaryFileName,
1593 packageEntryPath,
1594 platform,
1595 arch,
1596 cause,
1597 }),
1598 });
1599 yield* fs.copyFile(sourcePath, path.join(packageDir, artifact.binaryFileName));
1600 }
1601});
1602
1603export function createStageWorkspaceConfig(input: {
1604 readonly platform: typeof BuildPlatform.Type;
1605 readonly arch: typeof BuildArch.Type;
1606 readonly allowBuilds?: Record<string, boolean>;
1607 readonly patchedDependencies?: Record<string, string>;
1608 readonly overrides?: Record<string, string>;
1609}): StageWorkspaceConfig {
1610 const { platform, arch, allowBuilds, patchedDependencies, overrides } = input;
1611 const hostOs = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux";
1612 const hostCpu = arch === "universal" ? ["arm64", "x64"] : [arch];
1613 // Linux AppImages execute a Linux/glibc Node process that loads
1614 // Linux-native optional deps at runtime. Keep libc explicit so pnpm
1615 // includes those optional packages in the staged production install.
1616 const supportedArchitectures =
1617 platform === "linux"
1618 ? {
1619 os: [hostOs],
1620 cpu: hostCpu,
1621 libc: ["glibc"],
1622 }
1623 : {
1624 os: [hostOs],
1625 cpu: hostCpu,
1626 };
1627
1628 return {
1629 supportedArchitectures,
1630 ...(allowBuilds && Object.keys(allowBuilds).length > 0 ? { allowBuilds } : {}),
1631 ...(patchedDependencies && Object.keys(patchedDependencies).length > 0
1632 ? { patchedDependencies }
1633 : {}),
1634 ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}),
1635 packageExtensions: STAGE_PACKAGE_EXTENSIONS,
1636 };
1637}
1638
1639export function createStagePatchedDependencies(
1640 patchedDependencies: Record<string, string>,
1641 dependencies: Record<string, unknown>,
1642): Record<string, string> {
1643 return Object.fromEntries(
1644 Object.entries(patchedDependencies).filter(([patchKey]) =>
1645 Object.hasOwn(dependencies, getPatchedDependencyPackageName(patchKey)),
1646 ),
1647 );
1648}
1649
1650function getPatchedDependencyPackageName(patchKey: string): string {
1651 const versionSeparator = patchKey.lastIndexOf("@");
1652 return versionSeparator > 0 ? patchKey.slice(0, versionSeparator) : patchKey;
1653}
1654
1655const AzureTrustedSigningOptionsConfig = Config.all({
1656 publisherName: Config.String("AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"),
1657 endpoint: Config.String("AZURE_TRUSTED_SIGNING_ENDPOINT"),
1658 certificateProfileName: Config.String("AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME"),
1659 codeSigningAccountName: Config.String("AZURE_TRUSTED_SIGNING_ACCOUNT_NAME"),
1660 fileDigest: Config.String("AZURE_TRUSTED_SIGNING_FILE_DIGEST").pipe(Config.withDefault("SHA256")),
1661 timestampDigest: Config.String("AZURE_TRUSTED_SIGNING_TIMESTAMP_DIGEST").pipe(
1662 Config.withDefault("SHA256"),
1663 ),
1664 timestampRfc3161: Config.String("AZURE_TRUSTED_SIGNING_TIMESTAMP_RFC3161").pipe(
1665 Config.withDefault("http://timestamp.acs.microsoft.com"),
1666 ),
1667});
1668
1669const BuildEnvConfig = Config.all({
1670 platform: Config.schema(BuildPlatform, "T3CODE_DESKTOP_PLATFORM").pipe(Config.option),
1671 target: Config.String("T3CODE_DESKTOP_TARGET").pipe(Config.option),
1672 arch: Config.schema(BuildArch, "T3CODE_DESKTOP_ARCH").pipe(Config.option),
1673 version: Config.String("T3CODE_DESKTOP_VERSION").pipe(Config.option),
1674 outputDir: Config.String("T3CODE_DESKTOP_OUTPUT_DIR").pipe(Config.option),
1675 skipBuild: Config.Boolean("T3CODE_DESKTOP_SKIP_BUILD").pipe(Config.withDefault(false)),
1676 keepStage: Config.Boolean("T3CODE_DESKTOP_KEEP_STAGE").pipe(Config.withDefault(false)),
1677 signed: Config.Boolean("T3CODE_DESKTOP_SIGNED").pipe(Config.withDefault(false)),
1678 verbose: Config.Boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(false)),
1679 mockUpdates: Config.Boolean("T3CODE_DESKTOP_MOCK_UPDATES").pipe(Config.withDefault(false)),
1680 mockUpdateServerPort: Config.String("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option),
1681 // Path to the Linux CLI release archive (t3-<version>-linux-x64.tar.gz) built
1682 // by the build_linux_cli CI job. The Windows build embeds it verbatim as the
1683 // WSL runtime.
1684 wslRuntime: Config.String("T3CODE_DESKTOP_WSL_RUNTIME").pipe(Config.option),
1685});
1686
1687const MockUpdateServerPortSchema = Schema.NumberFromString.check(
1688 Schema.isInt(),
1689 Schema.isBetween({ minimum: 1, maximum: 65535 }),
1690);
1691const decodeMockUpdateServerPort = Schema.decodeUnknownEffect(MockUpdateServerPortSchema);
1692
1693function invalidMockUpdateServerPortReason(
1694 configuredPort: string,
1695): typeof InvalidMockUpdateServerPortReason.Type {
1696 const parsed = Number(configuredPort);
1697 if (!Number.isFinite(parsed)) return "not-numeric";
1698 if (!Number.isInteger(parsed)) return "not-integer";
1699 if (parsed < 1 || parsed > 65535) return "out-of-range";
1700 // This mapper is only called after schema decoding failed. An otherwise
1701 // valid integer therefore used a representation the decoder did not accept.
1702 return "not-numeric";
1703}
1704
1705const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
1706 Option.getOrElse(flag, () => envValue);
1707const mergeOptions = <A>(a: Option.Option<A>, b: Option.Option<A>, defaultValue: A) =>
1708 Option.getOrElse(a, () => Option.getOrElse(b, () => defaultValue));
1709
1710export const resolveMockUpdateServerPort = Effect.fn("resolveMockUpdateServerPort")(function* (
1711 mockUpdateServerPort: string | undefined,
1712) {
1713 const port = mockUpdateServerPort?.trim();
1714 if (!port) {
1715 return undefined;
1716 }
1717
1718 return yield* decodeMockUpdateServerPort(port);
1719});
1720
1721export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (
1722 input: BuildCliInput,
1723) {
1724 const path = yield* Path.Path;
1725 const repoRoot = yield* RepoRoot;
1726 const env = yield* BuildEnvConfig;
1727 const hostPlatform = yield* HostProcess.Platform;
1728
1729 const platform = mergeOptions(
1730 input.platform,
1731 env.platform,
1732 detectHostBuildPlatform(hostPlatform),
1733 );
1734
1735 if (!platform) {
1736 return yield* new UnsupportedHostBuildPlatformError({ hostPlatform });
1737 }
1738
1739 const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget);
1740 const defaultArch = yield* getDefaultArch(platform);
1741 const arch = mergeOptions(input.arch, env.arch, defaultArch);
1742 const supportedArchitectures = PLATFORM_CONFIG[platform].archChoices;
1743 if (!supportedArchitectures.includes(arch)) {
1744 return yield* new UnsupportedDesktopBuildArchitectureError({
1745 platform,
1746 arch,
1747 supportedArchitectures: [...supportedArchitectures],
1748 });
1749 }
1750 const version = mergeOptions(input.buildVersion, env.version, undefined);
1751 const releaseDir = resolveBooleanFlag(input.mockUpdates, env.mockUpdates)
1752 ? "release-mock"
1753 : "release";
1754 const outputDir = path.resolve(
1755 repoRoot,
1756 mergeOptions(input.outputDir, env.outputDir, releaseDir),
1757 );
1758
1759 const skipBuild = resolveBooleanFlag(input.skipBuild, env.skipBuild);
1760 const keepStage = resolveBooleanFlag(input.keepStage, env.keepStage);
1761 const signed = resolveBooleanFlag(input.signed, env.signed);
1762 const verbose = resolveBooleanFlag(input.verbose, env.verbose);
1763
1764 const mockUpdates = resolveBooleanFlag(input.mockUpdates, env.mockUpdates);
1765 const configuredMockUpdateServerPort = Option.getOrUndefined(env.mockUpdateServerPort);
1766 const mockUpdateServerPort =
1767 Option.getOrUndefined(input.mockUpdateServerPort) ??
1768 (configuredMockUpdateServerPort === undefined
1769 ? undefined
1770 : yield* resolveMockUpdateServerPort(configuredMockUpdateServerPort).pipe(
1771 Effect.mapError((cause) =>
1772 InvalidMockUpdateServerPortError.fromConfigValue(configuredMockUpdateServerPort, cause),
1773 ),
1774 ));
1775
1776 const wslRuntime =
1777 Option.getOrUndefined(input.wslRuntime) ?? Option.getOrUndefined(env.wslRuntime);
1778
1779 return {
1780 platform,
1781 target,
1782 arch,
1783 version,
1784 outputDir,
1785 skipBuild,
1786 keepStage,
1787 signed,
1788 verbose,
1789 mockUpdates,
1790 mockUpdateServerPort,
1791 wslRuntime,
1792 } satisfies ResolvedBuildOptions;
1793});
1794
1795const runCommand = Effect.fn("runCommand")(function* (
1796 command: ChildProcess.Command,
1797 options: {
1798 readonly label: string;
1799 readonly verbose: boolean;
1800 },
1801) {
1802 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1803 const child = yield* commandSpawner.spawn(command);
1804 const [stdout, stderr, exitCode] = yield* Effect.all(
1805 [
1806 collectCommandStream(child.stdout, process.stdout, options.verbose),
1807 collectCommandStream(child.stderr, process.stderr, options.verbose),
1808 child.exitCode.pipe(Effect.map(Number)),
1809 ],
1810 { concurrency: "unbounded" },
1811 );
1812
1813 if (exitCode !== 0) {
1814 return yield* new BuildCommandFailedError({
1815 command: options.label,
1816 exitCode,
1817 ...(stdout.trim() ? { stdoutTail: stdout } : {}),
1818 ...(stderr.trim() ? { stderrTail: stderr } : {}),
1819 });
1820 }
1821});
1822
1823const desktopBuildProbeSucceeds = Effect.fn("desktopBuildProbeSucceeds")(function* (
1824 command: ChildProcess.Command,
1825 label: string,
1826) {
1827 return yield* runCommand(command, { label, verbose: false }).pipe(
1828 Effect.as(true),
1829 Effect.orElseSucceed(() => false),
1830 );
1831});
1832
1833const rustTargetIsInstalled = Effect.fn("rustTargetIsInstalled")(function* (target: string) {
1834 const fs = yield* FileSystem.FileSystem;
1835 const result = yield* spawnAndCollectOutput(
1836 ChildProcess.make("rustc", ["--print", "target-libdir", "--target", target]),
1837 ).pipe(Effect.orElseSucceed(() => null));
1838 if (result === null || result.exitCode !== 0) return false;
1839
1840 const targetLibDir = result.stdout.trim();
1841 if (targetLibDir === "") return false;
1842 const entries = yield* fs.readDirectory(targetLibDir).pipe(Effect.orElseSucceed(() => []));
1843 return entries.some((entry) => entry.startsWith("libstd-") && entry.endsWith(".rlib"));
1844});
1845
1846export const preflightLinuxDesktopBuild = Effect.fn("preflightLinuxDesktopBuild")(function* (
1847 arch: typeof BuildArch.Type = "x64",
1848) {
1849 const reuseResourceMonitor = yield* Config.Boolean("T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR").pipe(
1850 Config.withDefault(false),
1851 );
1852 const reuseCaptureHelpers = yield* Config.Boolean(
1853 "T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS",
1854 ).pipe(Config.withDefault(false));
1855 // Rust is only optional when every Linux Rust artifact comes from a cache.
1856 const needsRust = !reuseResourceMonitor || !reuseCaptureHelpers;
1857 const rustTarget = resolveResourceMonitorRustTargets("linux", arch)[0]!;
1858
1859 const checks = yield* Effect.all(
1860 {
1861 cargo: needsRust
1862 ? desktopBuildProbeSucceeds(ChildProcess.make("cargo", ["--version"]), "cargo")
1863 : Effect.succeed(true),
1864 "rust-target": needsRust ? rustTargetIsInstalled(rustTarget) : Effect.succeed(true),
1865 cc: desktopBuildProbeSucceeds(ChildProcess.make("cc", ["--version"]), "cc"),
1866 make: desktopBuildProbeSucceeds(ChildProcess.make("make", ["--version"]), "make"),
1867 libsecret: desktopBuildProbeSucceeds(
1868 ChildProcess.make("pkg-config", ["--exists", "libsecret-1"]),
1869 "libsecret",
1870 ),
1871 imagemagick: Effect.all([
1872 desktopBuildProbeSucceeds(ChildProcess.make("magick", ["-version"]), "magick"),
1873 desktopBuildProbeSucceeds(ChildProcess.make("convert", ["-version"]), "convert"),
1874 ]).pipe(Effect.map(([magick, convert]) => magick || convert)),
1875 },
1876 { concurrency: "unbounded" },
1877 );
1878 const missing = LINUX_DESKTOP_BUILD_PREREQUISITES.filter(
1879 (requirement) => !checks[requirement.id],
1880 ).map((requirement) => requirement.id);
1881
1882 if (missing.length > 0) {
1883 return yield* new LinuxDesktopBuildPrerequisitesMissingError({ missing, rustTarget });
1884 }
1885});
1886
1887export const preflightMacDesktopBuild = Effect.fn("preflightMacDesktopBuild")(function* (
1888 arch: typeof BuildArch.Type,
1889) {
1890 const rustTargets = resolveResourceMonitorRustTargets("mac", arch);
1891 const reuseResourceMonitor = yield* Config.Boolean("T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR").pipe(
1892 Config.withDefault(false),
1893 );
1894 const checks = yield* Effect.all(
1895 {
1896 rust: reuseResourceMonitor
1897 ? Effect.succeed(true)
1898 : Effect.all([
1899 desktopBuildProbeSucceeds(ChildProcess.make("cargo", ["--version"]), "cargo"),
1900 Effect.forEach(rustTargets, rustTargetIsInstalled).pipe(
1901 Effect.map((results) => results.every(Boolean)),
1902 ),
1903 ]).pipe(Effect.map(([cargo, targets]) => cargo && targets)),
1904 clang: desktopBuildProbeSucceeds(ChildProcess.make("clang", ["--version"]), "clang"),
1905 make: desktopBuildProbeSucceeds(ChildProcess.make("make", ["--version"]), "make"),
1906 sips: desktopBuildProbeSucceeds(ChildProcess.make("sips", ["--help"]), "sips"),
1907 iconutil: desktopBuildProbeSucceeds(
1908 ChildProcess.make("xcrun", ["--find", "iconutil"]),
1909 "iconutil",
1910 ),
1911 lipo:
1912 arch === "universal"
1913 ? desktopBuildProbeSucceeds(ChildProcess.make("lipo", ["-version"]), "lipo")
1914 : Effect.succeed(true),
1915 },
1916 { concurrency: "unbounded" },
1917 );
1918 const missing = MAC_DESKTOP_BUILD_PREREQUISITES.filter(
1919 (requirement) => !checks[requirement.id],
1920 ).map((requirement) => requirement.id);
1921 if (missing.length > 0) {
1922 return yield* new MacDesktopBuildPrerequisitesMissingError({ missing, rustTargets });
1923 }
1924});
1925
1926function windowsVswherePrerequisiteScript(arch: typeof BuildArch.Type): string {
1927 const toolComponents =
1928 arch === "arm64"
1929 ? ["Microsoft.VisualStudio.Component.VC.Tools.ARM64"]
1930 : ["Microsoft.VisualStudio.Component.VC.Tools.x86.x64"];
1931 const spectreArch = arch === "arm64" ? "arm64" : "x64";
1932 return [
1933 "$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\\Installer\\vswhere.exe'",
1934 "if (!(Test-Path $vswhere)) { exit 1 }",
1935 `$install = & $vswhere -latest -products * -requires ${toolComponents.join(" ")} -property installationPath`,
1936 "if (!$install) { exit 1 }",
1937 "$kitsRoot = Get-ItemPropertyValue 'HKLM:\\SOFTWARE\\Microsoft\\Windows Kits\\Installed Roots' -Name KitsRoot10 -ErrorAction SilentlyContinue",
1938 "if (!$kitsRoot -or !(Test-Path (Join-Path $kitsRoot 'Lib'))) { exit 1 }",
1939 "$msvcToolset = Get-ChildItem (Join-Path $install 'VC\\Tools\\MSVC') -Directory | Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1",
1940 "if (!$msvcToolset) { exit 1 }",
1941 `if (!(Test-Path (Join-Path $msvcToolset.FullName 'lib\\spectre\\${spectreArch}'))) { exit 1 }`,
1942 ].join("; ");
1943}
1944
1945export const preflightWindowsDesktopBuild = Effect.fn("preflightWindowsDesktopBuild")(
1946 function* (input: { readonly arch: typeof BuildArch.Type; readonly bundlesWslRuntime: boolean }) {
1947 const rustTarget = resolveResourceMonitorRustTargets("win", input.arch)[0]!;
1948 const reuseResourceMonitor = yield* Config.Boolean(
1949 "T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR",
1950 ).pipe(Config.withDefault(false));
1951 const python = yield* resolvePythonForNodeGyp();
1952 const checks = yield* Effect.all(
1953 {
1954 rust: reuseResourceMonitor
1955 ? Effect.succeed(true)
1956 : Effect.all([
1957 desktopBuildProbeSucceeds(ChildProcess.make("cargo", ["--version"]), "cargo"),
1958 rustTargetIsInstalled(rustTarget),
1959 ]).pipe(Effect.map(([cargo, target]) => cargo && target)),
1960 python: Effect.succeed(python !== undefined),
1961 msvc: reuseResourceMonitor
1962 ? Effect.succeed(true)
1963 : desktopBuildProbeSucceeds(
1964 ChildProcess.make("powershell.exe", [
1965 "-NoLogo",
1966 "-NoProfile",
1967 "-NonInteractive",
1968 "-Command",
1969 windowsVswherePrerequisiteScript(input.arch),
1970 ]),
1971 "Visual Studio Build Tools",
1972 ),
1973 tar: input.bundlesWslRuntime
1974 ? desktopBuildProbeSucceeds(ChildProcess.make("tar.exe", ["--version"]), "tar")
1975 : Effect.succeed(true),
1976 },
1977 { concurrency: "unbounded" },
1978 );
1979 const missing = WINDOWS_DESKTOP_BUILD_PREREQUISITES.filter(
1980 (requirement) => !checks[requirement.id],
1981 ).map((requirement) => requirement.id);
1982 if (missing.length > 0) {
1983 return yield* new WindowsDesktopBuildPrerequisitesMissingError({ missing, rustTarget });
1984 }
1985 },
1986);
1987
1988/**
1989 * Every `node_modules` directory that would be visible from `startDir`.
1990 *
1991 * The self-containment check is only meaningful in a directory with none of
1992 * these: Node walks parents when resolving a bare import, so a stray
1993 * node_modules above the probe would satisfy imports that are missing from the
1994 * packaged tree and turn the check into a silent pass.
1995 */
1996function trimTrailingSeparators(value: string): string {
1997 let end = value.length;
1998 while (end > 1 && (value[end - 1] === "/" || value[end - 1] === "\\")) end -= 1;
1999 return value.slice(0, end);
2000}
2001
2002/**
2003 * Length of the `\\server\share` prefix, or 0 when the path is not UNC.
2004 *
2005 * The share is the highest real directory on a UNC path: `\\server` on its own
2006 * is not one, so the ancestor walk must stop there.
2007 */
2008function uncShareRootLength(value: string): number {
2009 const isUnc = value.startsWith("\\\\") || value.startsWith("//");
2010 if (!isUnc) return 0;
2011 const separator = /[\\/]/;
2012 const serverEnd = value.slice(2).search(separator);
2013 if (serverEnd < 0) return value.length;
2014 const shareStart = 2 + serverEnd + 1;
2015 const shareEnd = value.slice(shareStart).search(separator);
2016 return shareEnd < 0 ? value.length : shareStart + shareEnd;
2017}
2018
2019export function ancestorNodeModulesPaths(
2020 startDir: string,
2021 separator: string,
2022): ReadonlyArray<string> {
2023 // Walks with lastIndexOf rather than splitting into segments so UNC roots
2024 // (\\server\share) and drive roots keep their prefix instead of being
2025 // rebuilt into a relative path that silently resolves against the build cwd.
2026 const paths: string[] = [];
2027 let current = trimTrailingSeparators(startDir);
2028 // On a UNC path the share itself is the root: \\server is not a directory, so
2029 // walking past \\server\share would emit paths that cannot exist.
2030 const uncRootLength = uncShareRootLength(current);
2031 for (;;) {
2032 const cut = Math.max(current.lastIndexOf("/"), current.lastIndexOf("\\"));
2033 if (cut < 0 || (uncRootLength > 0 && cut < uncRootLength)) break;
2034 const parent = cut === 0 ? current.slice(0, 1) : current.slice(0, cut);
2035 if (parent === current) break;
2036 paths.push(
2037 parent.endsWith(separator) ? `${parent}node_modules` : `${parent}${separator}node_modules`,
2038 );
2039 if (cut === 0) break;
2040 current = parent;
2041 }
2042 return paths;
2043}
2044
2045const NativeMarkerManifest = Schema.Struct({
2046 dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
2047 optionalDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
2048});
2049const decodeNativeMarkerManifest = Schema.decodeUnknownSync(
2050 Schema.fromJsonString(NativeMarkerManifest),
2051);
2052
2053/** Locate a package inside the pnpm store, which is where the real files live. */
2054const findStorePackageDirectory = Effect.fn("findStorePackageDirectory")(function* (
2055 repoRoot: string,
2056 packageName: string,
2057) {
2058 const fs = yield* FileSystem.FileSystem;
2059 const path = yield* Path.Path;
2060 const storeDir = path.join(repoRoot, "node_modules/.pnpm");
2061 const exists = (candidate: string) =>
2062 fs.exists(candidate).pipe(Effect.orElseSucceed(() => false));
2063 if (!(yield* exists(storeDir))) return null;
2064
2065 const flattened = `${packageName.replace("/", "+")}@`;
2066 const entries = yield* fs
2067 .readDirectory(storeDir)
2068 .pipe(Effect.orElseSucceed(() => [] as string[]));
2069 for (const entry of entries) {
2070 if (!entry.startsWith(flattened)) continue;
2071 const candidate = path.join(storeDir, entry, "node_modules", packageName);
2072 if (yield* exists(candidate)) return candidate;
2073 }
2074 return null;
2075});
2076
2077/** Whether a package builds or ships a native addon it loads at runtime. */
2078const hasNativeLoaderMarkers = Effect.fn("hasNativeLoaderMarkers")(function* (packageDir: string) {
2079 const fs = yield* FileSystem.FileSystem;
2080 const path = yield* Path.Path;
2081 const exists = (candidate: string) =>
2082 fs.exists(candidate).pipe(Effect.orElseSucceed(() => false));
2083
2084 if (yield* exists(path.join(packageDir, "binding.gyp"))) return true;
2085 if (yield* exists(path.join(packageDir, "prebuilds"))) return true;
2086
2087 const manifestPath = path.join(packageDir, "package.json");
2088 if (!(yield* exists(manifestPath))) return false;
2089 const source = yield* fs.readFileString(manifestPath).pipe(Effect.orElseSucceed(() => ""));
2090 if (source === "") return false;
2091 const manifest = yield* Effect.try(() => decodeNativeMarkerManifest(source)).pipe(
2092 Effect.orElseSucceed(() => null),
2093 );
2094 if (manifest === null) return false;
2095 return Object.keys({ ...manifest.dependencies, ...manifest.optionalDependencies }).some(
2096 (dependency) => dependency.startsWith("node-gyp-build"),
2097 );
2098});
2099
2100export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservingSymlinks")(
2101 function* (source: string, destination: string) {
2102 const fs = yield* FileSystem.FileSystem;
2103 const path = yield* Path.Path;
2104
2105 // Effect's Node implementation delegates directory copies to fs.cp, whose
2106 // default rewrites links into absolute source-tree references. Recreate every
2107 // in-tree directory link as a junction rooted in the isolated copy so the
2108 // probe cannot resolve through staging and Windows needs no symlink privilege.
2109 yield* fs.copy(source, destination);
2110
2111 const restoreRelativeSymlinks = (
2112 sourceDirectory: string,
2113 destinationDirectory: string,
2114 ): Effect.Effect<void, PlatformError | BundleNotSelfContainedError> =>
2115 Effect.gen(function* () {
2116 for (const entry of yield* fs.readDirectory(sourceDirectory)) {
2117 const sourceEntry = path.join(sourceDirectory, entry);
2118 const destinationEntry = path.join(destinationDirectory, entry);
2119 const linkTarget = yield* fs.readLink(sourceEntry).pipe(Effect.option);
2120 if (Option.isSome(linkTarget)) {
2121 const absoluteSourceTarget = path.isAbsolute(linkTarget.value)
2122 ? linkTarget.value
2123 : path.resolve(path.dirname(sourceEntry), linkTarget.value);
2124 const sourceRelativeTarget = path.relative(source, absoluteSourceTarget);
2125 if (
2126 sourceRelativeTarget === ".." ||
2127 sourceRelativeTarget.startsWith(`..${path.sep}`) ||
2128 path.isAbsolute(sourceRelativeTarget)
2129 ) {
2130 return yield* new BundleNotSelfContainedError({
2131 exitCode: -1,
2132 output: `Refusing to copy symlink ${sourceEntry}: its target ${absoluteSourceTarget} escapes the packaged tree.`,
2133 });
2134 }
2135 const target = path.join(destination, sourceRelativeTarget);
2136 yield* fs.remove(destinationEntry, { recursive: true, force: true });
2137 yield* Effect.tryPromise({
2138 try: () => NodeFSP.symlink(target, destinationEntry, "junction"),
2139 catch: (cause) =>
2140 new BundleNotSelfContainedError({
2141 exitCode: -1,
2142 output: `Could not isolate ${sourceEntry}: ${String(cause)}`,
2143 }),
2144 });
2145 } else {
2146 const info = yield* fs.stat(sourceEntry);
2147 if (info.type === "Directory") {
2148 yield* restoreRelativeSymlinks(sourceEntry, destinationEntry);
2149 }
2150 }
2151 }
2152 });
2153
2154 yield* restoreRelativeSymlinks(source, destination);
2155 },
2156);
2157
2158const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSelfContained")(
2159 function* (input: {
2160 readonly asarPath: string;
2161 readonly executablePath: string;
2162 readonly verbose: boolean;
2163 }) {
2164 const fs = yield* FileSystem.FileSystem;
2165 const path = yield* Path.Path;
2166
2167 const probeRoot = yield* fs.makeTempDirectoryScoped({
2168 prefix: "t3code-bundle-selfcheck-",
2169 });
2170 const extractedApp = path.join(probeRoot, "extracted");
2171 const probeApp = path.join(probeRoot, "app");
2172 yield* Effect.try({
2173 try: () => extractAll(input.asarPath, extractedApp),
2174 catch: (cause) =>
2175 new BundleNotSelfContainedError({
2176 exitCode: -1,
2177 output: `Could not extract ${input.asarPath} for the bundle self-containment check: ${String(cause)}`,
2178 }),
2179 });
2180 // Keep the existing symlink isolation guard even though the sidecar stage
2181 // is hoisted and should be physical. A future package-manager layout change
2182 // must not let the probe resolve through the build tree.
2183 yield* copyDirectoryPreservingSymlinks(extractedApp, probeApp);
2184
2185 // Guard the guard: if anything above the probe provides a node_modules, a
2186 // missing dependency would resolve there and the check would pass while the
2187 // packaged tree is broken.
2188 for (const candidate of ancestorNodeModulesPaths(probeApp, path.sep)) {
2189 if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
2190 return yield* new BundleNotSelfContainedError({
2191 exitCode: -1,
2192 output: `Refusing to report success: ${candidate} is visible from the probe directory, so bare imports could resolve outside the packaged tree. Remove or rename it, or point TMPDIR somewhere without one.`,
2193 });
2194 }
2195 }
2196
2197 const entryPoint = path.join(probeApp, "apps/server/dist/bin.mjs");
2198 if (!(yield* fs.exists(entryPoint).pipe(Effect.orElseSucceed(() => false)))) {
2199 return yield* new BundleNotSelfContainedError({
2200 exitCode: -1,
2201 output: `Expected the server entry at ${entryPoint}.`,
2202 });
2203 }
2204
2205 // --version exercises the eagerly loaded module graph, which is where a
2206 // missing dependency shows up, without starting a server or touching disk
2207 // state. It does not cover lazily imported externals: node-pty is checked
2208 // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node
2209 // and the bun adapters are covered by the shared runtime-external closure
2210 // and emitted-bundle checks.
2211 const wine = Option.getOrUndefined(
2212 yield* Config.String("T3CODE_DESKTOP_WINDOWS_RUNTIME").pipe(Config.option),
2213 );
2214 const entry = wine === undefined ? entryPoint : `Z:${entryPoint.replaceAll("/", "\\")}`;
2215 yield* runCommand(
2216 ChildProcess.make(
2217 wine ?? process.execPath,
2218 // --no-global-search-paths because clearing NODE_PATH is not enough:
2219 // CommonJS resolution still falls back to $HOME/.node_modules,
2220 // $HOME/.node_libraries and the install prefix, so a globally installed
2221 // copy of a missing dependency would quietly satisfy this check.
2222 [
2223 ...(wine === undefined ? [] : [input.executablePath]),
2224 "--no-global-search-paths",
2225 entry,
2226 "--version",
2227 ],
2228 {
2229 cwd: probeApp,
2230 stdout: "pipe",
2231 stderr: "pipe",
2232 // NODE_PATH would let a createRequire call inside the bundle resolve
2233 // a missing external from outside the packaged tree, which is the
2234 // whole thing this is trying to rule out.
2235 env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", NODE_PATH: "" },
2236 },
2237 ),
2238 {
2239 label: "server sidecar self-containment check (node bin.mjs --version)",
2240 verbose: input.verbose,
2241 },
2242 ).pipe(
2243 // Printing a version should be immediate. A regression that blocks (on
2244 // stdin, a port, a lock) would otherwise hang release CI until the job
2245 // times out with nothing useful in the log.
2246 Effect.timeout(BUNDLE_SELF_CHECK_TIMEOUT),
2247 Effect.catchTags({
2248 TimeoutError: () =>
2249 Effect.fail(
2250 new BundleNotSelfContainedError({
2251 exitCode: -1,
2252 output: `The packaged bundle did not print its version within ${Duration.toSeconds(BUNDLE_SELF_CHECK_TIMEOUT)}s; it is hanging rather than failing to resolve.`,
2253 }),
2254 ),
2255 BuildCommandFailedError: (error) =>
2256 Effect.fail(
2257 new BundleNotSelfContainedError({
2258 exitCode: error.exitCode,
2259 output: `${error.stderrTail ?? ""}${error.stdoutTail ?? ""}`.trim(),
2260 }),
2261 ),
2262 }),
2263 );
2264 },
2265);
2266
2267export const stageLinuxCaptureHelper = Effect.fn("stageLinuxCaptureHelper")(function* (input: {
2268 readonly backend: "kde" | "hyprland";
2269 readonly repoRoot: string;
2270 readonly stageResourcesDir: string;
2271 readonly arch: typeof BuildArch.Type;
2272 readonly verbose: boolean;
2273}) {
2274 const fs = yield* FileSystem.FileSystem;
2275 const path = yield* Path.Path;
2276 const [rustTarget] = resolveResourceMonitorRustTargets("linux", input.arch);
2277 // Release CI restores these binaries from a cache keyed on the crate sources and
2278 // skips the Rust toolchain on a hit, so the build must be skippable too.
2279 const reuseHelpers = yield* Config.Boolean("T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS").pipe(
2280 Config.withDefault(false),
2281 );
2282 const binaryPath = path.join(
2283 input.repoRoot,
2284 `native/${input.backend}-snap-shot/target`,
2285 rustTarget!,
2286 `release/t3-${input.backend}-snap-shot`,
2287 );
2288 if (!reuseHelpers) {
2289 const spawnCommand = yield* resolveSpawnCommand("cargo", [
2290 "build",
2291 "--locked",
2292 "--release",
2293 "--manifest-path",
2294 path.join(input.repoRoot, `native/${input.backend}-snap-shot/Cargo.toml`),
2295 "--target",
2296 rustTarget!,
2297 ]);
2298 yield* runCommand(
2299 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
2300 cwd: input.repoRoot,
2301 shell: spawnCommand.shell,
2302 }),
2303 {
2304 label: `cargo build ${input.backend} capture helper (${rustTarget})`,
2305 verbose: input.verbose,
2306 },
2307 );
2308 } else if (!(yield* fs.exists(binaryPath))) {
2309 return yield* new ResourceMonitorBuildOutputMissingError({
2310 binaryPath,
2311 rustTarget: rustTarget!,
2312 platform: "linux",
2313 arch: input.arch,
2314 });
2315 }
2316 const destination = path.join(input.stageResourcesDir, `${input.backend}-capture`);
2317 yield* fs.makeDirectory(destination, { recursive: true });
2318 const executable = path.join(destination, `t3-${input.backend}-snap-shot`);
2319 yield* fs.copyFile(binaryPath, executable);
2320 yield* fs.chmod(executable, 0o755);
2321 if (input.backend === "hyprland") {
2322 // The official protocol XML includes the BSD notices required with binary distribution.
2323 yield* fs.copy(
2324 path.join(input.repoRoot, "native/hyprland-snap-shot/protocols"),
2325 path.join(destination, "protocols"),
2326 );
2327 }
2328});
2329
2330export const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function* (input: {
2331 readonly repoRoot: string;
2332 readonly stageResourcesDir: string;
2333 readonly platform: typeof BuildPlatform.Type;
2334 readonly arch: typeof BuildArch.Type;
2335 readonly verbose: boolean;
2336}) {
2337 const fs = yield* FileSystem.FileSystem;
2338 const path = yield* Path.Path;
2339 const manifestPath = path.join(input.repoRoot, "native/resource-monitor/Cargo.toml");
2340 const executableName = resourceMonitorExecutableName(input.platform);
2341 const rustTargets = resolveResourceMonitorRustTargets(input.platform, input.arch);
2342 const reuseResourceMonitor = yield* Config.Boolean("T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR").pipe(
2343 Config.withDefault(false),
2344 );
2345 const builtBinaries: string[] = [];
2346
2347 for (const rustTarget of rustTargets) {
2348 if (!reuseResourceMonitor) {
2349 const spawnCommand = yield* resolveSpawnCommand("cargo", [
2350 "build",
2351 "--locked",
2352 "--release",
2353 "--manifest-path",
2354 manifestPath,
2355 "--target",
2356 rustTarget,
2357 ]);
2358 yield* runCommand(
2359 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
2360 cwd: input.repoRoot,
2361 shell: spawnCommand.shell,
2362 }),
2363 {
2364 label: `cargo build resource monitor (${rustTarget})`,
2365 verbose: input.verbose,
2366 },
2367 );
2368 }
2369
2370 const binaryPath = path.join(
2371 input.repoRoot,
2372 "native/resource-monitor/target",
2373 rustTarget,
2374 "release",
2375 executableName,
2376 );
2377 if (!(yield* fs.exists(binaryPath))) {
2378 return yield* new ResourceMonitorBuildOutputMissingError({
2379 binaryPath,
2380 rustTarget,
2381 platform: input.platform,
2382 arch: input.arch,
2383 });
2384 }
2385 if (reuseResourceMonitor) {
2386 yield* Effect.log(`[desktop-artifact] Reusing cached resource monitor (${rustTarget}).`);
2387 }
2388 builtBinaries.push(binaryPath);
2389 }
2390
2391 const destinationDirectory = path.join(input.stageResourcesDir, "resource-monitor");
2392 const destinationPath = path.join(destinationDirectory, executableName);
2393 yield* fs.remove(destinationDirectory, { recursive: true, force: true }).pipe(Effect.ignore);
2394 yield* fs.makeDirectory(destinationDirectory, { recursive: true });
2395
2396 if (builtBinaries.length === 1) {
2397 yield* fs.copyFile(builtBinaries[0]!, destinationPath);
2398 } else {
2399 yield* runCommand(
2400 ChildProcess.make("lipo", ["-create", ...builtBinaries, "-output", destinationPath]),
2401 {
2402 label: "lipo resource monitor universal binary",
2403 verbose: input.verbose,
2404 },
2405 );
2406 }
2407
2408 if (input.platform !== "win") {
2409 yield* fs.chmod(destinationPath, 0o755);
2410 }
2411});
2412
2413export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (input: {
2414 readonly repoRoot: string;
2415 readonly stageResourcesDir: string;
2416 readonly platform: typeof BuildPlatform.Type;
2417 readonly arch: typeof BuildArch.Type;
2418 readonly verbose: boolean;
2419}) {
2420 if (input.platform !== "linux") return;
2421 // The helper links against the host's libsecret, so it can only be built on
2422 // Linux; the build script is a no-op elsewhere. A Linux artifact from
2423 // another host would ship without it and every v11 cookie import would
2424 // report the keyring as unavailable, so refuse rather than package that
2425 // silently. `universal` is a mac-only arch the option type still admits;
2426 // the helper script rejects it, so it maps to the concrete x64 the Linux
2427 // resource monitor uses for the same request.
2428 const hostPlatform = yield* HostProcess.Platform;
2429 if (hostPlatform !== "linux") {
2430 return yield* new LinuxBrowserSecretHostError({ hostPlatform });
2431 }
2432 const path = yield* Path.Path;
2433 yield* runCommand(
2434 ChildProcess.make(
2435 "node",
2436 [
2437 path.join(input.repoRoot, "apps/desktop/scripts/build-browser-secret.mjs"),
2438 "--arch",
2439 input.arch === "arm64" ? "arm64" : "x64",
2440 "--output",
2441 path.join(input.stageResourcesDir, "browser-secret", "t3-browser-secret"),
2442 ],
2443 { cwd: input.repoRoot },
2444 ),
2445 { label: "build Linux browser secret helper", verbose: input.verbose },
2446 );
2447});
2448
2449function generateMacIconSet(
2450 sourcePng: string,
2451 targetIcns: string,
2452 tmpRoot: string,
2453 path: Path.Path,
2454 verbose: boolean,
2455) {
2456 return Effect.gen(function* () {
2457 const fs = yield* FileSystem.FileSystem;
2458 const iconsetDir = path.join(tmpRoot, "icon.iconset");
2459 yield* fs.makeDirectory(iconsetDir, { recursive: true });
2460
2461 const iconSizes = [16, 32, 128, 256, 512] as const;
2462 for (const size of iconSizes) {
2463 yield* runCommand(
2464 ChildProcess.make(
2465 {},
2466 )`sips -z ${size} ${size} ${sourcePng} --out ${path.join(iconsetDir, `icon_${size}x${size}.png`)}`,
2467 { label: `sips icon ${size}x${size}`, verbose },
2468 );
2469
2470 const retinaSize = size * 2;
2471 yield* runCommand(
2472 ChildProcess.make(
2473 {},
2474 )`sips -z ${retinaSize} ${retinaSize} ${sourcePng} --out ${path.join(iconsetDir, `icon_${size}x${size}@2x.png`)}`,
2475 { label: `sips icon ${size}x${size}@2x`, verbose },
2476 );
2477 }
2478
2479 yield* runCommand(ChildProcess.make({})`iconutil -c icns ${iconsetDir} -o ${targetIcns}`, {
2480 label: "iconutil icns",
2481 verbose,
2482 });
2483 });
2484}
2485
2486function stageMacIcons(stageResourcesDir: string, sourcePng: string, verbose: boolean) {
2487 return Effect.gen(function* () {
2488 const fs = yield* FileSystem.FileSystem;
2489 const path = yield* Path.Path;
2490 if (!(yield* fs.exists(sourcePng))) {
2491 return yield* new DesktopIconSourceMissingError({
2492 platform: "mac",
2493 sourcePath: sourcePng,
2494 });
2495 }
2496
2497 const tmpRoot = yield* fs.makeTempDirectoryScoped({
2498 prefix: "t3code-icon-build-",
2499 });
2500
2501 const iconPngPath = path.join(stageResourcesDir, "icon.png");
2502 const iconIcnsPath = path.join(stageResourcesDir, "icon.icns");
2503
2504 yield* runCommand(ChildProcess.make({})`sips -z 512 512 ${sourcePng} --out ${iconPngPath}`, {
2505 label: "sips mac icon",
2506 verbose,
2507 });
2508
2509 yield* generateMacIconSet(sourcePng, iconIcnsPath, tmpRoot, path, verbose);
2510 });
2511}
2512
2513export const stageDesktopDmgBackground = Effect.fn("stageDesktopDmgBackground")(function* (
2514 stageResourcesDir: string,
2515 channel: "latest" | "nightly",
2516 verbose: boolean,
2517) {
2518 const fs = yield* FileSystem.FileSystem;
2519 const path = yield* Path.Path;
2520 const sourcePath = path.join(stageResourcesDir, "dmg", `dmg-background-${channel}.svg`);
2521 if (!(yield* fs.exists(sourcePath))) {
2522 return yield* new DesktopDmgBackgroundSourceMissingError({ channel, sourcePath });
2523 }
2524
2525 for (const output of [
2526 { suffix: "", width: 640, height: 432 },
2527 { suffix: "@2x", width: 1280, height: 864 },
2528 ] as const) {
2529 const targetPath = path.join(
2530 stageResourcesDir,
2531 "dmg",
2532 `dmg-background-${channel}${output.suffix}.png`,
2533 );
2534 yield* runCommand(
2535 ChildProcess.make(
2536 {},
2537 )`sips -s format png -z ${output.height} ${output.width} ${sourcePath} --out ${targetPath}`,
2538 {
2539 label: `sips ${channel} DMG background${output.suffix || "@1x"}`,
2540 verbose,
2541 },
2542 );
2543 }
2544});
2545
2546function stageLinuxIcons(stageResourcesDir: string, sourcePng: string, verbose: boolean) {
2547 return Effect.gen(function* () {
2548 const fs = yield* FileSystem.FileSystem;
2549 const path = yield* Path.Path;
2550 if (!(yield* fs.exists(sourcePng))) {
2551 return yield* new DesktopIconSourceMissingError({
2552 platform: "linux",
2553 sourcePath: sourcePng,
2554 });
2555 }
2556
2557 const iconPath = path.join(stageResourcesDir, "icon.png");
2558 yield* fs.copyFile(sourcePng, iconPath);
2559
2560 const iconsDir = path.join(stageResourcesDir, "icons");
2561 yield* fs.makeDirectory(iconsDir, { recursive: true });
2562 for (const iconSize of LINUX_ICON_SIZES) {
2563 yield* stageLinuxIconSize(
2564 sourcePng,
2565 path.join(iconsDir, `${iconSize}x${iconSize}.png`),
2566 iconSize,
2567 verbose,
2568 );
2569 }
2570 });
2571}
2572
2573export function stageLinuxIconSize(
2574 sourcePng: string,
2575 targetPng: string,
2576 iconSize: number,
2577 verbose: boolean,
2578) {
2579 const resize = (command: string) =>
2580 runCommand(
2581 ChildProcess.make(command, [sourcePng, "-resize", `${iconSize}x${iconSize}`, targetPng]),
2582 { label: `${command} linux icon ${iconSize}x${iconSize}`, verbose },
2583 );
2584
2585 return resize("magick").pipe(
2586 Effect.catch((primaryCause) =>
2587 resize("convert").pipe(
2588 Effect.mapError(
2589 (fallbackCause) =>
2590 new LinuxIconResizeError({
2591 operation: "resize",
2592 iconSize,
2593 primaryTool: "magick",
2594 fallbackTool: "convert",
2595 cause: new AggregateError(
2596 [primaryCause, fallbackCause],
2597 "Both Linux icon resize tool attempts failed.",
2598 { cause: primaryCause },
2599 ),
2600 }),
2601 ),
2602 ),
2603 ),
2604 );
2605}
2606
2607function stageWindowsIcons(stageResourcesDir: string, sourceIco: string) {
2608 return Effect.gen(function* () {
2609 const fs = yield* FileSystem.FileSystem;
2610 const path = yield* Path.Path;
2611 if (!(yield* fs.exists(sourceIco))) {
2612 return yield* new DesktopIconSourceMissingError({
2613 platform: "win",
2614 sourcePath: sourceIco,
2615 });
2616 }
2617
2618 const iconPath = path.join(stageResourcesDir, "icon.ico");
2619 yield* fs.copyFile(sourceIco, iconPath);
2620 });
2621}
2622
2623function validateBundledClientAssets(clientDir: string) {
2624 return Effect.gen(function* () {
2625 const fs = yield* FileSystem.FileSystem;
2626 const path = yield* Path.Path;
2627 const indexPath = path.join(clientDir, "index.html");
2628 const indexHtml = yield* fs.readFileString(indexPath);
2629 const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)]
2630 .map((match) => match[1])
2631 .filter((value): value is string => value !== undefined);
2632 const missing: string[] = [];
2633
2634 for (const ref of refs) {
2635 const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? "";
2636 if (!normalizedRef) continue;
2637 if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue;
2638 if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue;
2639
2640 const ext = path.extname(normalizedRef);
2641 if (!ext) continue;
2642
2643 const relativePath = normalizedRef.replace(/^\/+/, "");
2644 const assetPath = path.join(clientDir, relativePath);
2645 if (!(yield* fs.exists(assetPath))) {
2646 missing.push(normalizedRef);
2647 }
2648 }
2649
2650 if (missing.length > 0) {
2651 return yield* new BundledClientAssetsMissingError({
2652 indexPath,
2653 missingFiles: missing,
2654 });
2655 }
2656 });
2657}
2658
2659// The main-process bundle inlines every JS dependency (see
2660// apps/desktop/vite.config.ts), so the packaged app only installs the packages
2661// that bundle leaves external: native addons and playwright-core. Everything
2662// else already lives inside dist-electron and would only duplicate what the
2663// server bundle carries too.
2664export function resolveDesktopRuntimeDependencies(
2665 dependencies: Record<string, string> | undefined,
2666 catalog: Record<string, string>,
2667): Record<string, string> {
2668 if (!dependencies || Object.keys(dependencies).length === 0) {
2669 return {};
2670 }
2671
2672 return resolveCatalogDependencies(
2673 selectDesktopRuntimeExternalDependencies(dependencies),
2674 catalog,
2675 "apps/desktop",
2676 );
2677}
2678
2679export const resolveGitHubPublishConfig = Effect.fn("resolveGitHubPublishConfig")(function* (
2680 updateChannel: "latest" | "nightly",
2681) {
2682 const env = yield* Config.all({
2683 updateRepository: Config.String("T3CODE_DESKTOP_UPDATE_REPOSITORY").pipe(Config.option),
2684 githubRepository: Config.String("GITHUB_REPOSITORY").pipe(Config.option),
2685 });
2686 const rawRepo = (
2687 Option.getOrUndefined(env.updateRepository)?.trim() ||
2688 Option.getOrUndefined(env.githubRepository)?.trim() ||
2689 ""
2690 ).trim();
2691 if (!rawRepo) return undefined;
2692
2693 const [owner, repo, ...rest] = rawRepo.split("/");
2694 if (!owner || !repo || rest.length > 0) return undefined;
2695
2696 return {
2697 provider: "github",
2698 owner,
2699 repo,
2700 releaseType: updateChannel === "nightly" ? "prerelease" : "release",
2701 ...(updateChannel === "nightly" ? { channel: "nightly" as const } : {}),
2702 };
2703});
2704
2705export function resolveDesktopUpdateChannel(version: string): "latest" | "nightly" {
2706 return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest";
2707}
2708
2709// Pull request builds (`-pr.<n>.`) and the maintainers' preview train
2710// (`-preview.<date>.<run>`) are downloaded by hand and never through an
2711// updater. Building them without a publish config means electron-builder
2712// emits no `latest*.yml`/`nightly*.yml` manifests or blockmaps for them and
2713// the app ships without `app-update.yml`, so neither a stable nor a nightly
2714// install can be pointed at one of these releases, and the build itself
2715// reports that no update feed is configured instead of polling.
2716export function isDesktopPreviewVersion(version: string): boolean {
2717 return /-pr\./.test(version) || /-preview\.\d{8}\.\d+$/.test(version);
2718}
2719
2720export function resolveDesktopWebAssetBrand(version: string): WebAssetBrand {
2721 return resolveWebAssetBrandForChannel(resolveDesktopUpdateChannel(version));
2722}
2723
2724export function resolveDesktopBuildIconAssets(version: string): DesktopBuildIconAssets {
2725 if (resolveDesktopUpdateChannel(version) === "nightly") {
2726 return {
2727 macIconPng: BRAND_ASSET_PATHS.nightlyMacIconPng,
2728 linuxIconPng: BRAND_ASSET_PATHS.nightlyLinuxIconPng,
2729 windowsIconIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco,
2730 };
2731 }
2732
2733 return {
2734 macIconPng: BRAND_ASSET_PATHS.productionMacIconPng,
2735 linuxIconPng: BRAND_ASSET_PATHS.productionLinuxIconPng,
2736 windowsIconIco: BRAND_ASSET_PATHS.productionWindowsIconIco,
2737 };
2738}
2739
2740export function resolveMockUpdateServerUrl(mockUpdateServerPort: number | undefined): string {
2741 return `http://localhost:${mockUpdateServerPort ?? 3000}`;
2742}
2743
2744// Electron Builder detects pnpm from npm_config_user_agent, whose value uses
2745// user-agent syntax (pnpm/11.10.0) rather than packageManager syntax
2746// (pnpm@11.10.0).
2747export function resolvePackageManagerUserAgent(packageManager: string): string {
2748 const trimmed = packageManager.trim();
2749 const versionSeparator = trimmed.lastIndexOf("@");
2750 if (versionSeparator <= 0 || versionSeparator === trimmed.length - 1) {
2751 return trimmed;
2752 }
2753
2754 return `${trimmed.slice(0, versionSeparator)}/${trimmed.slice(versionSeparator + 1)}`;
2755}
2756
2757export function resolveDesktopProductName(version: string): string {
2758 return resolveDesktopUpdateChannel(version) === "nightly"
2759 ? "The Knife (Nightly)"
2760 : (desktopPackageJson.productName ?? "The Knife");
2761}
2762
2763export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
2764 platform: typeof BuildPlatform.Type,
2765 target: string,
2766 version: string,
2767 signed: boolean,
2768 mockUpdates: boolean,
2769 mockUpdateServerPort: number | undefined,
2770 macPasskeySigning:
2771 | {
2772 readonly entitlementsPath: string;
2773 readonly provisioningProfilePath: string;
2774 }
2775 | undefined,
2776 // Windows only, and false when no Linux CLI archive was handed to the build:
2777 // staging skips the archive in that case, and listing a resource whose
2778 // source file was never written fails the electron-builder step.
2779 wslRuntimeBundled = false,
2780 arch?: typeof BuildArch.Type,
2781) {
2782 const buildConfig: Record<string, unknown> = {
2783 appId: DESKTOP_APP_ID,
2784 productName: resolveDesktopProductName(version),
2785 artifactName: "The-Knife-${version}-${arch}.${ext}",
2786 electronLanguages: [...DESKTOP_ELECTRON_LANGUAGES],
2787 files: [
2788 ...DESKTOP_FILE_EXCLUSIONS,
2789 ...(platform === "mac"
2790 ? resolveMacFileExclusions(arch)
2791 : platform === "linux"
2792 ? LINUX_FILE_EXCLUSIONS
2793 : []),
2794 ],
2795 directories: {
2796 buildResources: "apps/desktop/resources",
2797 },
2798 // Smart unpack extracts entire native packages, including JavaScript and
2799 // metadata. Windows keeps those files archived so native dependencies do
2800 // not inflate the loose-file count and slow NSIS installation.
2801 ...(platform === "win"
2802 ? { asar: { smartUnpack: false }, asarUnpack: [WINDOWS_NATIVE_ASAR_UNPACK_GLOB] }
2803 : {}),
2804 extraResources: [
2805 ...DESKTOP_EXTRA_RESOURCES,
2806 ...(platform === "linux" ? LINUX_CAPTURE_EXTRA_RESOURCES : []),
2807 ...(platform === "linux" ? LINUX_BROWSER_SECRET_EXTRA_RESOURCES : []),
2808 ...(platform === "win" ? WINDOWS_SERVER_EXTRA_RESOURCES : []),
2809 ...(platform === "win" && wslRuntimeBundled ? WSL_RUNTIME_EXTRA_RESOURCES : []),
2810 ],
2811 };
2812 const updateChannel = resolveDesktopUpdateChannel(version);
2813 if (!isDesktopPreviewVersion(version)) {
2814 const updateUrl = yield* Config.URL("T3CODE_DESKTOP_UPDATE_URL").pipe(Config.option);
2815 const publishConfig = yield* resolveGitHubPublishConfig(updateChannel);
2816 if (Option.isSome(updateUrl)) {
2817 updateUrl.value.searchParams.set("ls", "");
2818 buildConfig.publish = [{ provider: "generic", url: updateUrl.value.href }];
2819 } else if (publishConfig) {
2820 buildConfig.publish = [publishConfig];
2821 } else if (mockUpdates) {
2822 buildConfig.publish = [
2823 {
2824 provider: "generic",
2825 url: resolveMockUpdateServerUrl(mockUpdateServerPort),
2826 },
2827 ];
2828 }
2829 }
2830
2831 if (platform === "mac") {
2832 const path = yield* Path.Path;
2833 const repoRoot = yield* RepoRoot;
2834 const configuredIdentity = yield* Config.String("T3CODE_DESKTOP_MAC_SIGN_IDENTITY").pipe(
2835 Config.option,
2836 );
2837 const identity = Option.getOrUndefined(configuredIdentity)?.trim();
2838 const signingType = yield* Config.Literals(
2839 ["development", "distribution"],
2840 "T3CODE_DESKTOP_MAC_SIGN_TYPE",
2841 ).pipe(Config.withDefault("development"));
2842 buildConfig.mac = {
2843 target: target === "dmg" ? [target, "zip"] : [target],
2844 icon: "icon.icns",
2845 category: "public.app-category.developer-tools",
2846 extendInfo: {
2847 // macOS 26 offers Messages/Mail codes in every text field unless opted out.
2848 NSAutoFillRequiresTextContentTypeForOneTimeCodeOnMac: true,
2849 NSLocalNetworkUsageDescription:
2850 "The Knife connects to devices on your local network for remote environments and commands run by terminals and coding agents.",
2851 NSScreenCaptureUsageDescription:
2852 "The Knife captures the active window when you use the window capture shortcut.",
2853 // macOS lists an app under Default web browser only when it opens web
2854 // pages as documents as well as http and https links (see protocols).
2855 CFBundleDocumentTypes: [
2856 {
2857 CFBundleTypeName: "Web page",
2858 CFBundleTypeRole: "Viewer",
2859 LSHandlerRank: "Alternate",
2860 LSItemContentTypes: ["public.html", "public.xhtml"],
2861 },
2862 ],
2863 },
2864 protocols: [
2865 {
2866 name: "The Knife",
2867 schemes: ["t3code", "t3code-dev"],
2868 },
2869 // Lets people choose The Knife as their default web browser, which opens
2870 // each link in a new thread's browser panel.
2871 {
2872 name: "Web site URL",
2873 schemes: ["http", "https"],
2874 role: "Viewer",
2875 },
2876 ],
2877 ...(signed || identity ? { sign: path.join(repoRoot, "scripts/sign-macos.ts") } : {}),
2878 ...(identity
2879 ? {
2880 identity,
2881 type: signingType,
2882 hardenedRuntime: true,
2883 notarize: signingType === "distribution",
2884 }
2885 : {}),
2886 ...(macPasskeySigning
2887 ? {
2888 entitlements: macPasskeySigning.entitlementsPath,
2889 provisioningProfile: macPasskeySigning.provisioningProfilePath,
2890 }
2891 : {}),
2892 };
2893 if (identity) buildConfig.forceCodeSigning = true;
2894 }
2895
2896 if (platform === "mac" && target === "dmg") {
2897 buildConfig.dmg = {
2898 // Give the themed installer its own Finder volume name. Finder caches
2899 // DMG window backgrounds by volume name, so reusing a generic name can
2900 // make a newly built background look unchanged during testing.
2901 title: `${resolveDesktopProductName(version)} ${version} Installer`,
2902 background: `dmg/dmg-background-${updateChannel}.png`,
2903 window: {
2904 width: 640,
2905 // The DMG backend derives bounds from the image, including Finder's
2906 // 32px title bar. Keep the last 32px of the artwork free of content.
2907 height: 432,
2908 },
2909 contents: [
2910 { x: 166, y: 214, type: "file" },
2911 { x: 474, y: 214, type: "link", path: "/Applications" },
2912 ],
2913 iconSize: 120,
2914 iconTextSize: 12,
2915 };
2916 }
2917
2918 if (platform === "linux") {
2919 // electron-builder 26 defaults to its legacy AppImage runtime, which
2920 // dynamically loads the system libfuse2 library. Pin the static runtime so
2921 // the AppImage also launches on distributions that only provide FUSE 3.
2922 buildConfig.toolsets = { appimage: "1.0.3" };
2923 const path = yield* Path.Path;
2924 const repoRoot = yield* RepoRoot;
2925 buildConfig.linux = {
2926 // The .deb is built from the same unpacked app after the AppImage.
2927 // electron-builder lists both in latest-linux.yml and writes
2928 // resources/package-type into the .deb only, so electron-updater updates
2929 // each install in its own format.
2930 target: target === "AppImage" ? [target, "deb"] : [target],
2931 executableName: "t3code",
2932 icon: "icons",
2933 category: "Development",
2934 synopsis: "Desktop GUI for coding agents",
2935 // Required by the .deb control file.
2936 maintainer: "T3 Tools <hello@t3.codes>",
2937 // electron-builder turns these into MimeType=x-scheme-handler/<scheme>;
2938 // in the .desktop entry (Exec already gets %U), so browsers can hand
2939 // t3code:// OAuth callbacks to the app.
2940 protocols: [
2941 {
2942 name: "The Knife",
2943 schemes: ["t3code", "t3code-dev"],
2944 },
2945 ],
2946 desktop: {
2947 entry: {
2948 StartupWMClass: "t3code",
2949 },
2950 },
2951 };
2952 buildConfig.deb = {
2953 // FPM runs outside the staged app directory, so source paths must be absolute.
2954 // AppStream consumers associate this metadata with our t3code.desktop entry.
2955 fpm: [
2956 `${path.join(repoRoot, "apps/desktop/resources/linux/com.t3tools.t3code.metainfo.xml")}=/usr/share/metainfo/com.t3tools.t3code.metainfo.xml`,
2957 `${path.join(repoRoot, "LICENSE")}=/usr/share/doc/t3code/copyright`,
2958 ],
2959 // Electron's runtime libraries. Debian 13 and Ubuntu 24.04 renamed some
2960 // for 64-bit time; the old name is the fallback for older releases.
2961 depends: [
2962 "libasound2t64 | libasound2",
2963 "libatspi2.0-0t64 | libatspi2.0-0",
2964 "libgbm1",
2965 "libgtk-3-0t64 | libgtk-3-0",
2966 "libnotify4",
2967 "libnss3",
2968 "libsecret-1-0",
2969 "libuuid1",
2970 "libxss1",
2971 "libxtst6",
2972 "xdg-utils",
2973 ],
2974 };
2975 }
2976
2977 if (platform === "win") {
2978 buildConfig.npmRebuild = false;
2979 // Keep blockmap-based differential downloads enabled while changing the
2980 // installed file topology. The optimization is in the payload shape, not
2981 // in trading update bandwidth for install speed.
2982 buildConfig.nsis = { differentialPackage: true };
2983 const winConfig: Record<string, unknown> = {
2984 target: [target],
2985 icon: "icon.ico",
2986 // Smart App Control validates unpacked native addons independently from
2987 // the signed desktop executable. Extend release signing to every native
2988 // Windows library that Electron or the server sidecar loads at runtime.
2989 signExts: [".node", ".dll"],
2990 // Resource editing applies the product metadata and icon independently
2991 // of code signing. Disabling it for local unsigned builds leaves the
2992 // packaged executable with Electron's stock icon.
2993 signAndEditExecutable: true,
2994 };
2995 if (signed) {
2996 winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig;
2997 }
2998 buildConfig.win = winConfig;
2999 }
3000
3001 return buildConfig;
3002});
3003
3004const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(function* (
3005 platform: typeof BuildPlatform.Type,
3006 stageResourcesDir: string,
3007 iconAssets: DesktopBuildIconAssets,
3008 verbose: boolean,
3009) {
3010 if (platform === "mac") {
3011 yield* stageMacIcons(stageResourcesDir, iconAssets.macIconPng, verbose);
3012 return;
3013 }
3014
3015 if (platform === "linux") {
3016 yield* stageLinuxIcons(stageResourcesDir, iconAssets.linuxIconPng, verbose);
3017 return;
3018 }
3019
3020 if (platform === "win") {
3021 yield* stageWindowsIcons(stageResourcesDir, iconAssets.windowsIconIco);
3022 }
3023});
3024
3025// Copy the Linux CLI release archive into the stage verbatim and record its
3026// SHA-256 beside it. The desktop app verifies the digest inside the distro
3027// before extracting, and the digest also names the extracted runtime's cache
3028// directory, so it must be computed from the exact bytes that ship.
3029export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(function* (input: {
3030 readonly sourceArchivePath: string;
3031 readonly archivePath: string;
3032 readonly hashPath: string;
3033}) {
3034 const fs = yield* FileSystem.FileSystem;
3035 const path = yield* Path.Path;
3036 const sourceExists = yield* fs
3037 .exists(input.sourceArchivePath)
3038 .pipe(Effect.orElseSucceed(() => false));
3039 if (!sourceExists) {
3040 return yield* new WslRuntimeArchiveMissingError({ archivePath: input.sourceArchivePath });
3041 }
3042 yield* fs.makeDirectory(path.dirname(input.archivePath), { recursive: true });
3043 yield* fs.copyFile(input.sourceArchivePath, input.archivePath);
3044 const hash = NodeCrypto.createHash("sha256");
3045 yield* fs
3046 .stream(input.archivePath)
3047 .pipe(Stream.runForEach((chunk) => Effect.sync(() => hash.update(chunk))));
3048 const digest = hash.digest("hex");
3049 yield* fs.writeFileString(input.hashPath, `${digest}\n`);
3050 yield* Effect.log(
3051 `[desktop-artifact] Staged WSL runtime archive at ${input.archivePath} (${digest}).`,
3052 );
3053});
3054
3055// Mirrors cliArchiveStem in scripts/build-cli-archive.ts (which imports from
3056// this module, so it cannot be imported here). WSL runs the same CPU arch as
3057// the Windows host.
3058export const wslRuntimeArchiveStem = (version: string, arch: typeof BuildArch.Type): string =>
3059 `t3-${version}-linux-${arch}`;
3060
3061export const parseWslRuntimeArchiveMembers = (listing: string): ReadonlyArray<string> =>
3062 listing
3063 .split(/\r?\n/)
3064 .map((member) => member.replace(/^\.\//, "").replace(/\/$/, ""))
3065 .filter((member) => member.length > 0);
3066
3067// Stage and pack the Windows server sidecar: the bundled server plus a hoisted
3068// install of only its runtime-external/native dependency closure for win32.
3069// The Windows primary runs from the archive through the asar-aware
3070// ELECTRON_RUN_AS_NODE runtime. Shipping one packed archive instead of
3071// thousands of loose files is what makes the NSIS install/update fast.
3072export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function* (input: {
3073 readonly sourceDir: string;
3074 readonly asarPath: string;
3075 readonly arch: typeof BuildArch.Type;
3076}) {
3077 const fs = yield* FileSystem.FileSystem;
3078 const path = yield* Path.Path;
3079 // Native addons are unpacked only when they are Windows PE ("MZ") binaries.
3080 // Signed builds Authenticode-sign every unpacked .node file, which fails on
3081 // the darwin and linux prebuilds some packages (node-pty) ship alongside
3082 // their win32 ones. The Windows primary never loads those, so they stay packed.
3083 const unpackGlobs: string[] = [...WINDOWS_SERVER_ASAR_UNPACK_GLOBS];
3084 for (const entry of yield* fs.readDirectory(input.sourceDir, { recursive: true })) {
3085 if (!entry.endsWith(".node")) continue;
3086 const addonPath = path.join(input.sourceDir, entry);
3087 if ((yield* fs.stat(addonPath)).type !== "File") continue;
3088 const bytes = yield* fs.readFile(addonPath);
3089 if (bytes[0] !== 0x4d || bytes[1] !== 0x5a) continue;
3090 const posixPath = entry.split(path.sep).join("/");
3091 // Backslash escapes are path separators to minimatch on Windows.
3092 if (/[\\*?[\]{}(),]/.test(posixPath)) {
3093 return yield* new WindowsServerSidecarPackError({
3094 asarPath: input.asarPath,
3095 cause: new Error(`native addon path contains glob syntax: ${posixPath}`),
3096 });
3097 }
3098 unpackGlobs.push(`**/${posixPath}`);
3099 }
3100 yield* Effect.tryPromise({
3101 try: () =>
3102 createPackageWithOptions(input.sourceDir, input.asarPath, {
3103 dot: true,
3104 unpack: `{${unpackGlobs.join(",")}}`,
3105 // glob 13 (via @electron/asar 4) matches `ignore` relative to `cwd`,
3106 // not against the absolute paths it crawls, so anchor it at the source.
3107 globOptions: {
3108 cwd: input.sourceDir,
3109 ignore: resolveWindowsServerAsarIgnoreGlobs(input.arch),
3110 },
3111 }),
3112 catch: (cause) => new WindowsServerSidecarPackError({ asarPath: input.asarPath, cause }),
3113 });
3114 const unpackedDirPath = `${input.asarPath}.unpacked`;
3115 if (!(yield* fs.exists(unpackedDirPath))) {
3116 return yield* new WindowsServerSidecarPackError({
3117 asarPath: input.asarPath,
3118 cause: new Error(`expected native binaries at ${unpackedDirPath}, but none were unpacked`),
3119 });
3120 }
3121});
3122
3123export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(function* (input: {
3124 readonly stageRoot: string;
3125 readonly repoRoot: string;
3126 readonly serverDistDir: string;
3127 readonly arch: typeof BuildArch.Type;
3128 readonly appVersion: string;
3129 readonly runtimeExternalDependencies: Record<string, string>;
3130 readonly fffNodeVersion: string;
3131 readonly allowBuilds: Record<string, boolean>;
3132 readonly patchedDependencies: Record<string, string>;
3133 readonly overrides: Record<string, string>;
3134 readonly asarPath: string;
3135 readonly verbose: boolean;
3136}) {
3137 const fs = yield* FileSystem.FileSystem;
3138 const path = yield* Path.Path;
3139
3140 const serverStageDir = path.join(input.stageRoot, "server");
3141 yield* fs.makeDirectory(path.join(serverStageDir, "apps/server"), { recursive: true });
3142 yield* fs.copy(input.serverDistDir, path.join(serverStageDir, "apps/server/dist"));
3143
3144 const sidecarDependencies = {
3145 ...input.runtimeExternalDependencies,
3146 ...resolveFffNativeDependencies("win", input.arch, input.fffNodeVersion),
3147 };
3148 const sidecarPatchedDependencies = createStagePatchedDependencies(
3149 input.patchedDependencies,
3150 sidecarDependencies,
3151 );
3152 const sidecarPackageJson = {
3153 name: "t3code-server",
3154 version: input.appVersion,
3155 private: true,
3156 packageManager: rootPackageJson.packageManager,
3157 dependencies: sidecarDependencies,
3158 };
3159 const sidecarPackageJsonString = yield* encodeJsonString(sidecarPackageJson);
3160 yield* fs.writeFileString(
3161 path.join(serverStageDir, "package.json"),
3162 `${sidecarPackageJsonString}\n`,
3163 );
3164 const sidecarWorkspaceConfig = {
3165 ...createStageWorkspaceConfig({
3166 platform: "win",
3167 arch: input.arch,
3168 allowBuilds: input.allowBuilds,
3169 patchedDependencies: sidecarPatchedDependencies,
3170 overrides: input.overrides,
3171 }),
3172 // The tree gets packed into server.asar, which cannot carry pnpm's
3173 // symlink/junction layout, so install a physical, hoisted node_modules.
3174 nodeLinker: "hoisted" as const,
3175 };
3176 const sidecarWorkspaceConfigString = yield* encodeStageWorkspaceConfig(sidecarWorkspaceConfig);
3177 yield* fs.writeFileString(
3178 path.join(serverStageDir, "pnpm-workspace.yaml"),
3179 sidecarWorkspaceConfigString,
3180 );
3181 if (Object.keys(sidecarPatchedDependencies).length > 0) {
3182 yield* fs.copy(path.join(input.repoRoot, "patches"), path.join(serverStageDir, "patches"));
3183 }
3184
3185 yield* Effect.log("[desktop-artifact] Installing server sidecar runtime externals...");
3186 const installCommand = yield* resolveSpawnCommand("vp", [...STAGE_INSTALL_ARGS]);
3187 yield* runCommand(
3188 ChildProcess.make(installCommand.command, installCommand.args, {
3189 cwd: serverStageDir,
3190 shell: installCommand.shell,
3191 }),
3192 { label: "vp install --prod (server sidecar)", verbose: input.verbose },
3193 );
3194
3195 yield* Effect.log("[desktop-artifact] Packing server.asar...");
3196 yield* fs.makeDirectory(path.dirname(input.asarPath), { recursive: true });
3197 yield* packWindowsServerAsar({
3198 sourceDir: serverStageDir,
3199 asarPath: input.asarPath,
3200 arch: input.arch,
3201 });
3202 const packedStat = yield* fs.stat(input.asarPath);
3203 yield* Effect.log(
3204 `[desktop-artifact] Packed server.asar (${String(packedStat.size)} bytes) + unpacked natives.`,
3205 );
3206});
3207
3208function collectUnpackedAsarFiles(
3209 directory: DirectoryRecord,
3210 parentPath = "",
3211 output: string[] = [],
3212): readonly string[] {
3213 for (const [name, entry] of Object.entries(directory.files)) {
3214 const entryPath = parentPath.length === 0 ? name : `${parentPath}/${name}`;
3215 if ("files" in entry) {
3216 collectUnpackedAsarFiles(entry, entryPath, output);
3217 } else if (entry.unpacked) {
3218 output.push(entryPath);
3219 }
3220 }
3221 return output;
3222}
3223
3224const countPayloadFiles = Effect.fn("desktopArtifact.countPayloadFiles")(function* (root: string) {
3225 const fs = yield* FileSystem.FileSystem;
3226 const path = yield* Path.Path;
3227 const pendingDirectories = [root];
3228 let count = 0;
3229
3230 while (pendingDirectories.length > 0) {
3231 const directory = pendingDirectories.pop();
3232 if (directory === undefined) break;
3233 const entries = yield* fs.readDirectory(directory);
3234 for (const entry of entries) {
3235 const entryPath = path.join(directory, entry);
3236 const stat = yield* fs.stat(entryPath);
3237 if (stat.type === "Directory") {
3238 pendingDirectories.push(entryPath);
3239 } else if (stat.type === "File") {
3240 count += 1;
3241 }
3242 }
3243 }
3244
3245 return count;
3246});
3247
3248export const verifyWindowsPrimaryFffNativeLoad = Effect.fn(
3249 "desktopArtifact.verifyWindowsPrimaryFffNativeLoad",
3250)(function* (input: {
3251 readonly packagedAppDir: string;
3252 readonly asarPath: string;
3253 readonly appExecutableName: string;
3254 readonly targetArch: typeof BuildArch.Type;
3255 readonly verbose: boolean;
3256}) {
3257 const hostPlatform = yield* HostProcess.Platform;
3258 const hostArchitecture = yield* HostProcess.Architecture;
3259 const fs = yield* FileSystem.FileSystem;
3260 const path = yield* Path.Path;
3261 const executablePath = path.join(input.packagedAppDir, input.appExecutableName);
3262 const executableStat = yield* fs.stat(executablePath).pipe(Effect.orElseSucceed(() => null));
3263 if (executableStat?.type !== "File") {
3264 return yield* new WindowsPrimaryNativeProbeError({
3265 executablePath,
3266 exitCode: -1,
3267 output: "The unpacked application does not contain its expected primary executable.",
3268 });
3269 }
3270 if (hostPlatform !== "win32" || hostArchitecture !== input.targetArch) return;
3271
3272 const probeRoot = yield* fs.makeTempDirectoryScoped({
3273 prefix: "t3code-windows-primary-native-probe-",
3274 });
3275 const fffEntryPath = path.join(input.asarPath, "node_modules/@ff-labs/fff-node/dist/index.js");
3276 const probeEnv = { ...process.env };
3277 delete probeEnv.ELECTRON_NO_ASAR;
3278 delete probeEnv.NODE_OPTIONS;
3279
3280 yield* runCommand(
3281 ChildProcess.make(
3282 executablePath,
3283 [
3284 "--no-global-search-paths",
3285 "--input-type=module",
3286 "--eval",
3287 WINDOWS_PRIMARY_FFF_PROBE_SOURCE,
3288 fffEntryPath,
3289 probeRoot,
3290 ],
3291 {
3292 cwd: input.packagedAppDir,
3293 stdout: "pipe",
3294 stderr: "pipe",
3295 env: {
3296 ...probeEnv,
3297 ELECTRON_RUN_AS_NODE: "1",
3298 NODE_PATH: "",
3299 },
3300 },
3301 ),
3302 {
3303 label: "Windows primary fff native-load probe",
3304 verbose: input.verbose,
3305 },
3306 ).pipe(
3307 Effect.timeout(WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT),
3308 Effect.catchTags({
3309 TimeoutError: () =>
3310 Effect.fail(
3311 new WindowsPrimaryNativeProbeError({
3312 executablePath,
3313 exitCode: -1,
3314 output: `The native-load probe did not finish within ${Duration.toSeconds(WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT)}s.`,
3315 }),
3316 ),
3317 BuildCommandFailedError: (error) =>
3318 Effect.fail(
3319 new WindowsPrimaryNativeProbeError({
3320 executablePath,
3321 exitCode: error.exitCode,
3322 output: `${error.stderrTail ?? ""}${error.stdoutTail ?? ""}`.trim(),
3323 }),
3324 ),
3325 }),
3326 );
3327});
3328
3329export const validateWindowsPackagedPayload = Effect.fn(
3330 "desktopArtifact.validateWindowsPackagedPayload",
3331)(function* (input: {
3332 readonly stageDistDir: string;
3333 readonly appExecutableName: string;
3334 readonly targetArch: typeof BuildArch.Type;
3335 // The version the embedded Linux CLI archive must carry; its top-level
3336 // directory is named t3-<version>-linux-<arch>.
3337 readonly appVersion: string;
3338 readonly expectWslRuntime?: boolean;
3339 readonly fileLimit?: number;
3340 readonly verbose?: boolean;
3341}) {
3342 const fs = yield* FileSystem.FileSystem;
3343 const path = yield* Path.Path;
3344 const fileLimit = input.fileLimit ?? WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT;
3345 const isFile = (filePath: string) =>
3346 fs.stat(filePath).pipe(
3347 Effect.map((stat) => stat.type === "File"),
3348 Effect.orElseSucceed(() => false),
3349 );
3350 const stageEntries = yield* fs.readDirectory(input.stageDistDir);
3351 let packagedAppDir: string | undefined;
3352
3353 for (const entry of stageEntries) {
3354 if (!entry.endsWith("-unpacked")) continue;
3355 const candidate = path.join(input.stageDistDir, entry);
3356 const stat = yield* fs.stat(candidate).pipe(Effect.orElseSucceed(() => null));
3357 if (stat?.type === "Directory") {
3358 packagedAppDir = candidate;
3359 break;
3360 }
3361 }
3362
3363 if (packagedAppDir === undefined) {
3364 return yield* new WindowsPackagedPayloadValidationError({
3365 reason: "packaged-app-missing",
3366 packagedAppDir: path.join(input.stageDistDir, "win-unpacked"),
3367 });
3368 }
3369
3370 const resourcesDir = path.join(packagedAppDir, "resources");
3371 const asarPath = path.join(resourcesDir, WINDOWS_SERVER_ASAR_RESOURCE);
3372 if (!(yield* fs.exists(asarPath).pipe(Effect.orElseSucceed(() => false)))) {
3373 return yield* new WindowsPackagedPayloadValidationError({
3374 reason: "sidecar-missing",
3375 packagedAppDir,
3376 missingFiles: [WINDOWS_SERVER_ASAR_RESOURCE],
3377 });
3378 }
3379
3380 const unpackedFiles = yield* Effect.try({
3381 try: () => {
3382 // The entry lookup proves the archive contains the server executable,
3383 // while the single header walk identifies every file ASAR redirects to
3384 // the unpacked sibling at runtime.
3385 // @electron/asar resolves entry names using the host path separator.
3386 // POSIX separators work on Linux/macOS but fail on Windows even when the
3387 // entry is present in the archive.
3388 statFile(asarPath, path.join("apps", "server", "dist", "bin.mjs"));
3389 return [...collectUnpackedAsarFiles(getRawHeader(asarPath).header)].sort();
3390 },
3391 catch: (cause) =>
3392 new WindowsPackagedPayloadValidationError({
3393 reason: "sidecar-invalid",
3394 packagedAppDir,
3395 cause,
3396 }),
3397 });
3398 if (unpackedFiles.length === 0) {
3399 return yield* new WindowsPackagedPayloadValidationError({
3400 reason: "sidecar-invalid",
3401 packagedAppDir,
3402 cause: new Error("server.asar does not declare any unpacked native files"),
3403 });
3404 }
3405
3406 const missingFiles: string[] = [];
3407 for (const unpackedFile of unpackedFiles) {
3408 const unpackedPath = path.join(
3409 resourcesDir,
3410 `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked`,
3411 ...unpackedFile.split("/"),
3412 );
3413 if (!(yield* isFile(unpackedPath))) {
3414 missingFiles.push(`${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked/${unpackedFile}`);
3415 }
3416 }
3417 if (missingFiles.length > 0) {
3418 return yield* new WindowsPackagedPayloadValidationError({
3419 reason: "unpacked-native-missing",
3420 packagedAppDir,
3421 missingFiles,
3422 });
3423 }
3424
3425 const resourceMonitorPath = path.join(
3426 resourcesDir,
3427 "resource-monitor",
3428 resourceMonitorExecutableName("win"),
3429 );
3430 if (!(yield* isFile(resourceMonitorPath))) {
3431 return yield* new WindowsPackagedPayloadValidationError({
3432 reason: "resource-monitor-missing",
3433 packagedAppDir,
3434 missingFiles: ["resource-monitor/t3-resource-monitor.exe"],
3435 });
3436 }
3437
3438 const wslArchivePath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_NAME);
3439 const wslArchiveHashPath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_HASH_NAME);
3440 const [hasWslArchive, hasWslArchiveHash] = yield* Effect.all([
3441 isFile(wslArchivePath),
3442 isFile(wslArchiveHashPath),
3443 ]);
3444 if (input.expectWslRuntime === true && (!hasWslArchive || !hasWslArchiveHash)) {
3445 return yield* new WindowsPackagedPayloadValidationError({
3446 reason: "wsl-runtime-missing",
3447 packagedAppDir,
3448 missingFiles: [
3449 ...(hasWslArchive ? [] : [WSL_RUNTIME_ARCHIVE_NAME]),
3450 ...(hasWslArchiveHash ? [] : [WSL_RUNTIME_ARCHIVE_HASH_NAME]),
3451 ],
3452 });
3453 }
3454 if (hasWslArchive !== hasWslArchiveHash) {
3455 return yield* new WindowsPackagedPayloadValidationError({
3456 reason: "wsl-runtime-missing",
3457 packagedAppDir,
3458 missingFiles: [hasWslArchive ? WSL_RUNTIME_ARCHIVE_HASH_NAME : WSL_RUNTIME_ARCHIVE_NAME],
3459 });
3460 }
3461 if (hasWslArchive && hasWslArchiveHash) {
3462 const invalidWslRuntime = (cause: unknown) =>
3463 new WindowsPackagedPayloadValidationError({
3464 reason: "wsl-runtime-invalid",
3465 packagedAppDir,
3466 cause,
3467 });
3468 const recordedHash = yield* fs
3469 .readFileString(wslArchiveHashPath)
3470 .pipe(Effect.mapError(invalidWslRuntime));
3471 const expectedHash = recordedHash.trim().toLowerCase();
3472 if (!/^[0-9a-f]{64}$/.test(expectedHash)) {
3473 return yield* invalidWslRuntime(new Error("invalid WSL runtime SHA-256 sidecar"));
3474 }
3475 const archiveHash = NodeCrypto.createHash("sha256");
3476 yield* fs.stream(wslArchivePath).pipe(
3477 Stream.runForEach((chunk) => Effect.sync(() => archiveHash.update(chunk))),
3478 Effect.mapError(invalidWslRuntime),
3479 );
3480 const actualHash = archiveHash.digest("hex");
3481 if (actualHash !== expectedHash) {
3482 return yield* invalidWslRuntime(
3483 new Error(`WSL runtime SHA-256 mismatch: expected ${expectedHash}, got ${actualHash}`),
3484 );
3485 }
3486
3487 const listing = yield* spawnAndCollectOutput(
3488 ChildProcess.make("tar", ["-tzf", WSL_RUNTIME_ARCHIVE_NAME], {
3489 cwd: resourcesDir,
3490 stdin: "ignore",
3491 stdout: "pipe",
3492 stderr: "pipe",
3493 }),
3494 ).pipe(Effect.mapError(invalidWslRuntime));
3495 if (listing.exitCode !== 0) {
3496 return yield* invalidWslRuntime(
3497 new Error(`tar could not list WSL runtime archive: ${listing.stderr.trim()}`),
3498 );
3499 }
3500 const members = parseWslRuntimeArchiveMembers(listing.stdout);
3501 // A release archive unpacks to one directory named after its stem; the
3502 // desktop app's WSL install script relies on that layout to find `t3`.
3503 const stem = wslRuntimeArchiveStem(input.appVersion, input.targetArch);
3504 const topLevel = new Set(members.map((member) => member.split("/")[0]));
3505 if (topLevel.size !== 1 || !topLevel.has(stem)) {
3506 return yield* invalidWslRuntime(
3507 new Error(
3508 `WSL runtime archive must contain a single top-level directory ${stem}, found ${[...topLevel].join(", ") || "nothing"}`,
3509 ),
3510 );
3511 }
3512 const requiredMembers = [`${stem}/t3`, `${stem}/client`, `${stem}/node_modules`];
3513 const missingMembers = requiredMembers.filter((member) => !members.includes(member));
3514 // node-pty can load a source build or the prebuild for the WSL target.
3515 const ptyCandidates = [
3516 `${stem}/node_modules/node-pty/build/Release/pty.node`,
3517 `${stem}/node_modules/node-pty/prebuilds/linux-${input.targetArch}/pty.node`,
3518 ];
3519 if (!ptyCandidates.some((member) => members.includes(member))) {
3520 missingMembers.push(...ptyCandidates);
3521 }
3522 if (missingMembers.length > 0) {
3523 return yield* new WindowsPackagedPayloadValidationError({
3524 reason: "wsl-runtime-invalid",
3525 packagedAppDir,
3526 missingFiles: missingMembers,
3527 cause: new Error("WSL runtime archive is not a Linux CLI release archive"),
3528 });
3529 }
3530 // The CLI archive runs the single-executable, never a loose server bundle.
3531 const bundleEntry = members.find((member) => member.endsWith("/bin.mjs"));
3532 if (bundleEntry !== undefined) {
3533 return yield* invalidWslRuntime(
3534 new Error(`WSL runtime archive contains a server bundle entry ${bundleEntry}`),
3535 );
3536 }
3537 }
3538
3539 const fileCount = yield* countPayloadFiles(packagedAppDir);
3540 if (fileCount > fileLimit) {
3541 return yield* new WindowsPackagedPayloadValidationError({
3542 reason: "file-limit-exceeded",
3543 packagedAppDir,
3544 fileCount,
3545 fileLimit,
3546 });
3547 }
3548
3549 yield* verifyWindowsPrimaryFffNativeLoad({
3550 packagedAppDir,
3551 asarPath,
3552 appExecutableName: input.appExecutableName,
3553 targetArch: input.targetArch,
3554 verbose: input.verbose ?? false,
3555 });
3556
3557 yield* verifyPackagedBundleIsSelfContained({
3558 asarPath,
3559 executablePath: path.join(packagedAppDir, input.appExecutableName),
3560 verbose: input.verbose ?? false,
3561 });
3562
3563 yield* Effect.log(
3564 `[desktop-artifact] Validated Windows payload (${String(fileCount)} files, ${String(unpackedFiles.length)} sidecar natives).`,
3565 );
3566 return { packagedAppDir, fileCount, unpackedFiles } as const;
3567});
3568
3569export const exportForkResources = Effect.fn("desktopArtifact.exportForkResources")(
3570 function* (input: {
3571 readonly stageDistDir: string;
3572 readonly outputDir: string;
3573 readonly platform: "linux" | "win";
3574 readonly arch: typeof BuildArch.Type;
3575 readonly serverRuntime: string;
3576 }) {
3577 const fs = yield* FileSystem.FileSystem;
3578 const path = yield* Path.Path;
3579 const entries = yield* fs.readDirectory(input.stageDistDir);
3580 const unpacked = entries.find((entry) => entry.endsWith("-unpacked"));
3581 const resources = path.join(
3582 input.stageDistDir,
3583 unpacked ?? `${input.platform}-unpacked`,
3584 "resources",
3585 );
3586 const required = input.platform === "win" ? ["app.asar", "server.asar"] : ["app.asar"];
3587 const members: string[] = [];
3588 for (const file of required) {
3589 yield* fs.stat(path.join(resources, file));
3590 members.push(file);
3591 if (yield* fs.exists(path.join(resources, `${file}.unpacked`)))
3592 members.push(`${file}.unpacked`);
3593 }
3594 const platform = input.platform === "win" ? "win32" : "linux";
3595 const archive = path.join(input.outputDir, `knife-resources-${platform}-${input.arch}.tar.gz`);
3596 const metadata = yield* fs.makeTempDirectoryScoped();
3597 yield* fs.writeFileString(path.join(metadata, "knife-server-runtime"), input.serverRuntime);
3598 yield* fs.makeDirectory(input.outputDir, { recursive: true });
3599 yield* runCommand(
3600 ChildProcess.make("tar", [
3601 "-czf",
3602 archive,
3603 "-C",
3604 resources,
3605 ...members,
3606 "-C",
3607 metadata,
3608 "knife-server-runtime",
3609 ]),
3610 { label: "Archive desktop resources for publishing", verbose: false },
3611 );
3612 return archive;
3613 },
3614);
3615
3616const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
3617 options: ResolvedBuildOptions,
3618) {
3619 const repoRoot = yield* RepoRoot;
3620 const path = yield* Path.Path;
3621 const fs = yield* FileSystem.FileSystem;
3622 const hostPlatform = yield* HostProcess.Platform;
3623 if (hostPlatform === "linux" && options.platform === "linux") {
3624 yield* preflightLinuxDesktopBuild(options.arch);
3625 }
3626 if (hostPlatform === "darwin" && options.platform === "mac") {
3627 yield* preflightMacDesktopBuild(options.arch);
3628 }
3629 if (hostPlatform === "win32" && options.platform === "win") {
3630 yield* preflightWindowsDesktopBuild({
3631 arch: options.arch,
3632 bundlesWslRuntime: bundlesWslRuntime({
3633 platform: options.platform,
3634 runtimeArchivePath: options.wslRuntime,
3635 }),
3636 });
3637 }
3638 const workspaceConfig = yield* readWorkspaceConfig();
3639 const workspaceCatalog = workspaceConfig.catalog ?? {};
3640 const workspaceOverrides = workspaceConfig.overrides ?? {};
3641 const workspacePatchedDependencies = workspaceConfig.patchedDependencies ?? {};
3642 const workspaceAllowBuilds = workspaceConfig.allowBuilds ?? {};
3643
3644 const platformConfig = PLATFORM_CONFIG[options.platform];
3645 if (!platformConfig) {
3646 return yield* new UnsupportedDesktopBuildPlatformError({
3647 platform: options.platform,
3648 });
3649 }
3650
3651 const electronVersion = desktopPackageJson.dependencies.electron;
3652
3653 const serverDependencies = serverPackageJson.dependencies;
3654 if (!serverDependencies || Object.keys(serverDependencies).length === 0) {
3655 return yield* new MissingServerProductionDependenciesError({
3656 manifestPath: "apps/server/package.json",
3657 });
3658 }
3659
3660 const resolvedOverrides = yield* Effect.try({
3661 try: () => resolveCatalogDependencies(workspaceOverrides, workspaceCatalog, "apps/desktop"),
3662 catch: (cause) =>
3663 new DesktopBuildDependencyResolutionError({
3664 kind: "workspace-overrides",
3665 manifestPath: "pnpm-workspace.yaml",
3666 cause,
3667 }),
3668 });
3669
3670 const resolvedServerDependencies = yield* Effect.try({
3671 try: () => resolveCatalogDependencies(serverDependencies, workspaceCatalog, "apps/server"),
3672 catch: (cause) =>
3673 new DesktopBuildDependencyResolutionError({
3674 kind: "server-production",
3675 manifestPath: "apps/server/package.json",
3676 cause,
3677 }),
3678 });
3679 const resolvedServerRuntimeExternalDependencies = selectCliRuntimeExternalDependencies(
3680 resolvedServerDependencies,
3681 );
3682 const resolvedDesktopRuntimeDependencies = yield* Effect.try({
3683 try: () => resolveDesktopRuntimeDependencies(desktopPackageJson.dependencies, workspaceCatalog),
3684 catch: (cause) =>
3685 new DesktopBuildDependencyResolutionError({
3686 kind: "desktop-runtime",
3687 manifestPath: "apps/desktop/package.json",
3688 cause,
3689 }),
3690 });
3691
3692 const appVersion = options.version ?? serverPackageJson.version;
3693 const iconAssets = resolveDesktopBuildIconAssets(appVersion);
3694 const commitHash = yield* resolveCommitHash(repoRoot);
3695 const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped;
3696 const stageRoot = yield* mkdir({
3697 prefix: `t3code-desktop-${options.platform}-stage-`,
3698 });
3699
3700 const stageAppDir = path.join(stageRoot, "app");
3701 const stageResourcesDir = path.join(stageAppDir, "apps/desktop/resources");
3702 const distDirs = {
3703 desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"),
3704 desktopResources: path.join(repoRoot, "apps/desktop/resources"),
3705 serverDist: path.join(repoRoot, "apps/server/dist"),
3706 };
3707 const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html");
3708
3709 if (!options.skipBuild) {
3710 yield* Effect.log("[desktop-artifact] Building desktop/server/web artifacts...");
3711 const spawnCommand = yield* resolveSpawnCommand("vp", ["run", "build:desktop"]);
3712 yield* runCommand(
3713 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
3714 cwd: repoRoot,
3715 shell: spawnCommand.shell,
3716 }),
3717 { label: "vp run build:desktop", verbose: options.verbose },
3718 );
3719 }
3720
3721 const requiredBuildInputs = [
3722 { artifact: "desktop-dist", artifactPath: distDirs.desktopDist },
3723 { artifact: "desktop-resources", artifactPath: distDirs.desktopResources },
3724 { artifact: "server-dist", artifactPath: distDirs.serverDist },
3725 ] as const;
3726 for (const input of requiredBuildInputs) {
3727 if (!(yield* fs.exists(input.artifactPath))) {
3728 return yield* new MissingDesktopBuildInputError({
3729 ...input,
3730 buildCommand: "vp run build:desktop",
3731 });
3732 }
3733 }
3734
3735 // Assert against the emitted bundle, not the bundler config. `alwaysBundle`
3736 // only forces packages IN, so a transitive dependency of an external package
3737 // is bundled by default however the predicate is written — that silently
3738 // inlined a native loader (node-gyp-build-optional-packages) while every
3739 // list-based test still passed. An inlined native loader resolves its
3740 // prebuilds relative to the bundle and quietly falls back to a slower
3741 // pure-JS path, so this fails the build rather than shipping a silent
3742 // regression.
3743 {
3744 const chunkNames = (yield* fs.readDirectory(distDirs.serverDist)).filter((entry) =>
3745 entry.endsWith(".mjs"),
3746 );
3747 let totalRegions = 0;
3748 const inlined = new Set<string>();
3749 const inlinedPackages = new Set<string>();
3750 for (const chunkName of chunkNames) {
3751 const source = yield* fs.readFileString(path.join(distDirs.serverDist, chunkName));
3752 const scan = findInlinedExternalPackages(source);
3753 totalRegions += scan.regionCount;
3754 for (const name of scan.inlined) inlined.add(name);
3755 for (const name of scan.inlinedPackages) inlinedPackages.add(name);
3756 }
3757 if (inlined.size > 0) {
3758 return yield* new InlinedExternalPackageError({
3759 packages: [...inlined].sort(),
3760 });
3761 }
3762 // No regions at all means the scan went blind (marker format changed), not
3763 // that the bundle is clean.
3764 if (totalRegions === 0) {
3765 return yield* new InlinedExternalPackageError({
3766 packages: ["<no module regions found; the bundle scan needs updating>"],
3767 });
3768 }
3769 // The check above is one-directional: it only proves nothing external got
3770 // inlined. A regression to externalizing everything would also pass it,
3771 // since source-file regions still exist -- and that is the failure this
3772 // whole change exists to prevent, because those packages are not in the
3773 // selected sidecar closure and both backends would die on ERR_MODULE_NOT_FOUND.
3774 // `effect` is imported by every server module, so it is inlined in any
3775 // correctly bundled build.
3776 // The list-based check above only sees packages someone already thought to
3777 // list. bufferutil and utf-8-validate were inlined for exactly that reason:
3778 // native, but absent from the list, so nothing flagged them. Ask the store
3779 // what each inlined package actually is instead.
3780 const nativeInlined: string[] = [];
3781 for (const name of [...inlinedPackages].sort()) {
3782 const packageDir = yield* findStorePackageDirectory(repoRoot, name);
3783 if (packageDir === null) continue;
3784 if (yield* hasNativeLoaderMarkers(packageDir)) nativeInlined.push(name);
3785 }
3786 if (nativeInlined.length > 0) {
3787 return yield* new InlinedNativePackageError({ packages: nativeInlined });
3788 }
3789
3790 if (!inlinedPackages.has(BUNDLE_SELF_CONTAINED_SENTINEL)) {
3791 return yield* new ExternalizedBundleError({
3792 sentinel: BUNDLE_SELF_CONTAINED_SENTINEL,
3793 inlinedPackageCount: inlinedPackages.size,
3794 });
3795 }
3796 }
3797
3798 if (!(yield* fs.exists(bundledClientEntry))) {
3799 return yield* new MissingDesktopBuildInputError({
3800 artifact: "bundled-server-client",
3801 artifactPath: bundledClientEntry,
3802 buildCommand: "vp run build:desktop",
3803 });
3804 }
3805
3806 const webAssetBrand = resolveDesktopWebAssetBrand(appVersion);
3807 yield* applyWebBrandAssets(webAssetBrand, "apps/server/dist/client");
3808 yield* Effect.log(`[desktop-artifact] Applied ${webAssetBrand} web client branding.`);
3809 yield* validateBundledClientAssets(path.dirname(bundledClientEntry));
3810
3811 yield* fs.makeDirectory(path.join(stageAppDir, "apps/desktop"), { recursive: true });
3812 if (options.platform !== "win") {
3813 yield* fs.makeDirectory(path.join(stageAppDir, "apps/server"), { recursive: true });
3814 }
3815
3816 yield* Effect.log("[desktop-artifact] Staging release app...");
3817 yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"));
3818 yield* fs.copy(distDirs.desktopResources, stageResourcesDir);
3819 if (options.platform === "linux") {
3820 const extensionDir = path.join(stageAppDir, "apps/desktop/gnome-extension");
3821 yield* fs.makeDirectory(extensionDir, { recursive: true });
3822 for (const file of gnomeCaptureBundle.files) {
3823 yield* fs.copyFile(
3824 path.join(repoRoot, "apps/desktop/gnome-extension", file),
3825 path.join(extensionDir, file),
3826 );
3827 }
3828 }
3829 if (options.platform === "mac" && options.target === "dmg") {
3830 yield* stageDesktopDmgBackground(
3831 stageResourcesDir,
3832 resolveDesktopUpdateChannel(appVersion),
3833 options.verbose,
3834 );
3835 }
3836 // On Windows the server tree ships in the server.asar sidecar instead of
3837 // app.asar (see stageWindowsServerSidecar), so the app stage omits it.
3838 if (options.platform !== "win") {
3839 yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"));
3840 }
3841 yield* stageResourceMonitor({
3842 repoRoot,
3843 stageResourcesDir,
3844 platform: options.platform,
3845 arch: options.arch,
3846 verbose: options.verbose,
3847 });
3848 if (options.platform === "linux") {
3849 for (const backend of ["kde", "hyprland"] as const)
3850 yield* stageLinuxCaptureHelper({
3851 backend,
3852 repoRoot,
3853 stageResourcesDir,
3854 arch: options.arch,
3855 verbose: options.verbose,
3856 });
3857 }
3858 yield* stageBrowserSecret({
3859 repoRoot,
3860 stageResourcesDir,
3861 platform: options.platform,
3862 arch: options.arch,
3863 verbose: options.verbose,
3864 });
3865
3866 yield* assertPlatformBuildResources(
3867 options.platform,
3868 stageResourcesDir,
3869 {
3870 macIconPng: path.join(repoRoot, iconAssets.macIconPng),
3871 linuxIconPng: path.join(repoRoot, iconAssets.linuxIconPng),
3872 windowsIconIco: path.join(repoRoot, iconAssets.windowsIconIco),
3873 },
3874 options.verbose,
3875 );
3876
3877 // electron-builder is filtering out stageResourcesDir directory in the AppImage for production
3878 const stageProdResourcesDir = path.join(stageAppDir, "apps/desktop/prod-resources");
3879 yield* fs.copy(stageResourcesDir, stageProdResourcesDir);
3880
3881 const configuredMacPasskeySigning =
3882 options.platform === "mac" && options.signed
3883 ? yield* Effect.try({
3884 try: () => resolveMacPasskeySigningConfiguration(loadRepoEnv({ repoRoot })),
3885 catch: MacPasskeySigningConfigurationResolutionError.fromCause,
3886 })
3887 : undefined;
3888 const macPasskeySigning = configuredMacPasskeySigning
3889 ? {
3890 ...configuredMacPasskeySigning,
3891 provisioningProfilePath: path.resolve(
3892 repoRoot,
3893 configuredMacPasskeySigning.provisioningProfilePath,
3894 ),
3895 }
3896 : undefined;
3897 const macEntitlementsPath = macPasskeySigning
3898 ? path.join(stageAppDir, "entitlements.mac.plist")
3899 : undefined;
3900 let macWebAuthn: MacWebAuthnEntitlements | undefined;
3901 if (macPasskeySigning && macEntitlementsPath) {
3902 if (!(yield* fs.exists(macPasskeySigning.provisioningProfilePath))) {
3903 return yield* new MacProvisioningProfileNotFoundError({
3904 provisioningProfilePath: macPasskeySigning.provisioningProfilePath,
3905 });
3906 }
3907 macWebAuthn = resolveMacWebAuthnEntitlements(
3908 yield* fs.readFileString(macPasskeySigning.provisioningProfilePath),
3909 macPasskeySigning,
3910 );
3911 yield* Effect.log(
3912 `[desktop-artifact] In-app browser passkeys: Touch ID ${macWebAuthn.touchIdKeychainAccessGroup ? "enabled" : "disabled"}, browser passkeys ${macWebAuthn.browserPasskeys ? "enabled" : "disabled"}.`,
3913 );
3914 yield* fs.writeFileString(
3915 macEntitlementsPath,
3916 renderMacPasskeyEntitlements(macPasskeySigning, macWebAuthn),
3917 );
3918 }
3919
3920 // Windows splits dependencies per process: app.asar carries only the
3921 // desktop main-process externals, while the server bundle's externals live
3922 // in the server.asar sidecar (see stageWindowsServerSidecar). macOS and
3923 // Linux merge both sets into one app.asar.
3924 const stageDependencies =
3925 options.platform === "win"
3926 ? { ...resolvedDesktopRuntimeDependencies }
3927 : resolveMergedStageDependencies({
3928 platform: options.platform,
3929 serverDependencies: resolvedServerDependencies,
3930 desktopDependencies: resolvedDesktopRuntimeDependencies,
3931 arch: options.arch,
3932 fffNodeVersion: serverPackageJson.dependencies["@ff-labs/fff-node"],
3933 });
3934 const stagePatchedDependencies = createStagePatchedDependencies(
3935 workspacePatchedDependencies,
3936 stageDependencies,
3937 );
3938 const windowsServerAsarPath =
3939 options.platform === "win"
3940 ? path.join(stageAppDir, WINDOWS_SERVER_RESOURCE_SOURCE_DIR, WINDOWS_SERVER_ASAR_RESOURCE)
3941 : undefined;
3942 const stagePackageJson: StagePackageJson = {
3943 name: "t3code",
3944 version: appVersion,
3945 buildVersion: appVersion,
3946 t3codeCommitHash: commitHash,
3947 // Read by apps/desktop/src/preview/Passkeys.ts; must match the signed entitlements.
3948 ...(macWebAuthn ? { t3codeWebAuthn: macWebAuthn } : {}),
3949 private: true,
3950 packageManager: rootPackageJson.packageManager,
3951 description:
3952 "The Knife is an open-source desktop app for coding agents. Work with your existing agent subscriptions, review code changes, and run commands in your projects. Connect from desktop, web, or mobile to continue working remotely.",
3953 license: "MIT",
3954 // Required by the .deb control file.
3955 homepage: "https://t3.codes",
3956 author: "T3 Tools",
3957 main: "apps/desktop/dist-electron/boot.cjs",
3958 build: yield* createBuildConfig(
3959 options.platform,
3960 options.target,
3961 appVersion,
3962 options.signed,
3963 options.mockUpdates,
3964 options.mockUpdateServerPort,
3965 macPasskeySigning && macEntitlementsPath
3966 ? {
3967 entitlementsPath: macEntitlementsPath,
3968 provisioningProfilePath: macPasskeySigning.provisioningProfilePath,
3969 }
3970 : undefined,
3971 bundlesWslRuntime({ platform: options.platform, runtimeArchivePath: options.wslRuntime }),
3972 options.arch,
3973 ),
3974 dependencies: stageDependencies,
3975 devDependencies: {
3976 electron: electronVersion,
3977 },
3978 };
3979
3980 const stagePackageJsonString = yield* encodeJsonString(stagePackageJson);
3981 yield* fs.writeFileString(path.join(stageAppDir, "package.json"), `${stagePackageJsonString}\n`);
3982 const stageWorkspaceConfig = createStageWorkspaceConfig({
3983 platform: options.platform,
3984 arch: options.arch,
3985 allowBuilds: workspaceAllowBuilds,
3986 patchedDependencies: stagePatchedDependencies,
3987 overrides: resolvedOverrides,
3988 });
3989 const stageWorkspaceConfigString = yield* encodeStageWorkspaceConfig(stageWorkspaceConfig);
3990 yield* fs.writeFileString(
3991 path.join(stageAppDir, "pnpm-workspace.yaml"),
3992 stageWorkspaceConfigString,
3993 );
3994
3995 if (Object.keys(stagePatchedDependencies).length > 0) {
3996 yield* fs.copy(path.join(repoRoot, "patches"), path.join(stageAppDir, "patches"));
3997 }
3998
3999 yield* Effect.log("[desktop-artifact] Installing staged production dependencies...");
4000 const installCommand = yield* resolveSpawnCommand("vp", [...STAGE_INSTALL_ARGS]);
4001 yield* runCommand(
4002 ChildProcess.make(installCommand.command, installCommand.args, {
4003 cwd: stageAppDir,
4004 shell: installCommand.shell,
4005 }),
4006 { label: "vp install --prod", verbose: options.verbose },
4007 );
4008 yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch);
4009 yield* stageKeyringNativeBinaries(stageAppDir, options.platform, options.arch);
4010
4011 // Only the Windows artifact carries the server sidecar and the WSL runtime;
4012 // other platforms ignore the --wsl-runtime input.
4013 if (options.platform === "win" && windowsServerAsarPath) {
4014 yield* stageWindowsServerSidecar({
4015 stageRoot,
4016 repoRoot,
4017 serverDistDir: distDirs.serverDist,
4018 arch: options.arch,
4019 appVersion,
4020 runtimeExternalDependencies: resolvedServerRuntimeExternalDependencies,
4021 fffNodeVersion: serverPackageJson.dependencies["@ff-labs/fff-node"],
4022 allowBuilds: workspaceAllowBuilds,
4023 patchedDependencies: workspacePatchedDependencies,
4024 overrides: resolvedOverrides,
4025 asarPath: windowsServerAsarPath,
4026 verbose: options.verbose,
4027 });
4028 }
4029 yield* stageCursorSdkPlatformPackages(
4030 path.join(
4031 options.platform === "win" ? path.join(stageRoot, "server") : stageAppDir,
4032 "node_modules",
4033 ),
4034 path.join(stageProdResourcesDir, "cursor-sdk"),
4035 );
4036 if (
4037 options.wslRuntime !== undefined &&
4038 bundlesWslRuntime({ platform: options.platform, runtimeArchivePath: options.wslRuntime })
4039 ) {
4040 yield* stageWslRuntimeArchive({
4041 sourceArchivePath: options.wslRuntime,
4042 archivePath: path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from),
4043 hashPath: path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from),
4044 });
4045 }
4046
4047 {
4048 const runtime = yield* Effect.tryPromise(async () => {
4049 const input = {
4050 platform: options.platform,
4051 arch: options.arch,
4052 signingFiles:
4053 options.platform === "mac"
4054 ? [
4055 path.join(repoRoot, "scripts/sign-macos.ts"),
4056 ...(macEntitlementsPath ? [macEntitlementsPath] : []),
4057 ...(macPasskeySigning ? [macPasskeySigning.provisioningProfilePath] : []),
4058 ]
4059 : [],
4060 ...(options.platform === "win" ? { serverDirectory: path.join(stageRoot, "server") } : {}),
4061 };
4062 return {
4063 sha256: await fingerprintForkRuntime(stageAppDir, input),
4064 shell: await fingerprintForkRuntime(stageAppDir, { ...input, shellOnly: true }),
4065 };
4066 });
4067 yield* fs.writeFileString(
4068 path.join(stageAppDir, FORK_RUNTIME_MANIFEST_FILE),
4069 yield* encodeJsonString({ version: appVersion, ...runtime }),
4070 );
4071 }
4072
4073 // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="")
4074 // as enabled, so copy the host env and scrub empty values instead of relying
4075 // on `extendEnv` merging.
4076 const buildEnv: NodeJS.ProcessEnv = {
4077 ...process.env,
4078 };
4079 buildEnv.npm_config_user_agent = resolvePackageManagerUserAgent(rootPackageJson.packageManager);
4080 for (const [key, value] of Object.entries(buildEnv)) {
4081 if (value === "") {
4082 delete buildEnv[key];
4083 }
4084 }
4085 if (options.platform === "linux") {
4086 // fpm compresses the .deb with the system xz through tar. Threaded mode
4087 // takes seconds on a many-core runner instead of about two minutes.
4088 buildEnv.XZ_DEFAULTS = "-T0";
4089 }
4090 if (!options.signed) {
4091 buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false";
4092 delete buildEnv.CSC_LINK;
4093 delete buildEnv.CSC_KEY_PASSWORD;
4094 delete buildEnv.APPLE_API_KEY;
4095 delete buildEnv.APPLE_API_KEY_ID;
4096 delete buildEnv.APPLE_API_ISSUER;
4097 }
4098
4099 if (hostPlatform === "win32") {
4100 const python = yield* resolvePythonForNodeGyp();
4101 if (python) {
4102 buildEnv.PYTHON = python;
4103 buildEnv.npm_config_python = python;
4104 }
4105 buildEnv.npm_config_msvs_version = buildEnv.npm_config_msvs_version ?? "2022";
4106 buildEnv.GYP_MSVS_VERSION = buildEnv.GYP_MSVS_VERSION ?? "2022";
4107 }
4108 if (options.verbose) {
4109 const debugNamespaces = [
4110 "electron-builder",
4111 "electron-builder:*",
4112 ...(options.platform === "mac" ? ["electron-osx-sign*", "electron-notarize*"] : []),
4113 ];
4114 buildEnv.DEBUG = [buildEnv.DEBUG, ...debugNamespaces].filter(Boolean).join(",");
4115 }
4116
4117 yield* Effect.log(
4118 `[desktop-artifact] Building ${options.platform}/${options.target} (arch=${options.arch}, version=${appVersion})...`,
4119 );
4120 const builderArgs = [
4121 "exec",
4122 "--filter",
4123 "@t3tools/desktop",
4124 "--",
4125 "electron-builder",
4126 "--projectDir",
4127 stageAppDir,
4128 platformConfig.cliFlag,
4129 `--${options.arch}`,
4130 "--publish",
4131 "never",
4132 ];
4133 const builderCommand = yield* resolveSpawnCommand("vp", builderArgs, { env: buildEnv });
4134 yield* runCommand(
4135 ChildProcess.make(builderCommand.command, builderCommand.args, {
4136 cwd: repoRoot,
4137 env: buildEnv,
4138 shell: builderCommand.shell,
4139 }),
4140 {
4141 label: `vp exec --filter @t3tools/desktop -- electron-builder --projectDir ${stageAppDir} ${platformConfig.cliFlag} --${options.arch} --publish never`,
4142 verbose: options.verbose,
4143 },
4144 );
4145
4146 const stageDistDir = path.join(stageAppDir, "dist");
4147 if (!(yield* fs.exists(stageDistDir))) {
4148 return yield* new DesktopBuildDistDirectoryMissingError({
4149 distPath: stageDistDir,
4150 platform: options.platform,
4151 arch: options.arch,
4152 });
4153 }
4154
4155 // Prove the packaged bundle is self-contained by loading it the way the WSL
4156 // backend does, rather than by reasoning about the emitted source.
4157 //
4158 // Static analysis kept getting this wrong here. Scanning for bare imports
4159 // matched specifiers inside effect's JSDoc examples and inside ajv's runtime
4160 // codegen template, and asserting that one sentinel package was inlined
4161 // missed a build that inlined `effect` while leaving `yaml` external. Node's
4162 // resolver has no such ambiguity: it either finds every import or it does not.
4163 //
4164 // Only Windows unpacks anything; macOS and Linux keep the whole tree inside
4165 // the app asar. Windows validates and executes the separately packed server
4166 // sidecar after electron-builder copies it into the final payload.
4167 if (options.platform === "win") {
4168 yield* validateWindowsPackagedPayload({
4169 stageDistDir,
4170 appExecutableName: `${resolveDesktopProductName(appVersion)}.exe`,
4171 targetArch: options.arch,
4172 appVersion,
4173 expectWslRuntime: bundlesWslRuntime({
4174 platform: options.platform,
4175 runtimeArchivePath: options.wslRuntime,
4176 }),
4177 verbose: options.verbose,
4178 });
4179 }
4180
4181 const stageEntries = yield* fs.readDirectory(stageDistDir);
4182 yield* fs.makeDirectory(options.outputDir, { recursive: true });
4183
4184 const copiedArtifacts: string[] = [];
4185 if (
4186 options.platform !== "mac" &&
4187 (yield* Config.Boolean("T3CODE_DESKTOP_FORK_RESOURCES").pipe(Config.withDefault(false)))
4188 ) {
4189 copiedArtifacts.push(
4190 yield* exportForkResources({
4191 stageDistDir,
4192 outputDir: options.outputDir,
4193 platform: options.platform,
4194 arch: options.arch,
4195 serverRuntime: yield* Effect.tryPromise(() => fingerprintForkServerRuntime(repoRoot)),
4196 }),
4197 );
4198 }
4199 for (const entry of stageEntries) {
4200 const from = path.join(stageDistDir, entry);
4201 const stat = yield* fs.stat(from).pipe(Effect.orElseSucceed(() => null));
4202 if (!stat || stat.type !== "File") continue;
4203
4204 const to = path.join(options.outputDir, entry);
4205 yield* fs.copyFile(from, to);
4206 copiedArtifacts.push(to);
4207 }
4208
4209 if (copiedArtifacts.length === 0) {
4210 return yield* new DesktopBuildNoArtifactsProducedError({
4211 distPath: stageDistDir,
4212 platform: options.platform,
4213 arch: options.arch,
4214 });
4215 }
4216
4217 yield* Effect.log("[desktop-artifact] Done. Artifacts:").pipe(
4218 Effect.annotateLogs({ artifacts: copiedArtifacts }),
4219 );
4220});
4221
4222const buildDesktopArtifactCli = Command.make("build-desktop-artifact", {
4223 platform: Flag.Literals("platform", BuildPlatform.literals).pipe(
4224 Flag.withDescription("Build platform (env: T3CODE_DESKTOP_PLATFORM)."),
4225 Flag.optional,
4226 ),
4227 target: Flag.String("target").pipe(
4228 Flag.withDescription(
4229 "Artifact target, for example dmg/AppImage/nsis (env: T3CODE_DESKTOP_TARGET).",
4230 ),
4231 Flag.optional,
4232 ),
4233 arch: Flag.Literals("arch", BuildArch.literals).pipe(
4234 Flag.withDescription("Build arch, for example arm64/x64/universal (env: T3CODE_DESKTOP_ARCH)."),
4235 Flag.optional,
4236 ),
4237 buildVersion: Flag.String("build-version").pipe(
4238 Flag.withDescription("Artifact version metadata (env: T3CODE_DESKTOP_VERSION)."),
4239 Flag.optional,
4240 ),
4241 outputDir: Flag.String("output-dir").pipe(
4242 Flag.withDescription("Output directory for artifacts (env: T3CODE_DESKTOP_OUTPUT_DIR)."),
4243 Flag.optional,
4244 ),
4245 skipBuild: Flag.Boolean("skip-build").pipe(
4246 Flag.withDescription(
4247 "Skip `vp run build:desktop` and use existing dist artifacts (env: T3CODE_DESKTOP_SKIP_BUILD).",
4248 ),
4249 Flag.optional,
4250 ),
4251 keepStage: Flag.Boolean("keep-stage").pipe(
4252 Flag.withDescription("Keep temporary staging files (env: T3CODE_DESKTOP_KEEP_STAGE)."),
4253 Flag.optional,
4254 ),
4255 signed: Flag.Boolean("signed").pipe(
4256 Flag.withDescription(
4257 "Enable signing/notarization discovery; Windows uses Azure Trusted Signing (env: T3CODE_DESKTOP_SIGNED).",
4258 ),
4259 Flag.optional,
4260 ),
4261 verbose: Flag.Boolean("verbose").pipe(
4262 Flag.withDescription("Stream subprocess stdout (env: T3CODE_DESKTOP_VERBOSE)."),
4263 Flag.optional,
4264 ),
4265 mockUpdates: Flag.Boolean("mock-updates").pipe(
4266 Flag.withDescription("Enable mock updates (env: T3CODE_DESKTOP_MOCK_UPDATES)."),
4267 Flag.optional,
4268 ),
4269 mockUpdateServerPort: Flag.Int("mock-update-server-port").pipe(
4270 Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
4271 Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."),
4272 Flag.optional,
4273 ),
4274 wslRuntime: Flag.String("wsl-runtime").pipe(
4275 Flag.withDescription(
4276 "Path to the Linux CLI release archive (t3-<version>-linux-x64.tar.gz) to embed as the WSL runtime of a Windows build (env: T3CODE_DESKTOP_WSL_RUNTIME).",
4277 ),
4278 Flag.optional,
4279 ),
4280}).pipe(
4281 Command.withDescription("Build a desktop artifact for The Knife."),
4282 Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)),
4283);
4284
4285const cliRuntimeLayer = Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer);
4286
4287if (import.meta.main) {
4288 Command.run(buildDesktopArtifactCli, { version: "0.0.0" }).pipe(
4289 Effect.scoped,
4290 Effect.provide(cliRuntimeLayer),
4291 NodeRuntime.runMain,
4292 );
4293}