diff --git a/patches/@ubjs__node@0.31.0-3.patch b/patches/@ubjs__node@0.31.0-3.patch new file mode 100644 index 000000000000..c6f782175844 --- /dev/null +++ b/patches/@ubjs__node@0.31.0-3.patch @@ -0,0 +1,22 @@ +diff --git a/typescript/dist/resolve-lib.js b/typescript/dist/resolve-lib.js +index e8837bb2337c42eaffe15942923b8af39fca2b1a..b0806b7ebb9b4995ee6a318b66875a7ee9471219 100644 +--- a/typescript/dist/resolve-lib.js ++++ b/typescript/dist/resolve-lib.js +@@ -166,5 +166,16 @@ function resolveNpmPackage(crateName, callerUrl, npmPackageBase, tripleStyle) { + attempted: [binaryPath], + }); + } +- return binaryPath; ++ return resolveUnpackedAsarPath(binaryPath); ++} ++// dlopen cannot read inside an Electron asar archive. Packagers move native ++// libraries to the archive's `.asar.unpacked` sibling, so load them from there. ++function resolveUnpackedAsarPath(binaryPath) { ++ const segments = binaryPath.split(node_path_1.sep); ++ const asarIndex = segments.findLastIndex((segment) => segment.endsWith(".asar")); ++ if (asarIndex === -1) ++ return binaryPath; ++ segments[asarIndex] = `${segments[asarIndex]}.unpacked`; ++ const unpackedPath = segments.join(node_path_1.sep); ++ return (0, node_fs_1.existsSync)(unpackedPath) ? unpackedPath : binaryPath; + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9fc829567f3..71a11459407d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,6 +175,7 @@ patchedDependencies: '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba '@react-navigation/native-stack@7.21.0': caae6552051bce91a85f567f3a08f9c8d25ce4c53f65a9391a67f016bb1d798e + '@ubjs/node@0.31.0-3': e97d224c08570d77cb1dbac63fbcde2cc62ec45e6466b508272bb174a6ad75d0 alchemy@2.0.0-beta.80: bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4 expo-audio@58.0.9: 2c2f8c73a23d48804c8b537b8cd73c25eb331aa47d82eaa481c6766cb81e5add @@ -17270,7 +17271,7 @@ snapshots: '@trycua/cua-driver@0.34.1': dependencies: '@ubjs/core': 0.31.0-3 - '@ubjs/node': 0.31.0-3 + '@ubjs/node': 0.31.0-3(patch_hash=e97d224c08570d77cb1dbac63fbcde2cc62ec45e6466b508272bb174a6ad75d0) optionalDependencies: '@trycua/cua-driver-darwin-arm64': 0.34.1 '@trycua/cua-driver-darwin-x64': 0.34.1 @@ -17711,7 +17712,7 @@ snapshots: '@ubjs/node-win32-x64-msvc@0.31.0-3': optional: true - '@ubjs/node@0.31.0-3': + '@ubjs/node@0.31.0-3(patch_hash=e97d224c08570d77cb1dbac63fbcde2cc62ec45e6466b508272bb174a6ad75d0)': optionalDependencies: '@ubjs/node-darwin-arm64': 0.31.0-3 '@ubjs/node-darwin-x64': 0.31.0-3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1cd123f63be9..5b02f99791e4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -315,6 +315,7 @@ packageExtensions: vite-plus: "catalog:" patchedDependencies: + "@ubjs/node@0.31.0-3": patches/@ubjs__node@0.31.0-3.patch alchemy@2.0.0-beta.80: patches/alchemy@2.0.0-beta.80.patch expo-audio@58.0.9: patches/expo-audio@58.0.9.patch "@clerk/expo-native-components@0.2.0": patches/@clerk__expo-native-components@0.2.0.patch diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 8a0dbc4faa8b..5dd220ed0e51 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -3,7 +3,9 @@ import * as NodeCrypto from "node:crypto"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { extractFile } from "@electron/asar"; +import { createPackageWithOptions, extractFile, listPackage, statFile } from "@electron/asar"; +import { vi } from "vite-plus/test"; +import * as CuaRelease from "@t3tools/shared/cuaDriverRelease"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as FileSystem from "effect/FileSystem"; @@ -11,11 +13,13 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { + stageCuaDriverExecutable, BundleNotSelfContainedError, BuildCommandFailedError, parseWslRuntimeArchiveMembers, @@ -42,6 +46,7 @@ import { MacPasskeySigningConfigurationResolutionError, MissingMacPasskeyProvisioningProfileError, packWindowsServerAsar, + stageWindowsServerSidecar, preflightLinuxDesktopBuild, preflightMacDesktopBuild, preflightWindowsDesktopBuild, @@ -176,6 +181,89 @@ function iconResizeSpawnerLayer( ); } +// USTAR/gzip and DEFLATE/ZIP archives; each file contains its own name. +const cuaArchives = { + mac: "H4sIAAAAAAACA+3V0QqCMBiG4V1KN6DMufJyxLZBUipMF3T3LSMiz1ti73PyzyF48O1zJjSZ9e3VefE1MjpoPc9oOaUs1Hs971eVUmInRQJhnBofPyn+k0mQP9advwl+HHw2nVznftJ/JT/7XxRSl/R/K/ljvS7tMR6B+nkF1KM95/YW9xL3v1r0X+n4Ov3fSP5Y9f//FX4/WFf70E9t5/LHQ7L+l3rR/3Iv6f9m8gcAAAAAAAAAAEA6d/qw0rcAKAAA", + linux: + "H4sIAAAAAAACA+3ZPVKDQACGYY6SC4Twk4VWZ0xp4wUym7AzoLA4uyB6e1HHImthIwTW92mSVBQvH4RkkG+11MW2VPWzMrtgCtEoF+LzdeS+/nwfR1kWBxsRzKC3nTTj4YP/abjsP1TaqHNnb8693F2vf5rGEf3nMHbeFqZ6UWa6Y/zaP06ccyHPExFsIvp70R/L7n/ujW3NtitVo66y/8S9/sfRPmP/vvTHctXVaTwFjl+3gKMtnkLbzr//vbv/PEvYvy/9sejr/3d83RbqaHrdVY0KPz7Mtv/U3X8qopz9+9Ifq+gvT1VYLub7P/v3pj+Wy/n9t9LjHOo6tOWc+09jZ/9JlkXs35f+WE3/i/9/GtXJQnYyfLStnnL/wt2/4Pnfo/5YZ3/12iltq1aPJ8Ck93/hfv8X3P896o/V9H843N7dH8KmmPX5P3X3n8Tpnv370h8AAAAAAAAAAAAAAAAAAAB/5x055e7oAFAAAA==", + win: "UEsDBBQAAAAIAAAAIVxkdYIxEAAAAA4AAAAOAAAAY3VhLWRyaXZlci5leGVLLk3UTSnKLEst0kutSAUAUEsDBBQAAAAIAAAAIVw8tMWfFAAAABIAAAASAAAAY3VhLWRyaXZlci11aWEuZXhlSy5N1E0pyixLLdItzUzUS61IBQBQSwMEFAAAAAgAAAAhXJdWRtQWAAAAFAAAABQAAABjdWEtY3Vyc29yLXRoZW1lLmV4ZUsuTdRNLi0qzi/SLclIzU3VS61IBQBQSwMEFAAAAAgAAAAhXG9yJBwUAAAAEgAAABIAAABjdWFfZHJpdmVyX3Nkay5kbGxLLk2MTynKLEstii9OydZLyckBAFBLAwQUAAAACAAAACFcx8Y50BsAAAAcAAAAHAAAAGN1YV9kcml2ZXJfbm9kZV9ydW50aW1lLm5vZGVLLk2MTynKLEstis/LT0mNLyrNK8nMTdUDcQBQSwECFAMUAAAACAAAACFcZHWCMRAAAAAOAAAADgAAAAAAAAAAAAAA7YEAAAAAY3VhLWRyaXZlci5leGVQSwECFAMUAAAACAAAACFcPLTFnxQAAAASAAAAEgAAAAAAAAAAAAAA7YE8AAAAY3VhLWRyaXZlci11aWEuZXhlUEsBAhQDFAAAAAgAAAAhXJdWRtQWAAAAFAAAABQAAAAAAAAAAAAAAO2BgAAAAGN1YS1jdXJzb3ItdGhlbWUuZXhlUEsBAhQDFAAAAAgAAAAhXG9yJBwUAAAAEgAAABIAAAAAAAAAAAAAAO2ByAAAAGN1YV9kcml2ZXJfc2RrLmRsbFBLAQIUAxQAAAAIAAAAIVzHxjnQGwAAABwAAAAcAAAAAAAAAAAAAADtgQwBAABjdWFfZHJpdmVyX25vZGVfcnVudGltZS5ub2RlUEsFBgAAAAAFAAUASAEAAGEBAAAAAA==", +}; + +const makeCuaStageFixture = Effect.fn("test.makeCuaStageFixture")(function* ( + platform: "mac" | "linux" | "win", +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cua-package-" }); + const resolveRelease = CuaRelease.cuaDriverRelease; + const pinned = resolveRelease( + platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux", + "x64", + ); + assert.isNotNull(pinned); + const archive = Buffer.from(cuaArchives[platform], "base64"); + const release = { + ...pinned!, + bytes: archive.byteLength, + sha256: NodeCrypto.createHash("sha256").update(archive).digest("hex"), + }; + yield* Effect.acquireRelease( + Effect.sync(() => + vi.spyOn(CuaRelease, "cuaDriverRelease").mockImplementation((target, arch) => { + const asset = resolveRelease(target, arch); + assert.isNotNull(asset); + assert.equal(asset!.archiveName, release.archiveName); + return release; + }), + ), + (spy) => Effect.sync(() => spy.mockRestore()), + ); + const cacheDir = path.join(root, "node_modules/.cache/t3code/cua-driver"); + const archivePath = path.join(cacheDir, release.archiveName); + yield* fs.makeDirectory(cacheDir, { recursive: true }); + let download = archive; + let requests = 0; + const downloadSpawner = ChildProcessSpawner.make((command) => { + if (command._tag !== "StandardCommand" || command.command !== "curl") { + return spawner.spawn(command); + } + const output = command.args[command.args.indexOf("--output") + 1]; + assert.isString(output); + requests++; + return fs.writeFile(output!, download).pipe(Effect.as(mockProcess(0))); + }); + const stage = (name: string) => { + const stageRoot = path.join(root, name); + const stageResourcesDir = path.join(stageRoot, "resources"); + return stageCuaDriverExecutable({ + platform, + arch: platform === "mac" ? "universal" : "x64", + repoRoot: root, + stageRoot, + stageResourcesDir, + verbose: false, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, downloadSpawner), + Effect.as(stageResourcesDir), + ); + }; + return { + fs, + path, + root, + archive, + archivePath, + cacheDir, + stage, + requests: () => requests, + respondWith: (bytes: Buffer, expectedBytes = archive.byteLength) => { + download = Buffer.from(bytes); + release.bytes = expectedBytes; + }, + }; +}); + const WINDOWS_PAYLOAD_FIXTURE_VERSION = "1.2.3"; const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(function* (input: { @@ -262,6 +350,206 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu }); it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { + it.effect( + "Cua release cache rejects bad bytes and hashes, redownloads corruption, and reuses verified bytes", + () => + Effect.gen(function* () { + const h = yield* makeCuaStageFixture("linux"); + const wrongHash = Buffer.from(h.archive); + wrongHash.writeUInt8(wrongHash.readUInt8(wrongHash.length - 1) ^ 1, wrongHash.length - 1); + for (const [index, [bytes, expectedBytes]] of ( + [ + [h.archive, h.archive.length + 1], + [h.archive, h.archive.length - 1], + [wrongHash, h.archive.length], + ] as const + ).entries()) { + h.respondWith(bytes, expectedBytes); + yield* Effect.flip(h.stage(`rejected-${index}`)); + assert.deepStrictEqual(yield* h.fs.readDirectory(h.cacheDir), []); + assert.isFalse( + yield* h.fs.exists(h.path.join(h.root, `rejected-${index}/resources/cua-driver`)), + ); + } + h.respondWith(h.archive); + yield* h.fs.writeFile(h.archivePath, wrongHash); + const resources = yield* h.stage("repaired"); + assert.equal(h.requests(), 4); + assert.deepStrictEqual(Buffer.from(yield* h.fs.readFile(h.archivePath)), h.archive); + assert.equal( + yield* h.fs.readFileString(h.path.join(resources, "cua-driver/cua-driver")), + "cua-driver", + ); + yield* h.stage("cached"); + assert.equal(h.requests(), 4); + assert.deepStrictEqual(yield* h.fs.readDirectory(h.cacheDir), [ + h.path.basename(h.archivePath), + ]); + }).pipe(Effect.scoped), + ); + + it.effect.each(["mac", "linux", "win"] as const)( + "stages the Cua %s compressed release at the desktop host path", + (platform) => + Effect.gen(function* () { + const h = yield* makeCuaStageFixture(platform); + yield* h.fs.writeFile(h.archivePath, h.archive); + const resources = yield* h.stage("release"); + const destination = h.path.join(resources, "cua-driver"); + assert.equal(h.requests(), 0); + if (platform === "mac") { + assert.equal(yield* h.fs.readFileString(destination), "cua-driver"); + assert.deepStrictEqual(yield* h.fs.readDirectory(resources), ["cua-driver"]); + } else { + const companions = + platform === "win" + ? ["cua-driver.exe", "cua-driver-uia.exe", "cua-cursor-theme.exe"] + : [ + "cua-driver", + "cua-cursor-theme", + "wayland-helper/README.md", + "wayland-helper/install.sh", + "wayland-helper/winrects@cua/metadata.json", + "wayland-helper/winrects@cua/extension.js", + ]; + assert.deepStrictEqual( + (yield* h.fs.readDirectory(destination)).sort(), + platform === "win" + ? [...companions].sort() + : ["cua-cursor-theme", "cua-driver", "wayland-helper"], + ); + for (const member of companions) { + assert.equal(yield* h.fs.readFileString(h.path.join(destination, member)), member); + } + } + if ((yield* HostProcess.Platform) !== "win32" && platform !== "win") { + const executables = + platform === "mac" + ? [destination] + : [ + h.path.join(destination, "cua-driver"), + h.path.join(destination, "cua-cursor-theme"), + ]; + for (const executable of executables) { + assert.equal((yield* h.fs.stat(executable)).mode & 0o777, 0o755); + } + } + }).pipe(Effect.scoped), + ); + + it.effect("Cua SDK native libraries unpack beside the ASAR while JavaScript stays archived", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cua-asar-" }); + for (const [platform, nativePackage, library] of [ + ["mac", "darwin-arm64", "libcua_driver_sdk.dylib"], + ["linux", "linux-x64-gnu", "libcua_driver_sdk.so"], + ["win", "win32-x64-msvc", "cua_driver_sdk.dll"], + ] as const) { + const sourceDir = path.join(root, platform); + const natives = [ + `node_modules/@trycua/cua-driver-${nativePackage}/${library}`, + `node_modules/@trycua/cua-driver-${nativePackage}/cua_driver_node_runtime.node`, + `node_modules/@ubjs/node-${nativePackage}/uniffi-runtime-napi.${nativePackage}.node`, + ]; + const javascript = [ + "node_modules/@trycua/cua-driver/dist/embedded.js", + "node_modules/@ubjs/node/typescript/dist/resolve-lib.js", + ]; + for (const file of [...natives, ...javascript]) { + yield* fs.makeDirectory(path.dirname(path.join(sourceDir, file)), { recursive: true }); + yield* fs.writeFileString(path.join(sourceDir, file), file); + } + const config = yield* createBuildConfig( + platform, + "dir", + "1.2.3", + false, + false, + undefined, + undefined, + ); + const globs = config.asarUnpack as string[]; + assert.isArray(globs, `${platform} needs explicit native unpack rules`); + const asarPath = path.join(root, `${platform}.asar`); + yield* Effect.promise(() => + createPackageWithOptions(sourceDir, asarPath, { + unpack: globs.length === 1 ? globs[0]! : `{${globs.join(",")}}`, + }), + ); + for (const file of natives) { + assert.equal(yield* fs.readFileString(path.join(`${asarPath}.unpacked`, file)), file); + assert.isTrue(statFile(asarPath, path.normalize(file)).unpacked); + } + for (const file of javascript) { + assert.equal(extractFile(asarPath, path.normalize(file)).toString(), file); + assert.isNotTrue(statFile(asarPath, path.normalize(file)).unpacked); + assert.isFalse(yield* fs.exists(path.join(`${asarPath}.unpacked`, file))); + } + } + }).pipe(Effect.scoped), + ); + + it.effect("Cua Windows SDK and driver fit the packaged 80 loose-file budget", () => + Effect.gen(function* () { + const h = yield* makeCuaStageFixture("win"); + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: false }); + const resources = h.path.join(fixture.packagedAppDir, "resources"); + const natives = [ + "node_modules/@trycua/cua-driver-win32-x64-msvc/cua_driver_sdk.dll", + "node_modules/@trycua/cua-driver-win32-x64-msvc/cua_driver_node_runtime.node", + "node_modules/@ubjs/node-win32-x64-msvc/uniffi-runtime-napi.win32-x64-msvc.node", + ]; + const javascript = Array.from( + { length: 81 }, + (_, index) => `node_modules/@trycua/cua-driver/dist/native/module-${index}.js`, + ); + for (const file of [...natives, ...javascript]) { + const target = h.path.join(fixture.sourceDir, file); + yield* h.fs.makeDirectory(h.path.dirname(target), { recursive: true }); + yield* h.fs.writeFileString( + target, + file.endsWith(".js") ? "export {};" : "MZ native fixture", + ); + } + const asarPath = h.path.join(resources, WINDOWS_SERVER_ASAR_RESOURCE); + yield* packWindowsServerAsar({ sourceDir: fixture.sourceDir, asarPath, arch: "x64" }); + const config = yield* createBuildConfig( + "win", + "dir", + "1.2.3", + false, + false, + undefined, + undefined, + ); + assert.deepStrictEqual(config.asar, { smartUnpack: false }); + const globs = config.asarUnpack as string[]; + yield* Effect.promise(() => + createPackageWithOptions(fixture.sourceDir, h.path.join(resources, "app.asar"), { + unpack: globs.length === 1 ? globs[0]! : `{${globs.join(",")}}`, + }), + ); + yield* h.fs.writeFile(h.archivePath, h.archive); + const staged = yield* h.stage("driver"); + yield* h.fs.copy(h.path.join(staged, "cua-driver"), h.path.join(resources, "cua-driver")); + const result = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, + }).pipe(Effect.provideService(HostProcess.Platform, "linux")); + assert.equal(WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT, 80); + assert.isAtMost(result.fileCount, 80); + assert.includeMembers(result.unpackedFiles, natives); + for (const file of javascript) { + assert.equal(extractFile(asarPath, h.path.normalize(file)).toString(), "export {};"); + assert.isNotTrue(statFile(asarPath, h.path.normalize(file)).unpacked); + } + }).pipe(Effect.scoped), + ); + it("resolves the dedicated nightly updater channel from nightly versions", () => { assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly"); assert.equal(resolveDesktopUpdateChannel("0.0.17"), "latest"); @@ -409,6 +697,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { "@napi-rs/keyring": "^1.3.0", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", + "@trycua/cua-driver": "catalog:", "dbus-next": "0.10.2", effect: "catalog:", electron: "41.5.0", @@ -420,6 +709,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { "@clerk/electron": "0.0.37", "@clerk/electron-passkeys": "0.0.3", "@effect/platform-node": "4.0.0-beta.59", + "@trycua/cua-driver": "0.34.1", effect: "4.0.0-beta.59", }, ), @@ -427,6 +717,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { "@clerk/electron-passkeys": "0.0.3", "@crowecawcaw/xa11y": "0.13.0", "@napi-rs/keyring": "^1.3.0", + "@trycua/cua-driver": "0.34.1", "ffi-rs": "1.3.2", "playwright-core": "1.60.0", }, @@ -612,6 +903,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { "!apps/desktop/prod-resources/wsl-runtime.tar.gz.sha256", "!apps/desktop/gnome-extension", "!apps/desktop/gnome-extension/**/*", + "!apps/desktop/prod-resources/cua-driver", + "!apps/desktop/prod-resources/cua-driver/**/*", + "!apps/desktop/resources/cua-driver", + "!apps/desktop/resources/cua-driver/**/*", ]); assert.equal(WINDOWS_SERVER_RESOURCE_SOURCE_DIR, "apps/desktop/prod-resources/windows-server"); assert.deepStrictEqual(WINDOWS_SERVER_EXTRA_RESOURCES, [ @@ -666,8 +961,6 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.notProperty(mac, "asar"); assert.notProperty(linux, "asar"); - assert.notProperty(mac, "asarUnpack"); - assert.notProperty(linux, "asarUnpack"); assert.deepStrictEqual(win.asar, { smartUnpack: false }); assert.deepStrictEqual(win.asarUnpack, [WINDOWS_NATIVE_ASAR_UNPACK_GLOB]); assert.deepStrictEqual(winWithoutWslRuntime.asar, win.asar); @@ -901,7 +1194,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ]); }); - it.effect("unpacks target Windows natives while excluding the other architecture", () => + it.effect("stages Windows sidecar externals and unpacks target natives", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -919,15 +1212,60 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { "node_modules/node-pty/prebuilds/linux-x64/pty.node", ]; - for (const nativeFile of nativeFiles) { - const nativePath = path.join(sourceDir, nativeFile); - yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); - yield* fs.writeFileString(nativePath, nativeFile.includes("linux") ? "\x7fELF" : "MZ"); - } - + const decodeDependencies = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ dependencies: Schema.Record(Schema.String, Schema.String) }), + ), + ); + const installSpawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const { dependencies } = yield* decodeDependencies( + yield* fs.readFileString(path.join(sourceDir, "package.json")), + ).pipe(Effect.orDie); + const installedFiles = Object.keys(dependencies).map( + (name) => `node_modules/${name}/package.json`, + ); + if (Object.hasOwn(dependencies, "node-pty")) installedFiles.push(...nativeFiles); + if (Object.hasOwn(dependencies, "@trycua/cua-driver")) { + installedFiles.push( + "node_modules/@trycua/cua-driver-win32-x64-msvc/cua_driver_sdk.dll", + ); + } + for (const file of installedFiles) { + const filePath = path.join(sourceDir, file); + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, file.includes("linux") ? "\x7fELF" : "MZ"); + } + return mockProcess(0); + }), + ); + const serverDistDir = path.join(tempDir, "dist"); + yield* fs.makeDirectory(serverDistDir); + yield* fs.writeFileString(path.join(serverDistDir, "bin.mjs"), "console.log('server');"); const asarPath = path.join(tempDir, "server.asar"); - yield* packWindowsServerAsar({ sourceDir, asarPath, arch: "x64" }); + yield* stageWindowsServerSidecar({ + stageRoot: tempDir, + repoRoot: tempDir, + serverDistDir, + arch: "x64", + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, + runtimeExternalDependencies: { "node-pty": "1.1.0", "@trycua/cua-driver": "0.34.1" }, + fffNodeVersion: "0.9.4", + allowBuilds: {}, + patchedDependencies: {}, + overrides: {}, + asarPath, + verbose: false, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, installSpawner)); const unpackedRoot = `${asarPath}.unpacked`; + assert.deepStrictEqual( + (yield* decodeDependencies(extractFile(asarPath, "package.json").toString())) + .dependencies, + { "node-pty": "1.1.0", "@ff-labs/fff-bin-win32-x64": "0.9.4" }, + ); + assert.isFalse( + listPackage(asarPath, { isPack: false }).some((file) => file.includes("@trycua")), + ); assert.isTrue( yield* fs.exists( @@ -1096,13 +1434,13 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { const fails = childProcess.command === "rustc" || childProcess.command === "powershell.exe" || + childProcess.command === "tar.exe" || childProcess.command === pythonPath; return Effect.succeed(mockProcess(fails ? 1 : 0)); }), ); const error = yield* preflightWindowsDesktopBuild({ arch: "x64", - bundlesWslRuntime: true, }).pipe( Effect.provide( Layer.merge( @@ -1116,7 +1454,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ); assert.instanceOf(error, WindowsDesktopBuildPrerequisitesMissingError); - assert.deepStrictEqual(error.missing, ["rust", "python", "msvc"]); + assert.deepStrictEqual(error.missing, ["rust", "python", "msvc", "tar"]); assert.equal(error.rustTarget, "x86_64-pc-windows-msvc"); assert.include(error.message, "Visual Studio Build Tools components"); }), @@ -1143,7 +1481,6 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { yield* preflightWindowsDesktopBuild({ arch: "x64", - bundlesWslRuntime: true, }).pipe( Effect.provide( Layer.merge( @@ -1191,7 +1528,6 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ); const error = yield* preflightWindowsDesktopBuild({ arch: "x64", - bundlesWslRuntime: false, }).pipe( Effect.provide( Layer.merge( @@ -2169,6 +2505,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { it("stages the resource monitor as an external executable resource", () => { assert.deepStrictEqual(DESKTOP_EXTRA_RESOURCES, [ + { from: "apps/desktop/prod-resources/cua-driver", to: "cua-driver" }, { from: "apps/desktop/prod-resources/cursor-sdk", to: "node_modules/@cursor", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index e58e15aeb3ee..f36ce85c0d86 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -19,6 +19,7 @@ import { fromYaml } from "@t3tools/shared/schemaYaml"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { cuaDriverRelease } from "@t3tools/shared/cuaDriverRelease"; import rootPackageJson from "../package.json" with { type: "json" }; import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; import gnomeCaptureBundle from "../apps/desktop/gnome-extension/bundle.json" with { type: "json" }; @@ -416,7 +417,10 @@ const WINDOWS_DESKTOP_BUILD_PREREQUISITES = [ id: "msvc", description: "Visual Studio Build Tools with C++, Windows SDK, and Spectre libraries", }, - { id: "tar", description: "tar to inspect the bundled WSL runtime archive" }, + { + id: "tar", + description: "tar.exe to extract Cua Driver and inspect the bundled WSL runtime archive", + }, ] as const; export class WindowsDesktopBuildPrerequisitesMissingError extends Schema.TaggedError()( @@ -988,6 +992,10 @@ export const DESKTOP_FILE_EXCLUSIONS = [ "!apps/desktop/prod-resources/wsl-runtime.tar.gz.sha256", "!apps/desktop/gnome-extension", "!apps/desktop/gnome-extension/**/*", + "!apps/desktop/prod-resources/cua-driver", + "!apps/desktop/prod-resources/cua-driver/**/*", + "!apps/desktop/resources/cua-driver", + "!apps/desktop/resources/cua-driver/**/*", ] as const; // Windows terminal helpers cannot run on macOS and slow signing and notarization. export const MAC_FILE_EXCLUSIONS = [ @@ -1086,6 +1094,7 @@ export const WSL_RUNTIME_EXTRA_RESOURCES = [ WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE, ] as const; export const DESKTOP_EXTRA_RESOURCES = [ + { from: "apps/desktop/prod-resources/cua-driver", to: "cua-driver" }, { from: "apps/desktop/prod-resources/cursor-sdk", to: "node_modules/@cursor", @@ -1617,9 +1626,14 @@ export function createStagePatchedDependencies( dependencies: Record, ): Record { return Object.fromEntries( - Object.entries(patchedDependencies).filter(([patchKey]) => - Object.hasOwn(dependencies, getPatchedDependencyPackageName(patchKey)), - ), + Object.entries(patchedDependencies).filter(([patchKey]) => { + const name = getPatchedDependencyPackageName(patchKey); + // The Cua SDK's transitive loader needs the ASAR path fix too. + return ( + Object.hasOwn(dependencies, name) || + (name === "@ubjs/node" && Object.hasOwn(dependencies, "@trycua/cua-driver")) + ); + }), ); } @@ -1915,7 +1929,7 @@ function windowsVswherePrerequisiteScript(arch: typeof BuildArch.Type): string { } export const preflightWindowsDesktopBuild = Effect.fn("preflightWindowsDesktopBuild")( - function* (input: { readonly arch: typeof BuildArch.Type; readonly bundlesWslRuntime: boolean }) { + function* (input: { readonly arch: typeof BuildArch.Type }) { const rustTarget = resolveResourceMonitorRustTargets("win", input.arch)[0]!; const reuseResourceMonitor = yield* Config.Boolean( "T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR", @@ -1942,9 +1956,7 @@ export const preflightWindowsDesktopBuild = Effect.fn("preflightWindowsDesktopBu ]), "Visual Studio Build Tools", ), - tar: input.bundlesWslRuntime - ? desktopBuildProbeSucceeds(ChildProcess.make("tar.exe", ["--version"]), "tar") - : Effect.succeed(true), + tar: desktopBuildProbeSucceeds(ChildProcess.make("tar.exe", ["--version"]), "tar"), }, { concurrency: "unbounded" }, ); @@ -2373,6 +2385,134 @@ export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (inp ); }); +class CuaDriverArtifactError extends Schema.TaggedError()( + "CuaDriverArtifactError", + { message: Schema.String }, +) {} + +export const stageCuaDriverExecutable = Effect.fn("stageCuaDriverExecutable")(function* (input: { + readonly repoRoot: string; + readonly stageRoot: string; + readonly stageResourcesDir: string; + readonly platform: typeof BuildPlatform.Type; + readonly arch: typeof BuildArch.Type; + readonly verbose: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const release = cuaDriverRelease( + input.platform === "mac" ? "darwin" : input.platform === "win" ? "win32" : "linux", + input.arch === "universal" ? "arm64" : input.arch, + ); + if (!release) { + return yield* new CuaDriverArtifactError({ message: "Unsupported Cua Driver build target." }); + } + const cacheDir = path.join(input.repoRoot, "node_modules/.cache/t3code/cua-driver"); + const archivePath = path.join(cacheDir, release.archiveName); + const verified = Effect.fn("verifyCuaDriverArchive")(function* (file: string) { + if (!(yield* fs.exists(file))) return false; + if (Number((yield* fs.stat(file)).size) !== release.bytes) return false; + const hash = NodeCrypto.createHash("sha256"); + yield* fs + .stream(file) + .pipe(Stream.runForEach((chunk) => Effect.sync(() => hash.update(chunk)))); + return hash.digest("hex") === release.sha256; + }); + yield* fs.makeDirectory(cacheDir, { recursive: true }); + if (!(yield* verified(archivePath))) { + yield* fs.remove(archivePath, { force: true }); + yield* Effect.scoped( + Effect.gen(function* () { + const temporary = yield* fs.makeTempDirectoryScoped({ directory: cacheDir }); + const downloadPath = path.join(temporary, release.archiveName); + yield* runCommand( + ChildProcess.make("curl", [ + "--fail", + "--location", + "--silent", + "--show-error", + "--connect-timeout", + "30", + "--max-time", + "600", + "--retry", + "3", + "--output", + downloadPath, + release.url, + ]), + { label: "download Cua Driver", verbose: input.verbose }, + ); + if (!(yield* verified(downloadPath))) { + return yield* new CuaDriverArtifactError({ + message: `Cua Driver archive failed size/SHA-256 verification: ${release.archiveName}`, + }); + } + yield* fs.rename(downloadPath, archivePath); + }), + ); + } + yield* fs.makeDirectory(input.stageRoot, { recursive: true }); + yield* Effect.scoped( + Effect.gen(function* () { + const extracted = yield* fs.makeTempDirectoryScoped({ directory: input.stageRoot }); + const hostPlatform = yield* HostProcess.Platform; + const command = + release.format === "zip" && hostPlatform !== "win32" + ? ChildProcess.make("unzip", ["-q", archivePath, "-d", extracted]) + : ChildProcess.make(hostPlatform === "win32" ? "tar.exe" : "tar", [ + "-xf", + archivePath, + "-C", + extracted, + ]); + yield* runCommand(command, { label: "extract Cua Driver", verbose: input.verbose }); + const requiredFiles = [ + release.executable, + ...(input.platform === "win" + ? ["cua-driver-uia.exe", "cua-cursor-theme.exe"] + : input.platform === "linux" + ? [ + "cua-cursor-theme", + "wayland-helper/install.sh", + "wayland-helper/winrects@cua/metadata.json", + "wayland-helper/winrects@cua/extension.js", + ] + : []), + ]; + for (const file of requiredFiles) { + if ((yield* fs.stat(path.join(extracted, file))).type !== "File") { + return yield* new CuaDriverArtifactError({ + message: `Cua Driver release is missing ${file}.`, + }); + } + } + yield* fs.makeDirectory(input.stageResourcesDir, { recursive: true }); + const destination = path.join(input.stageResourcesDir, "cua-driver"); + yield* fs.remove(destination, { recursive: true, force: true }); + if (input.platform === "mac") { + yield* fs.copyFile(path.join(extracted, release.executable), destination); + yield* fs.chmod(destination, 0o755); + } else { + yield* fs.makeDirectory(destination, { recursive: true }); + // SDK libraries come from the app's npm dependencies, not this release. + const entries = + input.platform === "linux" + ? [release.executable, "cua-cursor-theme", "wayland-helper"] + : requiredFiles; + for (const entry of entries) { + yield* fs.copy(path.join(extracted, entry), path.join(destination, entry)); + } + if (input.platform === "linux") { + for (const executable of [release.executable, "cua-cursor-theme"]) { + yield* fs.chmod(path.join(destination, executable), 0o755); + } + } + } + }), + ); +}); + function generateMacIconSet( sourcePng: string, targetIcns: string, @@ -2712,7 +2852,12 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( // not inflate the loose-file count and slow NSIS installation. ...(platform === "win" ? { asar: { smartUnpack: false }, asarUnpack: [WINDOWS_NATIVE_ASAR_UNPACK_GLOB] } - : {}), + : { + asarUnpack: [ + "**/node_modules/@trycua/cua-driver-*/**/*.{node,so,dylib,dll}", + "**/node_modules/@ubjs/node-*/**/*.node", + ], + }), extraResources: [ ...DESKTOP_EXTRA_RESOURCES, ...(platform === "linux" ? LINUX_CAPTURE_EXTRA_RESOURCES : []), @@ -3017,7 +3162,12 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( yield* fs.copy(input.serverDistDir, path.join(serverStageDir, "apps/server/dist")); const sidecarDependencies = { - ...input.runtimeExternalDependencies, + // The desktop host owns Cua; this server reaches it through telemetry IPC. + ...Object.fromEntries( + Object.entries(input.runtimeExternalDependencies).filter( + ([name]) => name !== "@trycua/cua-driver", + ), + ), ...resolveFffNativeDependencies("win", input.arch, input.fffNodeVersion), }; const sidecarPatchedDependencies = createStagePatchedDependencies( @@ -3354,13 +3504,18 @@ export const validateWindowsPackagedPayload = Effect.fn( ); } + const hostPlatform = yield* HostProcess.Platform; const listing = yield* spawnAndCollectOutput( - ChildProcess.make("tar", ["-tzf", WSL_RUNTIME_ARCHIVE_NAME], { - cwd: resourcesDir, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }), + ChildProcess.make( + hostPlatform === "win32" ? "tar.exe" : "tar", + ["-tzf", WSL_RUNTIME_ARCHIVE_NAME], + { + cwd: resourcesDir, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ), ).pipe(Effect.mapError(invalidWslRuntime)); if (listing.exitCode !== 0) { return yield* invalidWslRuntime( @@ -3449,10 +3604,6 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( if (hostPlatform === "win32" && options.platform === "win") { yield* preflightWindowsDesktopBuild({ arch: options.arch, - bundlesWslRuntime: bundlesWslRuntime({ - platform: options.platform, - runtimeArchivePath: options.wslRuntime, - }), }); } const workspaceConfig = yield* readWorkspaceConfig(); @@ -3664,6 +3815,14 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( arch: options.arch, verbose: options.verbose, }); + yield* stageCuaDriverExecutable({ + repoRoot, + stageRoot, + stageResourcesDir, + platform: options.platform, + arch: options.arch, + verbose: options.verbose, + }); yield* assertPlatformBuildResources( options.platform, diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index 5581510ad777..808b4d1d6530 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -51,6 +51,8 @@ describe("shouldBundleCliDependency", () => { "@napi-rs/keyring", "@clerk/electron-passkeys", "node-addon-api", + "@trycua/cua-driver/embedded", + "@ubjs/node", ]) { assert.strictEqual(shouldBundleCliDependency(id), false, id); } @@ -83,6 +85,7 @@ describe("selectCliRuntimeExternalDependencies", () => { "@cursor/sdk", "@ff-labs/fff-node", "@napi-rs/keyring", + "@trycua/cua-driver", "node-pty", "playwright-core", "undici", diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index 678d3d6e9151..5255456fbd5f 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -14,6 +14,8 @@ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ // Cursor ships computed Webpack imports and platform helper packages. "@cursor/sdk", + "@trycua/", + "@ubjs/", // Playwright reads package.json and browsers.json beside its runtime modules. "playwright-core", "node-pty", diff --git a/scripts/lib/desktop-external-packages.ts b/scripts/lib/desktop-external-packages.ts index d22e7bd42ec0..54375f796802 100644 --- a/scripts/lib/desktop-external-packages.ts +++ b/scripts/lib/desktop-external-packages.ts @@ -12,6 +12,8 @@ export const DESKTOP_RUNTIME_EXTERNAL_PREFIXES = [ "electron-webauthn", "ffi-rs", "@yuuang/", + "@trycua/", + "@ubjs/", // Reads its own bundle from disk by resolving `playwright-core/package.json` // at runtime and ships the browser driver alongside; there is nothing to // gain from inlining a 10 MB file the code re-reads as text.