diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 7b2f1c8f7..1e98c12d6 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -1,7 +1,7 @@ --- remote: t3code-upstream branch: main -reviewed-through: "2aa5f095fc3bb65c00cc4efce66a5473e2d4554a" +reviewed-through: "f2d5fc91e3030e5c3956fdadc13e1eaa25bcabe3" reviewed-through-date: "2026-08-19" --- @@ -1702,6 +1702,59 @@ Open questions raised by this batch, for a later decision: - **A muted preview tab loses its speaker affordance** once the guest goes silent (`tabAudioState` returns "none"), leaving no in-strip way to see or undo the mute. +## 2026-08-19 (second) — `2aa5f095fc3bb65c00cc4efce66a5473e2d4554a..f2d5fc91e3030e5c3956fdadc13e1eaa25bcabe3` + +Three upstream commits, three change sets, one PR. **All three adopted** onto +`upstream/2026-08-19-batch2`. `git cherry` reported every one absent from Pylon. The +deferred register was empty going in and stays empty. + +**N3 (`#7522`) supersedes work adopted hours earlier in the first 2026-08-19 batch.** +`6a687ee4` had added a local shim in `apps/web/src/main.tsx` overriding +`isAutoFillSupported` to `false`, because `@clerk/electron` reported passkey autofill as +supported while executing the "quiet" request as a modal OS dialog — so the sign-in form +popped a system passkey prompt the moment it mounted. Upstream has now deleted that shim +in favour of a library fix (clerk/javascript#9500). + +**The developer chose to adopt it to stay in step with upstream, over a recommendation to +defer.** The trade was stated plainly and decided: Pylon already had correct behavior from +the shim, so adoption buys no user-visible change, and the cost is six **canary** +prereleases pinned to one dated build (`v20260819050620`) — `@clerk/electron`, +`@clerk/electron-passkeys`, and four platform-specific native binaries +(`darwin-arm64`, `darwin-x64`, `win32-arm64-msvc`, `win32-x64-msvc`) — all added to +`minimumReleaseAgeExclude`, plus reliance on the explicitly-internal +`__internal_clerkUIVersion` prop. At adoption time `npm view @clerk/electron dist-tags` +still reported `latest: 0.0.33`; there is no stable `0.0.34`. + +**Follow-up this creates:** unpin the canaries once Clerk promotes a stable release +containing PR #9500. Check with `npm view @clerk/electron dist-tags` — when `latest` is +`0.0.34` or newer, move the catalog off the canary strings and drop the six +`minimumReleaseAgeExclude` entries. Until then Pylon ships prerelease native binaries in +its desktop builds. + +**N2 (`#6562`)** reorders desktop shutdown so the window closes before cleanup runs +rather than hanging visible through it, threading `ElectronWindow` into lifecycle +registration. It is adjacent to `#7397` (`QuitHold`) adopted in the first batch but does +not address that commit's watchdog gap, which stays open. + +**N1 (`#7491`)** is a one-line Tailwind fix adding `flex items-center` so sidebar status +pills align with project names. + +Conflicts and adaptations, all resolved Pylon-first: + +| File | Conflict | Resolution | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/desktop/src/app/DesktopLifecycle.test.ts` | `#6562` hoists the inline Electron fakes to module-scope helpers and deletes the per-test copies; Pylon's copy carried `name: Effect.succeed("Pylon")` | Took upstream's deletion — the test body below now calls `makeElectronAppLayer(appListeners)` — and restored Pylon's product name on the hoisted fake, which had arrived as "T3 Code". | +| `apps/web/src/components/clerk/electronPasskeys.test.ts` | New test stubs `location` as `t3code:` / `app` with rpId `clerk.t3.codes` | Rewritten to Pylon's `pylon-code:` renderer protocol and a placeholder rpId. Behavior is identical either way: `originCanSatisfyRpId` can never be satisfied by a custom scheme, which is what pushes both assertions onto the native path. | + +Validation: + +- 3 changed test files, **13 tests, 0 failures** (with the `.prime/` and `.claude/worktrees/` + excludes noted in the previous batch). +- Typecheck clean for `@t3tools/web` and `@t3tools/desktop`, both confirmed to have + actually run rather than filtered to nothing. +- `vp i` resolved the canary native binaries on darwin-arm64 and the lockfile passed the + supply-chain policy check. The other three platform binaries are unverified here. + ## Deferred register _The register is currently empty. DEF-1 and DEF-2 were adopted on 2026-08-11 diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index b072c88de..422772f6f 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; @@ -7,91 +8,110 @@ import type * as Electron from "electron"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; +function makeElectronAppLayer( + appListeners: Map void>, + quit: Effect.Effect = Effect.void, +) { + const registerListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => + Effect.acquireRelease( + Effect.sync(() => { + appListeners.set(eventName, listener); + }), + () => + Effect.sync(() => { + appListeners.delete(eventName); + }), + ).pipe(Effect.asVoid); + + return Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata read"), + name: Effect.succeed("Pylon"), + systemLocale: Effect.succeed("en-US"), + whenReady: Effect.void, + quit, + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + getAppMetrics: Effect.succeed([]), + isDefaultProtocolClient: () => Effect.succeed(false), + setAsDefaultProtocolClient: () => Effect.succeed(true), + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, + onBeforeQuitForUpdate: (listener) => registerListener("before-quit-for-update", listener), + on: (eventName, listener) => + registerListener(eventName, listener as unknown as (...args: readonly unknown[]) => void), + } satisfies ElectronApp.ElectronApp["Service"]); +} + +const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { + shouldUseDarkColors: Effect.succeed(false), + setSource: () => Effect.void, + onUpdated: () => Effect.void, +}); + +function makeElectronWindowLayer(destroyAll: Effect.Effect = Effect.void) { + return Layer.succeed(ElectronWindow.ElectronWindow, { + create: () => Effect.die("unexpected window creation"), + main: Effect.die("unexpected main window read"), + currentMainOrFirst: Effect.die("unexpected current window read"), + focusedMainOrFirst: Effect.die("unexpected focused window read"), + setMain: () => Effect.void, + clearMain: () => Effect.void, + reveal: () => Effect.void, + sendAll: () => Effect.void, + destroyAll, + syncAllAppearance: () => Effect.void, + }); +} + +function makeDesktopWindowLayer( + input: { + readonly activate?: Effect.Effect; + readonly flushMainWindowBounds?: Effect.Effect; + } = {}, +) { + return Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected window creation"), + ensureMain: Effect.die("unexpected window creation"), + revealOrCreateMain: Effect.die("unexpected window creation"), + activate: input.activate ?? Effect.void, + createMainIfBackendReady: Effect.void, + showConnectingSplash: Effect.void, + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, + flushMainWindowBounds: input.flushMainWindowBounds ?? Effect.void, + dispatchMenuAction: () => Effect.void, + zoomMain: () => Effect.void, + syncAppearance: Effect.void, + }); +} + describe("DesktopLifecycle", () => { for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray) { it.effect(`lets the updater's quit event proceed on ${platform}`, () => { const appListeners = new Map void>(); - - const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { - metadata: Effect.die("unexpected metadata read"), - name: Effect.succeed("Pylon"), - systemLocale: Effect.succeed("en-US"), - whenReady: Effect.void, - quit: Effect.void, - exit: () => Effect.void, - relaunch: () => Effect.void, - setPath: () => Effect.void, - setName: () => Effect.void, - setAboutPanelOptions: () => Effect.void, - setAppUserModelId: () => Effect.void, - getAppMetrics: Effect.succeed([]), - isDefaultProtocolClient: () => Effect.succeed(false), - setAsDefaultProtocolClient: () => Effect.succeed(true), - setDesktopName: () => Effect.void, - setDockIcon: () => Effect.void, - appendCommandLineSwitch: () => Effect.void, - removeCommandLineSwitch: () => Effect.void, - onBeforeQuitForUpdate: (listener) => - Effect.acquireRelease( - Effect.sync(() => { - appListeners.set("before-quit-for-update", listener); - }), - () => - Effect.sync(() => { - appListeners.delete("before-quit-for-update"); - }), - ).pipe(Effect.asVoid), - on: (eventName, listener) => - Effect.acquireRelease( - Effect.sync(() => { - appListeners.set( - eventName, - listener as unknown as (...args: readonly unknown[]) => void, - ); - }), - () => - Effect.sync(() => { - appListeners.delete(eventName); - }), - ).pipe(Effect.asVoid), - } satisfies ElectronApp.ElectronApp["Service"]); - - const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { - shouldUseDarkColors: Effect.succeed(false), - setSource: () => Effect.void, - onUpdated: () => Effect.void, - }); - - const desktopWindowLayer = Layer.succeed(DesktopWindow.DesktopWindow, { - createMain: Effect.die("unexpected window creation"), - ensureMain: Effect.die("unexpected window creation"), - revealOrCreateMain: Effect.die("unexpected window creation"), - activate: Effect.void, - createMainIfBackendReady: Effect.void, - showConnectingSplash: Effect.void, - handleBackendReady: () => Effect.void, - handleBackendNotReady: Effect.void, - flushMainWindowBounds: Effect.void, - dispatchMenuAction: () => Effect.void, - zoomMain: () => Effect.void, - syncAppearance: Effect.void, - }); - const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { platform, isDevelopment: false, } as DesktopEnvironment.DesktopEnvironment["Service"]); const layer = DesktopLifecycle.layer.pipe( - Layer.provideMerge(electronAppLayer), + Layer.provideMerge(makeElectronAppLayer(appListeners)), Layer.provideMerge(electronThemeLayer), - Layer.provideMerge(desktopWindowLayer), + Layer.provideMerge(makeElectronWindowLayer()), + Layer.provideMerge(makeDesktopWindowLayer()), Layer.provideMerge(environmentLayer), Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), @@ -123,4 +143,103 @@ describe("DesktopLifecycle", () => { ).pipe(Effect.provide(layer)); }); } + + it.effect("destroys windows before waiting for backend shutdown", () => + Effect.gen(function* () { + const appListeners = new Map void>(); + const shutdownRequested = yield* Deferred.make(); + const allowShutdown = yield* Deferred.make(); + const quitRequested = yield* Deferred.make(); + const events: string[] = []; + + const quit = Effect.sync(() => { + events.push("quit"); + }).pipe(Effect.andThen(Deferred.succeed(quitRequested, undefined)), Effect.asVoid); + const destroyAll = Effect.sync(() => { + events.push("destroy"); + }); + const flushMainWindowBounds = Effect.sync(() => { + events.push("flush"); + }); + + const desktopShutdownLayer = Layer.succeed(DesktopShutdown.DesktopShutdown, { + request: Effect.sync(() => { + events.push("request"); + }).pipe(Effect.andThen(Deferred.succeed(shutdownRequested, undefined)), Effect.asVoid), + awaitRequest: Deferred.await(shutdownRequested), + markComplete: Deferred.succeed(allowShutdown, undefined).pipe(Effect.asVoid), + awaitComplete: Deferred.await(allowShutdown), + isComplete: Deferred.isDone(allowShutdown), + }); + + const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: "darwin", + isDevelopment: false, + } as DesktopEnvironment.DesktopEnvironment["Service"]); + + const layer = DesktopLifecycle.layer.pipe( + Layer.provideMerge(makeElectronAppLayer(appListeners, quit)), + Layer.provideMerge(electronThemeLayer), + Layer.provideMerge(makeElectronWindowLayer(destroyAll)), + Layer.provideMerge(makeDesktopWindowLayer({ flushMainWindowBounds })), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(desktopShutdownLayer), + Layer.provideMerge(DesktopState.layer), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + const event = { preventDefault: () => undefined } as Electron.Event; + appListeners.get("before-quit")?.(event); + + yield* Deferred.await(shutdownRequested); + const eventsBeforeCleanup = [...events]; + yield* Deferred.succeed(allowShutdown, undefined); + yield* Deferred.await(quitRequested); + + assert.deepEqual(eventsBeforeCleanup, ["flush", "destroy", "request"]); + assert.deepEqual(events, ["flush", "destroy", "request", "quit"]); + }), + ).pipe(Effect.provide(layer)); + }), + ); + + it.effect("ignores app activation while quitting", () => + Effect.gen(function* () { + const appListeners = new Map void>(); + let activationCount = 0; + const activate = Effect.sync(() => { + activationCount += 1; + }); + const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: "darwin", + isDevelopment: false, + } as DesktopEnvironment.DesktopEnvironment["Service"]); + const layer = DesktopLifecycle.layer.pipe( + Layer.provideMerge(makeElectronAppLayer(appListeners)), + Layer.provideMerge(electronThemeLayer), + Layer.provideMerge(makeElectronWindowLayer()), + Layer.provideMerge(makeDesktopWindowLayer({ activate })), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(DesktopShutdown.layer), + Layer.provideMerge(DesktopState.layer), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const state = yield* DesktopState.DesktopState; + yield* lifecycle.register; + yield* Ref.set(state.quitting, true); + + appListeners.get("activate")?.(); + + assert.equal(activationCount, 0); + }), + ).pipe(Effect.provide(layer)); + }), + ); }); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index ab03d18f3..6a98e59eb 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -12,6 +12,7 @@ import { makeComponentLogger } from "./DesktopObservability.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; @@ -35,8 +36,12 @@ export type DesktopLifecycleRuntimeServices = | ElectronApp.ElectronApp | ElectronTheme.ElectronTheme; +type DesktopLifecycleRegistrationServices = + | DesktopLifecycleRuntimeServices + | ElectronWindow.ElectronWindow; + /** - * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme + * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow */ export class DesktopLifecycle extends Context.Service< DesktopLifecycle, @@ -44,7 +49,11 @@ export class DesktopLifecycle extends Context.Service< readonly relaunch: ( reason: string, ) => Effect.Effect; - readonly register: Effect.Effect; + readonly register: Effect.Effect< + void, + never, + Scope.Scope | DesktopLifecycleRegistrationServices + >; } >()("@t3tools/desktop/app/DesktopLifecycle") {} @@ -73,14 +82,13 @@ function addScopedListener>( } const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( - function* (): Effect.fn.Return< - void, - never, - DesktopShutdown.DesktopShutdown | DesktopWindow.DesktopWindow - > { + function* ( + afterBoundsFlush: Effect.Effect = Effect.void, + ): Effect.fn.Return { const shutdown = yield* DesktopShutdown.DesktopShutdown; const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.flushMainWindowBounds; + yield* afterBoundsFlush; yield* shutdown.request; yield* shutdown.awaitComplete; }, @@ -88,7 +96,9 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo function handleBeforeQuit( event: Electron.Event, - runEffect: (effect: Effect.Effect) => Promise, + runEffect: ( + effect: Effect.Effect, + ) => Promise, allowQuit: () => boolean, markQuitAllowed: () => void, ): void { @@ -107,9 +117,16 @@ function handleBeforeQuit( void runEffect( Effect.gen(function* () { const state = yield* DesktopState.DesktopState; + const electronWindow = yield* ElectronWindow.ElectronWindow; yield* Ref.set(state.quitting, true); yield* logLifecycleInfo("before-quit received"); - yield* requestDesktopShutdownAndWait(); + yield* requestDesktopShutdownAndWait( + electronWindow.destroyAll.pipe( + Effect.catchCause((cause) => + logLifecycleError("failed to destroy windows before shutdown", { cause }), + ), + ), + ); }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), ).finally(() => { markQuitAllowed(); @@ -124,7 +141,9 @@ function handleBeforeQuit( function quitFromSignal( signal: "SIGINT" | "SIGTERM", - runEffect: (effect: Effect.Effect) => Promise, + runEffect: ( + effect: Effect.Effect, + ) => Promise, ): void { void runEffect( Effect.gen(function* () { @@ -173,7 +192,7 @@ export const make = DesktopLifecycle.of({ const electronApp = yield* ElectronApp.ElectronApp; const electronTheme = yield* ElectronTheme.ElectronTheme; const environment = yield* DesktopEnvironment.DesktopEnvironment; - const context = yield* Effect.context(); + const context = yield* Effect.context(); const runEffect = Effect.runPromiseWith(context); let quitAllowed = false; let updaterQuitAllowed = false; @@ -204,7 +223,13 @@ export const make = DesktopLifecycle.of({ ); }); yield* electronApp.on("activate", () => { - void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate"))); + void runEffect( + Effect.gen(function* () { + const state = yield* DesktopState.DesktopState; + if (yield* Ref.get(state.quitting)) return; + yield* desktopWindow.activate; + }).pipe(Effect.withSpan("desktop.lifecycle.activate")), + ); }); yield* electronApp.on("window-all-closed", () => { void runEffect( diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index 6c738132f..a013ab246 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -208,7 +208,7 @@ describe("ElectronWindow", () => { }).pipe(Effect.provide(TestLayer)), ); - it.effect("preserves destroy failures with the target window", () => + it.effect("preserves destroy failures and continues with later windows", () => Effect.gen(function* () { const cause = new Error("window destroy failed"); const window = { @@ -217,7 +217,11 @@ describe("ElectronWindow", () => { throw cause; }), } as unknown as Electron.BrowserWindow; - getAllWindowsMock.mockReturnValueOnce([window]); + const laterWindow = { + id: 44, + destroy: vi.fn(), + } as unknown as Electron.BrowserWindow; + getAllWindowsMock.mockReturnValueOnce([window, laterWindow]); const electronWindow = yield* ElectronWindow.ElectronWindow; const exit = yield* Effect.exit(electronWindow.destroyAll); @@ -231,6 +235,7 @@ describe("ElectronWindow", () => { assert.isNull(error.channel); assert.strictEqual(error.cause, cause); } + assert.equal(vi.mocked(laterWindow.destroy).mock.calls.length, 1); }).pipe(Effect.provide(TestLayer)), ); }); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 467132858..5f6a9d342 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -1,6 +1,8 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import type * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -257,18 +259,27 @@ export const make = Effect.gen(function* () { } }), destroyAll: Effect.gen(function* () { + let firstFailure: Cause.Cause | undefined; for (const window of yield* listWindows) { - yield* Effect.try({ - try: () => window.destroy(), - catch: (cause) => - new ElectronWindowOperationError({ - operation: "destroy-window", - platform, - windowId: window.id, - channel: null, - cause, - }), - }).pipe(Effect.orDie); + const exit = yield* Effect.exit( + Effect.try({ + try: () => window.destroy(), + catch: (cause) => + new ElectronWindowOperationError({ + operation: "destroy-window", + platform, + windowId: window.id, + channel: null, + cause, + }), + }).pipe(Effect.orDie), + ); + if (Exit.isFailure(exit)) { + firstFailure ??= exit.cause; + } + } + if (firstFailure !== undefined) { + return yield* Effect.failCause(firstFailure); } }), syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5ab2359ef..5175d8a3c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1416,7 +1416,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { isWokeStatus ? "pointer-events-auto" : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0", - "self-center justify-self-end tabular-nums text-secondary-label transition-opacity", + "flex items-center self-center justify-self-end tabular-nums text-secondary-label transition-opacity", snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0", )} > diff --git a/apps/web/src/components/clerk/electronPasskeys.test.ts b/apps/web/src/components/clerk/electronPasskeys.test.ts new file mode 100644 index 000000000..ac308c433 --- /dev/null +++ b/apps/web/src/components/clerk/electronPasskeys.test.ts @@ -0,0 +1,62 @@ +import { createPasskeys } from "@clerk/electron/passkeys"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +const publicKeyOptions = { + allowCredentials: [], + challenge: new Uint8Array([1]), + rpId: "clerk.example.com", + timeout: 60_000, + userVerification: "preferred" as const, +}; + +const stubNativePasskeys = () => { + const get = vi.fn().mockResolvedValue({ + ok: false, + error: { code: "cancelled", message: "user cancelled" }, + }); + + // Pylon's renderer protocol, not T3's. A custom scheme can never satisfy an + // rpId, which is what pushes both cases below onto the native path. + vi.stubGlobal("location", { protocol: "pylon-code:", hostname: "app" }); + vi.stubGlobal("window", { + PublicKeyCredential: vi.fn(), + __clerk_internal_electron_passkeys: { + platform: "darwin", + electronMajor: 41, + get, + }, + }); + + return get; +}; + +describe("Electron passkeys", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not send an autofill request to the native bridge", async () => { + const get = stubNativePasskeys(); + const passkeys = createPasskeys(); + + const result = await passkeys.get({ + publicKeyOptions, + conditionalUI: true, + }); + + expect(get).not.toHaveBeenCalled(); + expect(result.error).toMatchObject({ code: "passkey_operation_aborted" }); + }); + + it("sends an explicit passkey request to the native bridge", async () => { + const get = stubNativePasskeys(); + const passkeys = createPasskeys(); + + await passkeys.get({ + publicKeyOptions, + conditionalUI: false, + }); + + expect(get).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index c9e5dd419..f41db1081 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,7 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { ClerkProvider } from "@clerk/react"; -import { passkeys as electronPasskeys } from "@clerk/electron/passkeys"; +import { passkeys } from "@clerk/electron/passkeys"; import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react"; import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; @@ -37,14 +37,9 @@ if (isElectron) { const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; -// @clerk/electron reports passkey autofill as supported but executes the -// "quiet" autofill request as a modal prompt, so Clerk's sign-in form pops an -// OS passkey dialog the moment it mounts. Report autofill as unsupported; the -// explicit "Use passkey" button keeps working. -// Upstream: https://github.com/clerk/javascript/issues/9496 -const passkeys = { - ...electronPasskeys, - isAutoFillSupported: () => Promise.resolve(false), +// First Clerk UI build containing https://github.com/clerk/javascript/pull/9500. +const electronClerkUI = { + __internal_clerkUIVersion: "1.30.5-canary.v20260819050620", }; const app = ; @@ -54,6 +49,7 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( {clerkPublishableKey && hasCloudPublicConfig() ? ( isElectron ? ( @solana/wallet-adapter-react': '-' '@clerk/clerk-js>@solana/wallet-standard': '-' '@clerk/clerk-js>@wallet-standard/core': '-' - '@clerk/electron': 0.0.33 - '@clerk/electron-passkeys': 0.0.3 + '@clerk/electron': 0.0.34-canary.v20260819050620 + '@clerk/electron-passkeys': 0.0.4-canary.v20260819050620 '@clerk/expo': 4.2.0 '@clerk/react': 6.14.4 '@clerk/shared': 4.29.2 @@ -118,11 +118,11 @@ importers: apps/desktop: dependencies: '@clerk/electron': - specifier: 0.0.33 - version: 0.0.33(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 0.0.34-canary.v20260819050620 + version: 0.0.34-canary.v20260819050620(@clerk/electron-passkeys@0.0.4-canary.v20260819050620)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron-passkeys': - specifier: 0.0.3 - version: 0.0.3 + specifier: 0.0.4-canary.v20260819050620 + version: 0.0.4-canary.v20260819050620 '@effect/platform-node': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) @@ -524,8 +524,8 @@ importers: specifier: ^1.4.1 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.33 - version: 0.0.33(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 0.0.34-canary.v20260819050620 + version: 0.0.34-canary.v20260819050620(@clerk/electron-passkeys@0.0.4-canary.v20260819050620)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': specifier: 6.14.4 version: 6.14.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1748,35 +1748,35 @@ packages: resolution: {integrity: sha512-4i4RE+ZQ0hKDDFSRKCISQPBy07SfFeFAhBUhNj2j01Nx2n4pqV+5iyg2Vf27+NKFNkgq7kdmZGvtug24D++8WQ==} engines: {node: '>=20.9.0'} - '@clerk/electron-passkeys-darwin-arm64@0.0.3': - resolution: {integrity: sha512-oAlrd+GLqP0oREP3hNRc6yNZhXGaQgJss9iCWRQqXjpw1yZD1ZU+L+E2Gfg84vMj/NSUhxbU0Djy0RXhmeUwgg==} + '@clerk/electron-passkeys-darwin-arm64@0.0.4-canary.v20260819050620': + resolution: {integrity: sha512-avxygtEfWBD3YPkxws4lB5mL/75SQvVBMQCg+b1gjFsQInnVOgAqAv4wHR8wgUsEbXY2zEkvqaYjFSnEveC0VA==} cpu: [arm64] os: [darwin] - '@clerk/electron-passkeys-darwin-x64@0.0.3': - resolution: {integrity: sha512-0UPAWQEni7o8gjWwd2sP97fPshJmT2w5b8eV/jOPVSP8NqIcFCm5yIFSEx1SfB53LGfzB01A4iEGi2pxiCHqyQ==} + '@clerk/electron-passkeys-darwin-x64@0.0.4-canary.v20260819050620': + resolution: {integrity: sha512-clShldbwDcC2ek6gJT85QKsMfaPyOhEgfz61u2n3YKivbeui33XjJu261d+LO20mqDjXZy3flLGPTIh/Kii80g==} cpu: [x64] os: [darwin] - '@clerk/electron-passkeys-win32-arm64-msvc@0.0.3': - resolution: {integrity: sha512-bbRvifm9A/gfPkwC1UyMyF9sYGEs0aen7J2kCcaOIW3K0PXcAbHtsR/gnx4dT0bjFdd21vSa2zlIdERvHc1BiA==} + '@clerk/electron-passkeys-win32-arm64-msvc@0.0.4-canary.v20260819050620': + resolution: {integrity: sha512-alIfXJSxyeulnNeHoT4X7+qO0o9/EBhiRWMhCXwKJIbWfwzqnnVrRH1P7HfYRnDepKhqRzsUaBuYTqQzisHG+A==} cpu: [arm64] os: [win32] - '@clerk/electron-passkeys-win32-x64-msvc@0.0.3': - resolution: {integrity: sha512-gFaVqKlOKTF2SJ3lMwkZ0oonycQn93p1qSrp5kzzeZBeQ+tGB3gQnjXecrhQy21AzDUyWqVJJ8KrP2HRAwifWg==} + '@clerk/electron-passkeys-win32-x64-msvc@0.0.4-canary.v20260819050620': + resolution: {integrity: sha512-QSKKMCdV9AhOt74ls8ExdwR+2ikoLGgN2LdWIjBgNk6epJVs2L1FXVZOM0ZqfVHFSl5y7vLmlOACR6WyJ2eoLg==} cpu: [x64] os: [win32] - '@clerk/electron-passkeys@0.0.3': - resolution: {integrity: sha512-OHhIe88qDL+FxyBalXdXNHAS5eEramr6Rerp+6iNkfkjqT8rx4hHNmfpmjg5/T1/am8QfknbOBZkqoXZlCrjPg==} + '@clerk/electron-passkeys@0.0.4-canary.v20260819050620': + resolution: {integrity: sha512-zOoFEsvOrArv7p4ERNAdkGbYoD86ragDhysLG1NdxJm2jnfw+AfwxUv0FkOPZ7iNuQ0rbJaR/urxdvEwVcuKRA==} engines: {node: '>=20.9.0'} - '@clerk/electron@0.0.33': - resolution: {integrity: sha512-lP2B7wGHwKWrCAh4zPWPfRUIHoxQ1rwG8iiFeLmI8jBjSGcSoPX1N9bIKUCWL+Z9POmC2aRn6yXvL7e9yzfEVw==} + '@clerk/electron@0.0.34-canary.v20260819050620': + resolution: {integrity: sha512-sfKWkH2SigKcxtR+h36G1NGnmMI5xOlwvqHpr3JYcacS/uokQbeAjCCQzEeV/9zqWYtRL4et5wCD13+gWv4b6w==} engines: {node: '>=20.9.0'} peerDependencies: - '@clerk/electron-passkeys': 0.0.3 + '@clerk/electron-passkeys': 0.0.4-canary.v20260819050620 electron: '>=28' electron-store: ^8.2.0 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -11869,26 +11869,26 @@ snapshots: - react - react-dom - '@clerk/electron-passkeys-darwin-arm64@0.0.3': + '@clerk/electron-passkeys-darwin-arm64@0.0.4-canary.v20260819050620': optional: true - '@clerk/electron-passkeys-darwin-x64@0.0.3': + '@clerk/electron-passkeys-darwin-x64@0.0.4-canary.v20260819050620': optional: true - '@clerk/electron-passkeys-win32-arm64-msvc@0.0.3': + '@clerk/electron-passkeys-win32-arm64-msvc@0.0.4-canary.v20260819050620': optional: true - '@clerk/electron-passkeys-win32-x64-msvc@0.0.3': + '@clerk/electron-passkeys-win32-x64-msvc@0.0.4-canary.v20260819050620': optional: true - '@clerk/electron-passkeys@0.0.3': + '@clerk/electron-passkeys@0.0.4-canary.v20260819050620': optionalDependencies: - '@clerk/electron-passkeys-darwin-arm64': 0.0.3 - '@clerk/electron-passkeys-darwin-x64': 0.0.3 - '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3 - '@clerk/electron-passkeys-win32-x64-msvc': 0.0.3 + '@clerk/electron-passkeys-darwin-arm64': 0.0.4-canary.v20260819050620 + '@clerk/electron-passkeys-darwin-x64': 0.0.4-canary.v20260819050620 + '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.4-canary.v20260819050620 + '@clerk/electron-passkeys-win32-x64-msvc': 0.0.4-canary.v20260819050620 - '@clerk/electron@0.0.33(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/electron@0.0.34-canary.v20260819050620(@clerk/electron-passkeys@0.0.4-canary.v20260819050620)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@clerk/clerk-js': 6.29.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': 6.14.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -11897,7 +11897,7 @@ snapshots: react: 19.2.6 tslib: 2.8.1 optionalDependencies: - '@clerk/electron-passkeys': 0.0.3 + '@clerk/electron-passkeys': 0.0.4-canary.v20260819050620 electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5c09c851d..2b243c8c4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -25,8 +25,8 @@ allowBuilds: catalog: "@clerk/backend": 3.14.0 "@clerk/clerk-js": 6.29.2 - "@clerk/electron": 0.0.33 - "@clerk/electron-passkeys": 0.0.3 + "@clerk/electron": 0.0.34-canary.v20260819050620 + "@clerk/electron-passkeys": 0.0.4-canary.v20260819050620 "@clerk/expo": 4.2.0 "@clerk/react": 6.14.4 "@clerk/shared": 4.29.2 @@ -56,7 +56,8 @@ catalog: minimumReleaseAgeExclude: - "@clerk/backend@3.14.0" - "@clerk/clerk-js@6.29.2" - - "@clerk/electron@0.0.33" + - "@clerk/electron@0.0.34-canary.v20260819050620" + - "@clerk/electron-passkeys@0.0.4-canary.v20260819050620" - "@clerk/expo@4.2.0" - "@clerk/react@6.14.4" - "@clerk/shared@4.29.2" @@ -78,6 +79,10 @@ minimumReleaseAgeExclude: - alchemy@2.0.0-beta.70 - effect@4.0.0-beta.103 - "@legendapp/list@3.3.5" + - "@clerk/electron-passkeys-darwin-arm64@0.0.4-canary.v20260819050620" + - "@clerk/electron-passkeys-darwin-x64@0.0.4-canary.v20260819050620" + - "@clerk/electron-passkeys-win32-arm64-msvc@0.0.4-canary.v20260819050620" + - "@clerk/electron-passkeys-win32-x64-msvc@0.0.4-canary.v20260819050620" overrides: "@clerk/backend": "catalog:"