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/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/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 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); }