From 77ad60ee46a08aaca9074a8d20b1ce6259663764 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:24:58 +0200 Subject: [PATCH 1/2] chore(deps): bump Electron to 44.1.1 Electron 44 makes the clipboard module asynchronous, removes clipboard.writeImage, and requires macOS 13 or newer. Text copies now await clipboard.writeText and report a refused write as false, the same shape as openExternal, so the Copy Link menu item cannot leave an unhandled rejection. Screenshot copies write a PNG ClipboardItem and keep write failures in the typed PreviewOperationError path. 44.1.1 clears the release-age policy on its own, so the exact-version exclusion is gone. Verified on the rebased branch: desktop typecheck, the three touched desktop test files, lint and formatting, a desktop and server production build, and an Electron 44.1.1 launch under xvfb that reached main window created and backend ready. Co-Authored-By: Claude Fable 5.1 --- apps/desktop/package.json | 2 +- .../src/electron/ElectronShell.test.ts | 23 ++++++++++ apps/desktop/src/electron/ElectronShell.ts | 12 +++-- apps/desktop/src/preview/Manager.test.ts | 44 ++++++++++++++++--- apps/desktop/src/preview/Manager.ts | 20 +++++++-- apps/desktop/src/window/DesktopWindow.test.ts | 4 +- docs/user/install.md | 2 + pnpm-lock.yaml | 18 ++++---- 8 files changed, 99 insertions(+), 26 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 83a07cccb660..82f8f3fc0662 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -21,7 +21,7 @@ "@t3tools/ssh": "workspace:*", "@t3tools/tailscale": "workspace:*", "effect": "catalog:", - "electron": "43.4.1", + "electron": "44.1.1", "electron-store": "^8.2.0", "electron-updater": "^6.6.2", "playwright-core": "1.60.0", diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 9ae6f502b000..2342dbb96c50 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -36,6 +36,29 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("copies text to the system clipboard", () => + Effect.gen(function* () { + writeTextMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.copyText("https://example.com/path"); + + assert.equal(result, true); + assert.deepEqual(writeTextMock.mock.calls, [["https://example.com/path"]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("reports a refused clipboard write instead of failing", () => + Effect.gen(function* () { + writeTextMock.mockRejectedValue(new Error("clipboard unavailable")); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.copyText("https://example.com/path"); + + assert.equal(result, false); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("opens remote SSH editor URLs", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 2ed13bfebd0f..61d9f70359e7 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -43,7 +43,8 @@ export class ElectronShell extends Context.Service< ElectronShell, { readonly openExternal: (rawUrl: unknown) => Effect.Effect; - readonly copyText: (text: string) => Effect.Effect; + /** Writes to the system clipboard. Resolves false when the platform refuses the write. */ + readonly copyText: (text: string) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronShell") {} @@ -60,9 +61,12 @@ export const make = ElectronShell.of({ ), }), copyText: (text) => - Effect.sync(() => { - Electron.clipboard.writeText(text); - }), + Effect.promise(() => + Electron.clipboard.writeText(text).then( + () => true, + () => false, + ), + ), }); export const layer = Layer.succeed(ElectronShell, make); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 75271d76386a..672efcb7cc2f 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -113,6 +113,7 @@ describe("previewWindowOpenAction", () => { const { browserWindowConstructor, + clipboardItemConstructor, createFromPath, fromId, getFocusedWebContents, @@ -120,23 +121,28 @@ const { showItemInFolder, webviewSend, writeFile, - writeImage, + writeClipboard, } = vi.hoisted(() => ({ browserWindowConstructor: vi.fn(), - createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), + clipboardItemConstructor: vi.fn<(data: Record) => void>(), + createFromPath: vi.fn(() => ({ + isEmpty: (): boolean => false, + toPNG: () => Buffer.from("png"), + })), fromId: vi.fn<(_id?: number) => Electron.WebContents | null>((_id?: number) => null), getFocusedWebContents: vi.fn(() => null), mkdir: vi.fn((_path: string) => undefined), showItemInFolder: vi.fn(), webviewSend: vi.fn(), writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined), - writeImage: vi.fn(), + writeClipboard: vi.fn(async () => undefined), })); vi.mock("electron", () => ({ BrowserWindow: browserWindowConstructor, + ClipboardItem: clipboardItemConstructor, clipboard: { - writeImage, + write: writeClipboard, }, nativeImage: { createFromPath, @@ -469,7 +475,8 @@ describe("PreviewManager", () => { mkdir.mockClear(); writeFile.mockClear(); showItemInFolder.mockClear(); - writeImage.mockClear(); + clipboardItemConstructor.mockClear(); + writeClipboard.mockClear(); createFromPath.mockClear(); webviewSend.mockClear(); }); @@ -3232,7 +3239,15 @@ describe("PreviewManager", () => { yield* manager.copyArtifactToClipboard(artifactPath); expect(createFromPath).toHaveBeenCalledWith(artifactPath); - expect(writeImage).toHaveBeenCalledOnce(); + const pngBlob = clipboardItemConstructor.mock.calls[0]?.[0]["image/png"]; + if (!(pngBlob instanceof Blob)) { + throw new Error("expected a PNG Blob in the ClipboardItem"); + } + expect(pngBlob.type).toBe("image/png"); + expect(Buffer.from(yield* Effect.promise(() => pngBlob.arrayBuffer())).toString()).toBe( + "png", + ); + expect(writeClipboard).toHaveBeenCalledOnce(); const exit = yield* Effect.exit( manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json"), ); @@ -3246,7 +3261,10 @@ describe("PreviewManager", () => { }); expect("cause" in error).toBe(false); - createFromPath.mockReturnValueOnce({ isEmpty: () => true }); + createFromPath.mockReturnValueOnce({ + isEmpty: () => true, + toPNG: () => Buffer.from("invalid"), + }); const invalidImageExit = yield* Effect.exit(manager.copyArtifactToClipboard(artifactPath)); expect(Exit.isFailure(invalidImageExit)).toBe(true); if (Exit.isSuccess(invalidImageExit)) return; @@ -3254,6 +3272,18 @@ describe("PreviewManager", () => { _tag: "PreviewArtifactImageLoadError", artifactPath, }); + + const writeCause = new Error("clipboard write failed"); + writeClipboard.mockRejectedValueOnce(writeCause); + const writeExit = yield* Effect.exit(manager.copyArtifactToClipboard(artifactPath)); + expect(Exit.isFailure(writeExit)).toBe(true); + if (Exit.isSuccess(writeExit)) return; + expect(Option.getOrThrow(Cause.findErrorOption(writeExit.cause))).toMatchObject({ + _tag: "PreviewOperationError", + operation: "copyArtifactToClipboard.write", + artifactPath, + cause: writeCause, + }); }), ), ); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 01398721dd58..5196c3ee2cf5 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -32,7 +32,15 @@ import type { } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; -import { BrowserWindow, type Session, clipboard, nativeImage, shell, webContents } from "electron"; +import { + BrowserWindow, + ClipboardItem, + type Session, + clipboard, + nativeImage, + shell, + webContents, +} from "electron"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -4031,8 +4039,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (image.isEmpty()) { return yield* new PreviewArtifactImageLoadError({ artifactPath: resolvedPath }); } - yield* attempt({ operation: "copyArtifactToClipboard.write", artifactPath: resolvedPath }, () => - clipboard.writeImage(image), + yield* attemptPromise( + { operation: "copyArtifactToClipboard.write", artifactPath: resolvedPath }, + () => + clipboard.write([ + new ClipboardItem({ + "image/png": new Blob([Uint8Array.from(image.toPNG())], { type: "image/png" }), + }), + ]), ); }); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index abf6f220eca4..1a78426e1768 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -280,7 +280,7 @@ function makeTestLayer(input: { input.openedExternalUrls?.push(url); return true; }), - copyText: () => Effect.void, + copyText: () => Effect.succeed(true), } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, electronWindowLayer, @@ -380,7 +380,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), - copyText: () => Effect.void, + copyText: () => Effect.succeed(true), } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, Layer.succeed(ElectronWindow.ElectronWindow, electronWindowShape), diff --git a/docs/user/install.md b/docs/user/install.md index 29ec17684674..d8cba891614d 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -8,6 +8,8 @@ Node.js `^22.16 || ^23.11 || >=24.10` on the machine that runs the T3 Code serve At least one provider CLI, installed and authenticated. See [Providers](#providers) below. +The desktop app needs macOS 13 (Ventura) or newer on Mac. + ## Run Without Installing ```bash diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1085cbb120f1..913806c13e58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,7 +132,7 @@ importers: dependencies: '@clerk/electron': specifier: 0.0.37 - version: 0.0.37(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@43.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 0.0.37(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.1.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron-passkeys': specifier: 0.0.3 version: 0.0.3 @@ -158,8 +158,8 @@ importers: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) electron: - specifier: 43.4.1 - version: 43.4.1 + specifier: 44.1.1 + version: 44.1.1 electron-store: specifier: ^8.2.0 version: 8.2.0 @@ -544,7 +544,7 @@ importers: version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron': specifier: 0.0.37 - version: 0.0.37(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@43.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 0.0.37(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.1.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': specifier: 6.14.7 version: 6.14.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -6348,8 +6348,8 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@43.4.1: - resolution: {integrity: sha512-5b+EuiwkgG5iRcsEL34rimgRpkYp15SsfZOa0pC5kXs0Tb82TH4n95rpQzTZa7yRCbA7tm0WoEbuBL6NaAhAcA==} + electron@44.1.1: + resolution: {integrity: sha512-N2WCq2sbOkqQgvXJYx2lS6UiO8bF+Yr67trDnS6JKa2WxTCRQsAjGl57SUtdW9h6r5PlduBFjIhxhgd3dzv1hg==} engines: {node: '>= 22.12.0'} hasBin: true @@ -11441,12 +11441,12 @@ snapshots: '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3 '@clerk/electron-passkeys-win32-x64-msvc': 0.0.3 - '@clerk/electron@0.0.37(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@43.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/electron@0.0.37(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.1.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@clerk/clerk-js': 6.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': 6.14.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/shared': 4.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - electron: 43.4.1 + electron: 44.1.1 react: 19.2.6 tslib: 2.8.1 optionalDependencies: @@ -16143,7 +16143,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron@43.4.1: + electron@44.1.1: dependencies: '@electron-internal/extract-zip': 1.0.5 '@electron/get': 5.1.0 From 869888944569e6940b1fbcfdfa3cd09b3d56d78b Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:21:24 +0200 Subject: [PATCH 2/2] chore(release): declare the macOS 13 floor in updater manifests Electron 44 cannot launch on macOS 12, but electron-builder only writes a minimum system version into Info.plist, never into latest-mac.yml. Without it, electron-updater on Monterey would install a build that refuses to open. The desktop artifact build now sets mac.minimumSystemVersion to 13.0 and stamps minimumSystemVersion: '22.0.0' into every -mac.yml it emits. electron-updater compares that field against os.release(), which is the Darwin kernel version (Ventura is Darwin 22), so the manifest carries the Darwin form while the plist keeps the product version. The manifest merge step already carries the key through. Co-Authored-By: Claude Fable 5.1 --- docs/operations/release.md | 1 + scripts/build-desktop-artifact.test.ts | 40 ++++++++++++++++++++++++++ scripts/build-desktop-artifact.ts | 34 +++++++++++++++++++++- 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/docs/operations/release.md b/docs/operations/release.md index e5a4d3544c35..6a4553b55f75 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -215,6 +215,7 @@ available. - macOS metadata note: - `electron-updater` reads `latest-mac.yml` on stable and `nightly-mac.yml` on nightly, for both Intel and Apple Silicon. - The workflow merges the per-arch mac manifests into one channel-specific mac manifest before publishing the GitHub Release. + - The desktop artifact build stamps `minimumSystemVersion: '22.0.0'` (Darwin 22, macOS 13 Ventura) into every mac manifest. `electron-updater` compares that field against `os.release()`, which reports the Darwin kernel version rather than the macOS product version, and skips the update on older systems instead of installing an app that cannot launch there. ### Windows payload topology and update validation diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 19979c330dc0..4e7965d92fbd 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -21,6 +21,8 @@ import { createStageWorkspaceConfig, createStagePatchedDependencies, createBuildConfig, + isMacUpdateManifestName, + stampMacUpdateManifest, DESKTOP_ELECTRON_LANGUAGES, DESKTOP_FILE_EXCLUSIONS, DESKTOP_EXTRA_RESOURCES, @@ -563,6 +565,43 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ]); }); + it("stamps the macOS floor into updater manifests", () => { + const stamped = stampMacUpdateManifest( + `version: 1.2.3 +files: + - url: T3-Code-1.2.3-arm64.zip + sha512: zipsha + size: 1 + - url: T3-Code-1.2.3-arm64.dmg + sha512: dmgsha + size: 2 +path: T3-Code-1.2.3-arm64.zip +sha512: zipsha +releaseDate: '2026-09-03T10:00:00.000Z' +`, + "latest-mac.yml", + ); + + assert.equal( + stamped, + `version: '1.2.3' +files: + - url: T3-Code-1.2.3-arm64.zip + sha512: zipsha + size: 1 + - url: T3-Code-1.2.3-arm64.dmg + sha512: dmgsha + size: 2 +minimumSystemVersion: '22.0.0' +releaseDate: '2026-09-03T10:00:00.000Z' +`, + ); + assert.isTrue(isMacUpdateManifestName("latest-mac.yml")); + assert.isTrue(isMacUpdateManifestName("nightly-mac.yml")); + assert.isFalse(isMacUpdateManifestName("builder-debug.yml")); + assert.isFalse(isMacUpdateManifestName("latest.yml")); + }); + it.effect("applies platform-specific packaging to the build config", () => Effect.gen(function* () { const mac = yield* createBuildConfig( @@ -610,6 +649,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.notProperty(mac, "asarUnpack"); assert.notProperty(linux, "asarUnpack"); assert.notProperty(win, "asarUnpack"); + assert.nestedPropertyVal(mac, "mac.minimumSystemVersion", "13.0"); assert.deepStrictEqual(win.extraResources, [ { from: "apps/desktop/prod-resources/resource-monitor", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index f4bbad9d5407..e874e4929c45 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -34,6 +34,7 @@ import { } from "./lib/cli-external-packages.ts"; import { loadRepoEnv } from "./lib/public-config.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; +import { parseUpdateManifest, serializeUpdateManifest } from "./lib/update-manifest.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -2422,6 +2423,32 @@ export function resolveDesktopProductName(version: string): string { : (desktopPackageJson.productName ?? "T3 Code"); } +// Electron 44 dropped macOS 12, so the app cannot launch below Ventura. The +// product version lands in Info.plist as LSMinimumSystemVersion. The updater +// manifests need the same floor as a Darwin kernel version, because +// electron-updater compares `minimumSystemVersion` against `os.release()` +// (Ventura is Darwin 22) and skips the update instead of installing a build the +// OS cannot run. +export const MAC_MINIMUM_SYSTEM_VERSION = "13.0"; +export const MAC_MINIMUM_DARWIN_VERSION = "22.0.0"; + +// electron-builder names the channel manifests `-mac.yml`; the +// builder-debug.yml it drops next to them is not an updater manifest. +export function isMacUpdateManifestName(fileName: string): boolean { + return /^[A-Za-z]+-mac\.yml$/.test(fileName); +} + +export function stampMacUpdateManifest(raw: string, sourcePath: string): string { + const manifest = parseUpdateManifest(raw, sourcePath, "macOS"); + return serializeUpdateManifest( + { + ...manifest, + extras: { ...manifest.extras, minimumSystemVersion: MAC_MINIMUM_DARWIN_VERSION }, + }, + { platformLabel: "macOS" }, + ); +} + export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( platform: typeof BuildPlatform.Type, target: string, @@ -2485,6 +2512,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( target: target === "dmg" ? [target, "zip"] : [target], icon: "icon.icns", category: "public.app-category.developer-tools", + minimumSystemVersion: MAC_MINIMUM_SYSTEM_VERSION, protocols: [ { name: "T3 Code", @@ -3694,7 +3722,11 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( if (!stat || stat.type !== "File") continue; const to = path.join(options.outputDir, entry); - yield* fs.copyFile(from, to); + if (options.platform === "mac" && isMacUpdateManifestName(entry)) { + yield* fs.writeFileString(to, stampMacUpdateManifest(yield* fs.readFileString(from), from)); + } else { + yield* fs.copyFile(from, to); + } copiedArtifacts.push(to); }