diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 9b5ed56d1f3..c51a815388f 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -31,7 +31,7 @@ const makeDesktopClerkLayer = (isDevelopment = true) => { isDevelopment, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); - return DesktopClerk.layer.pipe( + return DesktopClerk.makeDesktopClerkLayer(true).pipe( Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)), ); }; @@ -53,6 +53,26 @@ describe("DesktopClerk", () => { assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname("invalid"), undefined); }); + it.effect("skips acquiring the SDK bridge when Clerk is disabled", () => { + const environment = DesktopEnvironment.DesktopEnvironment.of({ + stateDir: "/tmp/t3-state", + isDevelopment: true, + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + + return Effect.gen(function* () { + yield* Effect.scoped( + Layer.build( + DesktopClerk.makeDesktopClerkLayer(false).pipe( + Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)), + ), + ), + ); + + assert.deepEqual(storageMock.mock.calls, []); + assert.deepEqual(createClerkBridgeMock.mock.calls, []); + }); + }); + it.effect("acquires and releases the SDK bridge with the layer", () => { const cleanup = vi.fn(); storageMock.mockReturnValue(storageAdapter); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 0e283f8dd0c..7ed97461eeb 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -71,6 +71,8 @@ export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHos : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, ); +export const desktopClerkBridgeEnabled = Boolean(desktopClerkFrontendApiHostname); + export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { return createClerkBridge({ storage: storage({ path: stateDir }), @@ -82,54 +84,60 @@ export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolea }); } -export const make = Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - yield* Effect.acquireRelease( - Effect.try({ - try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), - catch: (cause) => - new DesktopClerkBridgeInitializationError({ - stateDir: environment.stateDir, - isDevelopment: environment.isDevelopment, - cause, +export function makeDesktopClerkLayer(enabled = desktopClerkBridgeEnabled) { + const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (enabled) { + yield* Effect.acquireRelease( + Effect.try({ + try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), + catch: (cause) => + new DesktopClerkBridgeInitializationError({ + stateDir: environment.stateDir, + isDevelopment: environment.isDevelopment, + cause, + }), }), - }), - (bridge) => - Effect.try({ - try: () => bridge.cleanup(), - catch: (cause) => - new DesktopClerkBridgeCleanupError({ - stateDir: environment.stateDir, - isDevelopment: environment.isDevelopment, - cause, - }), - }).pipe(Effect.orDie), - ); - - return DesktopClerk.of({ - configure: Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - const electronWindow = yield* ElectronWindow.ElectronWindow; - const context = yield* Effect.context(); - const runPromise = Effect.runPromiseWith(context); - - if (!(yield* electronApp.requestSingleInstanceLock)) { - yield* electronApp.quit; - return yield* Effect.interrupt; - } - - yield* electronApp.on("second-instance", () => { - void runPromise( - Effect.gen(function* () { - const mainWindow = yield* electronWindow.currentMainOrFirst; - if (Option.isSome(mainWindow)) { - yield* electronWindow.reveal(mainWindow.value); - } - }), - ); - }); - }).pipe(Effect.withSpan("desktop.clerk.configure")), + (bridge) => + Effect.try({ + try: () => bridge.cleanup(), + catch: (cause) => + new DesktopClerkBridgeCleanupError({ + stateDir: environment.stateDir, + isDevelopment: environment.isDevelopment, + cause, + }), + }).pipe(Effect.orDie), + ); + } + + return DesktopClerk.of({ + configure: Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + if (!(yield* electronApp.requestSingleInstanceLock)) { + yield* electronApp.quit; + return yield* Effect.interrupt; + } + + yield* electronApp.on("second-instance", () => { + void runPromise( + Effect.gen(function* () { + const mainWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(mainWindow)) { + yield* electronWindow.reveal(mainWindow.value); + } + }), + ); + }); + }).pipe(Effect.withSpan("desktop.clerk.configure")), + }); }); -}); -export const layer = Layer.effect(DesktopClerk, make); + return Layer.effect(DesktopClerk, make); +} + +export const layer = makeDesktopClerkLayer(); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 56fe009fee2..406873856da 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -1,35 +1,83 @@ import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { beforeEach, vi } from "vite-plus/test"; -const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({ +const { handleMock, registerSchemesAsPrivilegedMock, unhandleMock } = vi.hoisted(() => ({ handleMock: vi.fn(), - netFetchMock: vi.fn(), + registerSchemesAsPrivilegedMock: vi.fn(), unhandleMock: vi.fn(), })); vi.mock("electron", () => ({ - net: { fetch: netFetchMock }, - protocol: { handle: handleMock, unhandle: unhandleMock }, + protocol: { + handle: handleMock, + registerSchemesAsPrivileged: registerSchemesAsPrivilegedMock, + unhandle: unhandleMock, + }, })); import * as ElectronProtocol from "./ElectronProtocol.ts"; +function makeHttpClientLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ); +} + +const unexpectedHttpClientLayer = makeHttpClientLayer(() => + Effect.die("unexpected Electron protocol proxy request"), +); + describe("ElectronProtocol", () => { beforeEach(() => { handleMock.mockReset(); - netFetchMock.mockReset(); + registerSchemesAsPrivilegedMock.mockReset(); unhandleMock.mockReset(); }); + it("registers the desktop scheme as a secure standard scheme", () => { + ElectronProtocol.registerDesktopSchemePrivileges(true); + + assert.deepEqual(registerSchemesAsPrivilegedMock.mock.calls, [ + [ + [ + { + scheme: "t3code-dev", + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + stream: true, + }, + }, + ], + ], + ]); + }); + it.effect("proxies the stable renderer origin to the current app server", () => Effect.gen(function* () { let handler: ((request: Request) => Promise) | undefined; + const requestUrls: string[] = []; handleMock.mockImplementation((_scheme, nextHandler) => { handler = nextHandler; }); - netFetchMock.mockResolvedValue(new Response("ok")); + const httpClientLayer = makeHttpClientLayer((request) => + Effect.gen(function* () { + const webRequest = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie); + requestUrls.push(webRequest.url); + return HttpClientResponse.fromWeb(request, new Response("ok")); + }), + ); yield* Effect.scoped( Effect.gen(function* () { @@ -63,23 +111,79 @@ describe("ElectronProtocol", () => { "font-src 'self' t3code-dev: data:", ); }), - ); + ).pipe(Effect.provide(ElectronProtocol.layer.pipe(Layer.provide(httpClientLayer)))); assert.deepEqual( handleMock.mock.calls.map((call) => call[0]), ["t3code-dev"], ); - assert.equal(netFetchMock.mock.calls[0]?.[0], "http://127.0.0.1:3773/api/health?verbose=1"); + assert.equal(requestUrls[0], "http://127.0.0.1:3773/api/health?verbose=1"); assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }), + ); + + it.effect("returns proxied response bodies without buffering them first", () => + Effect.gen(function* () { + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + const chunk = new TextEncoder().encode("streamed"); + const httpClientLayer = makeHttpClientLayer((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(chunk); + }, + }), + ), + ), + ), + ); + + const response = yield* Effect.scoped( + Effect.gen(function* () { + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code-dev", + targetOrigin: new URL("http://127.0.0.1:3773/"), + backendOrigin: new URL("http://127.0.0.1:3774/"), + clerkFrontendApiHostname: undefined, + }); + assert.isDefined(handler); + return yield* Effect.raceFirst( + Effect.promise(() => handler!(new Request("t3code-dev://app/large-asset"))), + Effect.sleep("250 millis").pipe( + Effect.andThen(Effect.die(new Error("proxy response was buffered"))), + ), + ); + }), + ).pipe(Effect.provide(ElectronProtocol.layer.pipe(Layer.provide(httpClientLayer)))); + + const reader = response.body?.getReader(); + assert.isDefined(reader); + const firstChunk = yield* Effect.promise(() => reader!.read()); + assert.deepEqual(firstChunk.value, chunk); + yield* Effect.promise(() => reader!.cancel()); + }), ); it.effect("rejects custom protocol requests for another host", () => Effect.gen(function* () { let handler: ((request: Request) => Promise) | undefined; + const requests: HttpClientRequest.HttpClientRequest[] = []; handleMock.mockImplementation((_scheme, nextHandler) => { handler = nextHandler; }); + const httpClientLayer = makeHttpClientLayer((request) => + Effect.sync(() => { + requests.push(request); + return HttpClientResponse.fromWeb(request, new Response("unexpected")); + }), + ); const response = yield* Effect.scoped( Effect.gen(function* () { @@ -92,11 +196,11 @@ describe("ElectronProtocol", () => { }); return yield* Effect.promise(() => handler!(new Request("t3code://other/"))); }), - ); + ).pipe(Effect.provide(ElectronProtocol.layer.pipe(Layer.provide(httpClientLayer)))); assert.equal(response.status, 404); - assert.equal(netFetchMock.mock.calls.length, 0); - }).pipe(Effect.provide(ElectronProtocol.layer)), + assert.equal(requests.length, 0); + }), ); it.effect("preserves protocol registration failures", () => @@ -120,7 +224,7 @@ describe("ElectronProtocol", () => { assert.equal(error.scheme, "t3code-dev"); assert.strictEqual(error.cause, cause); assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".'); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(ElectronProtocol.layer.pipe(Layer.provide(unexpectedHttpClientLayer)))), ); it.effect("preserves protocol unregistration failures", () => @@ -150,7 +254,7 @@ describe("ElectronProtocol", () => { assert.strictEqual(error.cause, cause); assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".'); } - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(ElectronProtocol.layer.pipe(Layer.provide(unexpectedHttpClientLayer)))), ); it("keeps executable sources host-restricted while allowing runtime network resources", () => { diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 757c26178d0..81d773bac04 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -4,6 +4,8 @@ import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import * as Electron from "electron"; @@ -23,6 +25,21 @@ export function getDesktopUrl(isDevelopment: boolean): string { return `${getDesktopOrigin(isDevelopment)}/`; } +export function registerDesktopSchemePrivileges(isDevelopment: boolean): void { + Electron.protocol.registerSchemesAsPrivileged([ + { + scheme: getDesktopScheme(isDevelopment), + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + stream: true, + }, + }, + ]); +} + export class ElectronProtocolRegistrationError extends Schema.TaggedErrorClass()( "ElectronProtocolRegistrationError", { @@ -103,10 +120,33 @@ function withContentSecurityPolicy(response: Response, policy: string): Response }); } +const hopByHopRequestHeaders = new Set([ + "connection", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +function makeProxyRequestHeaders(requestHeaders: Headers): Headers { + const headers = new Headers(); + for (const [name, value] of requestHeaders) { + if (!hopByHopRequestHeaders.has(name.toLowerCase())) { + headers.set(name, value); + } + } + return headers; +} + async function proxyRequest( request: Request, targetOrigin: URL, contentSecurityPolicy: string, + httpClient: HttpClient.HttpClient, ): Promise { const requestUrl = new URL(request.url); if (requestUrl.host !== DESKTOP_HOST) { @@ -115,19 +155,36 @@ async function proxyRequest( const targetUrl = new URL(`${requestUrl.pathname}${requestUrl.search}`, targetOrigin); const init: RequestInit = { + cache: "no-store", method: request.method, - headers: request.headers, + headers: makeProxyRequestHeaders(request.headers), }; if (request.method !== "GET" && request.method !== "HEAD") { init.body = request.body; (init as RequestInit & { duplex: "half" }).duplex = "half"; } - const response = await Electron.net.fetch(targetUrl.toString(), init); - return withContentSecurityPolicy(response, contentSecurityPolicy); + + const effect = Effect.gen(function* () { + const clientRequest = HttpClientRequest.fromWeb(new Request(targetUrl, init)); + const clientResponse = yield* httpClient.execute(clientRequest); + return withContentSecurityPolicy( + new Response( + request.method === "HEAD" ? null : Stream.toReadableStream(clientResponse.stream), + { + status: clientResponse.status, + headers: clientResponse.headers, + }, + ), + contentSecurityPolicy, + ); + }); + + return Effect.runPromise(effect); } export const make = Effect.gen(function* () { const registered = yield* Ref.make(false); + const httpClient = yield* HttpClient.HttpClient; const registerDesktopProtocol = Effect.fn("desktop.electron.protocol.registerDesktopProtocol")( function* (input: DesktopProtocolRegistrationInput) { @@ -139,7 +196,7 @@ export const make = Effect.gen(function* () { Effect.try({ try: () => { Electron.protocol.handle(input.scheme, (request) => - proxyRequest(request, input.targetOrigin, contentSecurityPolicy), + proxyRequest(request, input.targetOrigin, contentSecurityPolicy, httpClient), ); }, catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }), diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b88eb18e57f..b307e858771 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -51,6 +51,12 @@ import * as BrowserSession from "./preview/BrowserSession.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; +const isDesktopDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); + +if (!DesktopClerk.desktopClerkBridgeEnabled) { + ElectronProtocol.registerDesktopSchemePrivileges(isDesktopDevelopment); +} + const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( @@ -105,7 +111,7 @@ const electronLayer = Layer.mergeAll( ElectronApp.layer, ElectronDialog.layer, ElectronMenu.layer, - ElectronProtocol.layer, + ElectronProtocol.layer.pipe(Layer.provide(NodeHttpClient.layerUndici)), ElectronSafeStorage.layer, ElectronShell.layer, ElectronTheme.layer, diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index ced16c15f4e..a6e758ea52d 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -186,6 +186,25 @@ describe("resolveInitialServerAuthGateState", () => { ); }); + it("uses the vite proxy for configured loopback auth requests during local dev", async () => { + await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) }); + vi.stubEnv("VITE_HTTP_URL", "http://localhost:13773"); + vi.stubEnv("VITE_WS_URL", "ws://localhost:13773"); + vi.stubEnv("VITE_DEV_SERVER_URL", "http://localhost:5733"); + installTestBrowser("http://localhost:5733/"); + + const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } = + await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: LOOPBACK_AUTH, + }); + expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe( + "http://localhost:5733/api/auth/session", + ); + }); + it("uses the vite proxy for desktop-managed loopback auth requests during local dev", async () => { await installAuthApi({ session: () => unauthenticatedSession(DESKTOP_AUTH) }); vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733"); @@ -211,6 +230,23 @@ describe("resolveInitialServerAuthGateState", () => { ); }); + it("uses the desktop custom scheme proxy for desktop-managed auth requests", async () => { + const testWindow = installTestBrowser("t3code-dev://app/"); + testWindow.desktopBridge = { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://127.0.0.1:3773", + wsBaseUrl: "ws://127.0.0.1:3773", + }), + } as DesktopBridge; + + const { resolvePrimaryEnvironmentHttpUrl } = await import("./environments/primary"); + + expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe( + "t3code-dev://app/api/auth/session", + ); + }); + it("returns a requires-auth state instead of throwing when no bootstrap credential exists", async () => { await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) }); const { resolveInitialServerAuthGateState } = await import("./environments/primary"); @@ -361,6 +397,28 @@ describe("resolveInitialServerAuthGateState", () => { expect(error.message).not.toContain(cause.message); }); + it("accepts an already-established session after a duplicate one-time token submit", async () => { + const testWindow = installTestBrowser("http://localhost/pair#token=already-used-token"); + const testApi = await installAuthApi({ + session: () => authenticatedSession(LOOPBACK_AUTH), + browserSession: () => + Effect.fail( + new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-invalid-credential", + }), + ), + }); + + const { submitServerAuthCredential } = await import("./environments/primary"); + + await expect(submitServerAuthCredential("already-used-token")).resolves.toBeUndefined(); + expect(testWindow.location.hash).toBe(""); + expect(testApi.calls.browserSession).toEqual([{ credential: "already-used-token" }]); + expect(testApi.calls.session).toBe(1); + }); + it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => { vi.useFakeTimers(); const nextSession = sequence( diff --git a/apps/web/src/cloud/ConfiguredCloudAuthRoot.tsx b/apps/web/src/cloud/ConfiguredCloudAuthRoot.tsx new file mode 100644 index 00000000000..955c2509959 --- /dev/null +++ b/apps/web/src/cloud/ConfiguredCloudAuthRoot.tsx @@ -0,0 +1,25 @@ +import { ClerkProvider } from "@clerk/react"; +import { passkeys } from "@clerk/electron/passkeys"; +import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react"; +import type { ReactNode } from "react"; + +import { isElectron } from "../env"; +import { ManagedRelayAuthProvider } from "./managedAuth"; + +export default function ConfiguredCloudAuthRoot({ + children, + publishableKey, +}: { + readonly children: ReactNode; + readonly publishableKey: string; +}) { + return isElectron ? ( + + {children} + + ) : ( + + {children} + + ); +} diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index 3ec748c6bcb..8228a9f1fcb 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -1,5 +1,4 @@ import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react"; -import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; import * as Schema from "effect/Schema"; import { useEffect, useMemo, type ReactNode } from "react"; import { useTheme } from "../hooks/useTheme"; @@ -15,6 +14,12 @@ export class DiffWorkerError extends Schema.TaggedErrorClass()( } } +function createDiffsWorker() { + return new Worker(new URL("@pierre/diffs/worker/worker-portable.js", import.meta.url), { + type: "module", + }); +} + function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { const workerPool = useWorkerPool(); @@ -59,7 +64,7 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { poolOptions={{ workerFactory: () => { try { - return new DiffsWorker(); + return createDiffsWorker(); } catch (cause) { throw new DiffWorkerError({ operation: "create-worker", diff --git a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.configured.tsx b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.configured.tsx new file mode 100644 index 00000000000..4ebb2e4cb74 --- /dev/null +++ b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.configured.tsx @@ -0,0 +1,47 @@ +import { UserButton, useAuth } from "@clerk/react"; +import { LogInIcon } from "lucide-react"; + +import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; +import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; + +export function ConfiguredT3ConnectSidebarAvatar() { + const { isLoaded, isSignedIn } = useAuth(); + + if (!isLoaded || !isSignedIn) return null; + + return ( + + ); +} + +export function ConfiguredT3ConnectSidebarSignIn() { + const { isLoaded, isSignedIn } = useAuth(); + const { authPrompt, openAuthPrompt } = useT3ConnectAuthPrompt(); + + if (!isLoaded || isSignedIn) return null; + + return ( + <> + + + + + Sign in to T3 Connect + + + + {authPrompt} + + ); +} diff --git a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx index d3f906ef414..cf1c0d0d21d 100644 --- a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx +++ b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx @@ -1,60 +1,35 @@ -import { UserButton, useAuth } from "@clerk/react"; -import { LogInIcon } from "lucide-react"; +import { lazy, Suspense } from "react"; import { hasCloudPublicConfig } from "../../cloud/publicConfig"; -import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; -import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; -export function T3ConnectSidebarSignIn() { - if (!hasCloudPublicConfig()) return null; +const ConfiguredT3ConnectSidebarSignIn = lazy(() => + import("./T3ConnectSidebarSignIn.configured").then((module) => ({ + default: module.ConfiguredT3ConnectSidebarSignIn, + })), +); - return ; -} +const ConfiguredT3ConnectSidebarAvatar = lazy(() => + import("./T3ConnectSidebarSignIn.configured").then((module) => ({ + default: module.ConfiguredT3ConnectSidebarAvatar, + })), +); -export function T3ConnectSidebarAvatar() { +export function T3ConnectSidebarSignIn() { if (!hasCloudPublicConfig()) return null; - return ; -} - -function ConfiguredT3ConnectSidebarAvatar() { - const { isLoaded, isSignedIn } = useAuth(); - - if (!isLoaded || !isSignedIn) return null; - return ( - + + + ); } -function ConfiguredT3ConnectSidebarSignIn() { - const { isLoaded, isSignedIn } = useAuth(); - const { authPrompt, openAuthPrompt } = useT3ConnectAuthPrompt(); - - if (!isLoaded || isSignedIn) return null; +export function T3ConnectSidebarAvatar() { + if (!hasCloudPublicConfig()) return null; return ( - <> - - - - - Sign in to T3 Connect - - - - {authPrompt} - + + + ); } diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 96814b92b79..23259def1b0 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -150,6 +150,10 @@ type ServerAuthGateState = let bootstrapPromise: Promise | null = null; let resolvedAuthenticatedGateState: ServerAuthGateState | null = null; +let credentialSubmitPromise: { + readonly credential: string; + readonly promise: Promise; +} | null = null; const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000; const AUTH_SESSION_ESTABLISH_STEP_MS = 100; @@ -346,8 +350,50 @@ export async function submitServerAuthCredential(credential: string): Promise { + if (resolvedAuthenticatedGateState?.status === "authenticated") { + bootstrapPromise = null; + stripPairingTokenFromUrl(); + return; + } resolvedAuthenticatedGateState = null; - await exchangeBootstrapCredential(trimmedCredential); + try { + await exchangeBootstrapCredential(trimmedCredential); + await waitForAuthenticatedSessionAfterBootstrap(); + } catch (error) { + if ( + (isPrimaryEnvironmentRequestError(error) && error.status === 401) || + isPrimaryEnvironmentPairingCredentialRejectedError(error) + ) { + const currentSession = await fetchSessionState().catch(() => null); + if (currentSession?.authenticated) { + resolvedAuthenticatedGateState = { status: "authenticated" }; + bootstrapPromise = null; + stripPairingTokenFromUrl(); + return; + } + } + throw error; + } + resolvedAuthenticatedGateState = { status: "authenticated" }; bootstrapPromise = null; stripPairingTokenFromUrl(); } @@ -529,4 +575,5 @@ export async function resolveInitialServerAuthGateState(): Promise { ); }); + it("uses the vite proxy for configured loopback descriptor requests during local dev", async () => { + vi.stubEnv("VITE_HTTP_URL", "http://localhost:13773"); + vi.stubEnv("VITE_WS_URL", "ws://localhost:13773"); + vi.stubEnv("VITE_DEV_SERVER_URL", "http://localhost:5733"); + installTestBrowser("http://localhost:5733/"); + await installDescriptorApi(); + + await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT); + expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe( + "http://localhost:5733/.well-known/t3/environment", + ); + expect(getPrimaryKnownEnvironment()?.target).toEqual({ + httpBaseUrl: "http://localhost:5733/", + wsBaseUrl: "ws://localhost:13773/", + }); + }); + it("uses the vite proxy for desktop-managed loopback descriptor requests during local dev", async () => { vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733"); vi.stubGlobal("window", { @@ -251,4 +268,30 @@ describe("environmentBootstrap", () => { message: "The window-origin primary environment target uses unsupported protocol file:.", }); }); + + it("uses the desktop custom scheme proxy for desktop-managed descriptor requests", async () => { + writePrimaryEnvironmentDescriptor(BASE_ENVIRONMENT); + vi.stubGlobal("window", { + location: new URL("t3code-dev://app/"), + history: { + replaceState: vi.fn(), + }, + desktopBridge: { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://127.0.0.1:3773", + wsBaseUrl: "ws://127.0.0.1:3773", + bootstrapToken: "desktop-bootstrap-token", + }), + }, + }); + + expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe( + "t3code-dev://app/.well-known/t3/environment", + ); + expect(getPrimaryKnownEnvironment()?.target).toEqual({ + httpBaseUrl: "t3code-dev://app/", + wsBaseUrl: "ws://127.0.0.1:3773/", + }); + }); }); diff --git a/apps/web/src/environments/primary/httpLayer.test.ts b/apps/web/src/environments/primary/httpLayer.test.ts index 5bc1ef01da1..292f53a6c34 100644 --- a/apps/web/src/environments/primary/httpLayer.test.ts +++ b/apps/web/src/environments/primary/httpLayer.test.ts @@ -35,6 +35,31 @@ describe.sequential("primary environment HTTP layer", () => { }).pipe(Effect.provide(makePrimaryEnvironmentHttpLayer())); }); + it.effect("uses cookie credentials for vite-proxied configured loopback environments", () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubEnv("VITE_HTTP_URL", "http://localhost:13773"); + vi.stubEnv("VITE_WS_URL", "ws://localhost:13773"); + vi.stubEnv("VITE_DEV_SERVER_URL", "http://localhost:5733"); + vi.stubGlobal("fetch", fetchMock); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: { + href: "http://localhost:5733/pair", + origin: "http://localhost:5733", + }, + }, + }); + + return Effect.gen(function* () { + yield* HttpClient.get("http://localhost:5733/api/auth/session"); + + const request = new Request(fetchMock.mock.calls[0]?.[0], fetchMock.mock.calls[0]?.[1]); + expect(request.credentials).toBe("include"); + expect(request.headers.get("authorization")).toBeNull(); + }).pipe(Effect.provide(makePrimaryEnvironmentHttpLayer())); + }); + it.effect("uses bearer auth without cookies for desktop-managed primaries", () => { const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); vi.stubGlobal("fetch", fetchMock); diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index a14a99ec4dc..a38bf824a12 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -98,6 +98,10 @@ function parseTargetUrl(input: { } } +function currentWindowBaseUrl(): string { + return window.location.origin === "null" ? window.location.href : window.location.origin; +} + function normalizeBaseUrl( rawValue: string, source: PrimaryEnvironmentTargetSource, @@ -105,7 +109,7 @@ function normalizeBaseUrl( ): string { return parseTargetUrl({ rawValue, - baseUrl: window.location.origin, + baseUrl: currentWindowBaseUrl(), source, urlKind, }).toString(); @@ -137,13 +141,16 @@ export function isLoopbackHostname(hostname: string): boolean { return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname)); } +function effectiveUrlOrigin(url: URL): string { + return url.origin === "null" ? `${url.protocol}//${url.host}` : url.origin; +} + +function originBaseUrl(origin: string): string { + return origin.endsWith("/") ? origin : `${origin}/`; +} + function resolveHttpRequestBaseUrl(primaryTarget: PrimaryEnvironmentTarget): string { const httpBaseUrl = primaryTarget.target.httpBaseUrl; - const configuredDevServerUrl = import.meta.env.VITE_DEV_SERVER_URL?.trim(); - if (!configuredDevServerUrl) { - return httpBaseUrl; - } - const currentUrl = parseTargetUrl({ rawValue: window.location.href, source: "window-origin", @@ -154,27 +161,38 @@ function resolveHttpRequestBaseUrl(primaryTarget: PrimaryEnvironmentTarget): str source: primaryTarget.source, urlKind: "http-base-url", }); + const currentOrigin = effectiveUrlOrigin(currentUrl); + const isCurrentOriginDesktopDevApp = + currentUrl.protocol === "t3code-dev:" && currentUrl.host === "app"; + + if (currentOrigin === targetUrl.origin || !isLoopbackHostname(targetUrl.hostname)) { + return httpBaseUrl; + } + + if (isCurrentOriginDesktopDevApp) { + return originBaseUrl(currentOrigin); + } + + const configuredDevServerUrl = import.meta.env.VITE_DEV_SERVER_URL?.trim(); + if (!configuredDevServerUrl) { + return httpBaseUrl; + } + const devServerUrl = parseTargetUrl({ rawValue: configuredDevServerUrl, - baseUrl: currentUrl.origin, + baseUrl: originBaseUrl(currentOrigin), source: "configured", urlKind: "development-server-url", }); const isCurrentOriginDevServer = (currentUrl.protocol === "http:" || currentUrl.protocol === "https:") && - currentUrl.origin === devServerUrl.origin; - - if ( - !isCurrentOriginDevServer || - currentUrl.origin === targetUrl.origin || - !isLoopbackHostname(currentUrl.hostname) || - !isLoopbackHostname(targetUrl.hostname) - ) { + currentOrigin === devServerUrl.origin; + if (!isCurrentOriginDevServer || !isLoopbackHostname(currentUrl.hostname)) { return httpBaseUrl; } - return currentUrl.origin; + return originBaseUrl(currentOrigin); } function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { @@ -199,7 +217,13 @@ function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { return { source: "configured", target: { - httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl, "configured", "http-base-url"), + httpBaseUrl: resolveHttpRequestBaseUrl({ + source: "configured", + target: { + httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl, "configured", "http-base-url"), + wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl, "configured", "websocket-base-url"), + }, + }), wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl, "configured", "websocket-base-url"), }, }; @@ -249,11 +273,21 @@ function resolveDesktopPrimaryTarget(): PrimaryEnvironmentTarget | null { return { source: "desktop-managed", target: { - httpBaseUrl: normalizeBaseUrl( - desktopBootstrap.httpBaseUrl, - "desktop-managed", - "http-base-url", - ), + httpBaseUrl: resolveHttpRequestBaseUrl({ + source: "desktop-managed", + target: { + httpBaseUrl: normalizeBaseUrl( + desktopBootstrap.httpBaseUrl, + "desktop-managed", + "http-base-url", + ), + wsBaseUrl: normalizeBaseUrl( + desktopBootstrap.wsBaseUrl, + "desktop-managed", + "websocket-base-url", + ), + }, + }), wsBaseUrl: normalizeBaseUrl( desktopBootstrap.wsBaseUrl, "desktop-managed", diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 453649bfdc5..5caf960dcfb 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,8 +1,5 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { ClerkProvider } from "@clerk/react"; -import { passkeys } from "@clerk/electron/passkeys"; -import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react"; import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; import "@fontsource-variable/dm-sans/index.css"; @@ -12,7 +9,6 @@ import "@xterm/xterm/css/xterm.css"; import "./index.css"; import { isElectron } from "./env"; -import { ManagedRelayAuthProvider } from "./cloud/managedAuth"; import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter } from "./router"; import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; @@ -28,21 +24,18 @@ if (isElectron) { } const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; +const ConfiguredCloudAuthRoot = React.lazy(() => import("./cloud/ConfiguredCloudAuthRoot")); const app = ; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( {clerkPublishableKey && hasCloudPublicConfig() ? ( - isElectron ? ( - - {app} - - ) : ( - - {app} - - ) + + + {app} + + ) : ( app )} diff --git a/apps/web/src/routes/settings.connections.tsx b/apps/web/src/routes/settings.connections.tsx index 275eda6c516..e1490d19f3b 100644 --- a/apps/web/src/routes/settings.connections.tsx +++ b/apps/web/src/routes/settings.connections.tsx @@ -1,7 +1,20 @@ import { createFileRoute } from "@tanstack/react-router"; +import { lazy, Suspense } from "react"; -import { ConnectionsSettings } from "../components/settings/ConnectionsSettings"; +const ConnectionsSettings = lazy(() => + import("../components/settings/ConnectionsSettings").then((module) => ({ + default: module.ConnectionsSettings, + })), +); + +function ConnectionsSettingsRoute() { + return ( + + + + ); +} export const Route = createFileRoute("/settings/connections")({ - component: ConnectionsSettings, + component: ConnectionsSettingsRoute, }); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 8f984c850dc..ba532796537 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -14,6 +14,7 @@ Object.assign(process.env, repoEnv); const port = Number(process.env.PORT ?? 5733); const host = process.env.HOST?.trim() || "localhost"; +const configuredDevServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); const configuredWsUrl = process.env.VITE_WS_URL?.trim(); const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; @@ -103,7 +104,6 @@ export default defineConfig(() => { "@pierre/diffs", "@pierre/diffs/editor", "@pierre/diffs/react", - "@pierre/diffs/worker/worker.js", "effect/Array", "effect/Order", "react-dom/client", @@ -111,6 +111,7 @@ export default defineConfig(() => { }, define: { // In dev mode, tell the web app where the WebSocket server lives + "import.meta.env.VITE_DEV_SERVER_URL": JSON.stringify(configuredDevServerUrl ?? ""), "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl), "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey), @@ -156,6 +157,7 @@ export default defineConfig(() => { // connection logs — enable "Verbose" in DevTools to see them. protocol: "ws", host, + clientPort: port, }, }, build: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 192723b7663..2297e65c9e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,7 +58,6 @@ overrides: '@effect/vitest>@vitest/runner': '-' '@effect/vitest>vitest': '-' '@expo/metro-config': 56.0.13 - '@pierre/diffs>@shikijs/transformers': ^4.2.0 '@types/node': 24.12.4 effect: 4.0.0-beta.78 vite: npm:@voidzero-dev/vite-plus-core@0.1.24 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 03960dfbbdd..b3900ebfa8d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -73,7 +73,6 @@ overrides: "@effect/vitest>@vitest/runner": "-" "@effect/vitest>vitest": "-" "@expo/metro-config": 56.0.13 - "@pierre/diffs>@shikijs/transformers": ^4.2.0 "@types/node": "catalog:" effect: "catalog:" vite: "catalog:"