diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 53c17c06402..c0713bfc059 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -290,22 +290,38 @@ describe("resolveInitialServerAuthGateState", () => { }); it("surfaces a friendly error message when an invalid pairing token is submitted", async () => { + const cause = new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-invalid-credential", + }); const testApi = await installAuthApi({ - browserSession: () => - Effect.fail( - new EnvironmentAuthInvalidError({ - code: "auth_invalid", - reason: "invalid_credential", - traceId: "trace-invalid-credential", - }), - ), + browserSession: () => Effect.fail(cause), }); - const { submitServerAuthCredential } = await import("./environments/primary"); + const { isPrimaryEnvironmentRequestError, submitServerAuthCredential } = + await import("./environments/primary"); - await expect(submitServerAuthCredential("bad-token")).rejects.toThrow( - "Invalid pairing token. Check the token and try again.", + const error = await submitServerAuthCredential("bad-token").then( + () => null, + (failure: unknown) => failure, ); + expect(error).toMatchObject({ + _tag: "PrimaryEnvironmentRequestError", + operation: "exchange-bootstrap-credential", + status: 401, + detail: "Invalid pairing token. Check the token and try again.", + }); + expect(isPrimaryEnvironmentRequestError(error)).toBe(true); + if (!isPrimaryEnvironmentRequestError(error)) { + throw new Error("Expected a structured primary environment request error."); + } + expect(error.cause).toMatchObject({ + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-invalid-credential", + }); expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]); }); diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index f6f07dbb303..5cf7d2d34b7 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -21,15 +21,57 @@ import { import { PrimaryEnvironmentHttpClient } from "./httpClient"; import { runPrimaryHttp } from "../../lib/runtime"; -import * as Data from "effect/Data"; -import * as Predicate from "effect/Predicate"; - -export class BootstrapHttpError extends Data.TaggedError("BootstrapHttpError")<{ - readonly message: string; - readonly status: number; -}> {} -const isBootstrapHttpError = (u: unknown): u is BootstrapHttpError => - Predicate.isTagged(u, "BootstrapHttpError"); + +const PrimaryEnvironmentRequestOperation = Schema.Literals([ + "fetch-session-state", + "exchange-bootstrap-credential", + "fetch-environment-descriptor", + "create-pairing-credential", + "list-pairing-links", + "revoke-pairing-link", + "list-client-sessions", + "revoke-client-session", + "revoke-other-client-sessions", +]); +type PrimaryEnvironmentRequestOperation = typeof PrimaryEnvironmentRequestOperation.Type; + +export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentRequestError", + { + operation: PrimaryEnvironmentRequestOperation, + status: Schema.Number, + detail: Schema.String, + pairingLinkId: Schema.optional(Schema.String), + sessionId: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + static fromCause(input: { + readonly operation: PrimaryEnvironmentRequestOperation; + readonly cause: unknown; + readonly fallbackMessage: (status: number) => string; + readonly formatDetail?: (detail: string, status: number) => string; + readonly pairingLinkId?: string; + readonly sessionId?: string; + }): PrimaryEnvironmentRequestError { + const status = readHttpApiStatus(input.cause) ?? 500; + const rawDetail = readHttpApiErrorMessage(input.cause, input.fallbackMessage(status)); + return new PrimaryEnvironmentRequestError({ + operation: input.operation, + status, + detail: input.formatDetail?.(rawDetail, status) ?? rawDetail, + ...(input.pairingLinkId !== undefined ? { pairingLinkId: input.pairingLinkId } : {}), + ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}), + cause: input.cause, + }); + } + + override get message(): string { + return this.detail; + } +} + +export const isPrimaryEnvironmentRequestError = Schema.is(PrimaryEnvironmentRequestError); const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); export interface ServerPairingLinkRecord { @@ -106,10 +148,10 @@ export async function fetchSessionState(): Promise { ), ); } catch (error) { - const status = readHttpApiStatus(error); - throw new BootstrapHttpError({ - message: `Failed to load server auth session state (${status ?? "unknown"}).`, - status: status ?? 500, + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "fetch-session-state", + cause: error, + fallbackMessage: (status) => `Failed to load server auth session state (${status}).`, }); } }); @@ -183,11 +225,11 @@ async function exchangeBootstrapCredential(credential: string): Promise `Failed to bootstrap auth session (${status}).`, + formatDetail: (detail, status) => toFriendlyBootstrapErrorMessage(status, detail), }); } }); @@ -240,7 +282,7 @@ function waitForBootstrapRetry(delayMs: number): Promise { } function isTransientBootstrapError(error: unknown): boolean { - if (isBootstrapHttpError(error)) { + if (isPrimaryEnvironmentRequestError(error)) { return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status); } @@ -310,13 +352,11 @@ export async function createServerPairingCredential(input?: { ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to create pairing credential (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "create-pairing-credential", + cause: error, + fallbackMessage: (status) => `Failed to create pairing credential (${status}).`, + }); } } @@ -353,13 +393,11 @@ export async function listServerPairingLinks(): Promise `Failed to load pairing links (${status}).`, + }); } } @@ -371,13 +409,12 @@ export async function revokeServerPairingLink(id: string): Promise { ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke pairing link (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-pairing-link", + pairingLinkId: id, + cause: error, + fallbackMessage: (status) => `Failed to revoke pairing link (${status}).`, + }); } } @@ -406,13 +443,11 @@ export async function listServerClientSessions(): Promise< current: clientSession.current, })); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to load paired clients (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "list-client-sessions", + cause: error, + fallbackMessage: (status) => `Failed to load paired clients (${status}).`, + }); } } @@ -426,13 +461,12 @@ export async function revokeServerClientSession(sessionId: AuthSessionId): Promi ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke client session (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-client-session", + sessionId, + cause: error, + fallbackMessage: (status) => `Failed to revoke client session (${status}).`, + }); } } @@ -445,13 +479,11 @@ export async function revokeOtherServerClientSessions(): Promise { ); return result.revokedCount; } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke other client sessions (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-other-client-sessions", + cause: error, + fallbackMessage: (status) => `Failed to revoke other client sessions (${status}).`, + }); } } diff --git a/apps/web/src/environments/primary/bootstrap.test.ts b/apps/web/src/environments/primary/bootstrap.test.ts index f3a5c2678ac..e8333c2e078 100644 --- a/apps/web/src/environments/primary/bootstrap.test.ts +++ b/apps/web/src/environments/primary/bootstrap.test.ts @@ -4,6 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { getPrimaryKnownEnvironment, + isDesktopEnvironmentBootstrapIncompleteError, + isPrimaryEnvironmentProtocolUnsupportedError, + isPrimaryEnvironmentUrlInvalidError, + readPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl, resolveInitialPrimaryEnvironmentDescriptor, resetPrimaryEnvironmentDescriptorForTests, @@ -43,6 +47,15 @@ function installTestBrowser(url: string) { }); } +function captureThrown(run: () => unknown): unknown { + try { + run(); + } catch (error) { + return error; + } + throw new Error("Expected the operation to throw."); +} + describe("environmentBootstrap", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -175,4 +188,67 @@ describe("environmentBootstrap", () => { "http://127.0.0.1:5733/.well-known/t3/environment", ); }); + + it("retains the URL parser cause without exposing the configured URL in its message", () => { + vi.stubEnv("VITE_HTTP_URL", "http://["); + + const error = captureThrown(readPrimaryEnvironmentTarget); + + expect(isPrimaryEnvironmentUrlInvalidError(error)).toBe(true); + if (!isPrimaryEnvironmentUrlInvalidError(error)) { + throw new Error("Expected a structured primary environment URL error."); + } + expect(error).toMatchObject({ + source: "configured", + urlKind: "http-base-url", + message: "Could not parse http-base-url for the configured primary environment target.", + }); + expect(error.cause).toBeInstanceOf(TypeError); + expect(error.message).not.toContain("http://["); + }); + + it("describes which desktop bootstrap endpoint is missing", () => { + vi.stubGlobal("window", { + location: new URL("http://127.0.0.1:5733/"), + history: { replaceState: vi.fn() }, + desktopBridge: { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://127.0.0.1:3773", + bootstrapToken: "desktop-bootstrap-token", + }), + }, + }); + + const error = captureThrown(readPrimaryEnvironmentTarget); + + expect(isDesktopEnvironmentBootstrapIncompleteError(error)).toBe(true); + if (!isDesktopEnvironmentBootstrapIncompleteError(error)) { + throw new Error("Expected a structured desktop bootstrap error."); + } + expect(error).toMatchObject({ + hasHttpBaseUrl: true, + hasWsBaseUrl: false, + message: "Desktop bootstrap is missing wsBaseUrl for the local environment.", + }); + }); + + it("preserves an unsupported window-origin protocol", () => { + vi.stubGlobal("window", { + location: { origin: "file:///tmp/t3code/" }, + history: { replaceState: vi.fn() }, + }); + + const error = captureThrown(readPrimaryEnvironmentTarget); + + expect(isPrimaryEnvironmentProtocolUnsupportedError(error)).toBe(true); + if (!isPrimaryEnvironmentProtocolUnsupportedError(error)) { + throw new Error("Expected a structured primary environment protocol error."); + } + expect(error).toMatchObject({ + source: "window-origin", + protocol: "file:", + message: "The window-origin primary environment target uses unsupported protocol file:.", + }); + }); }); diff --git a/apps/web/src/environments/primary/context.ts b/apps/web/src/environments/primary/context.ts index eb818e8f558..40b6f68bd09 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -5,9 +5,8 @@ import { } from "@t3tools/client-runtime/environment"; import type { ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { HttpClientError } from "effect/unstable/http"; -import { BootstrapHttpError, retryTransientBootstrap } from "./auth"; +import { PrimaryEnvironmentRequestError, retryTransientBootstrap } from "./auth"; import { PrimaryEnvironmentHttpClient } from "./httpClient"; import { runPrimaryHttp } from "../../lib/runtime"; @@ -44,13 +43,10 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise client.metadata.descriptor())), ); } catch (error) { - const status = - HttpClientError.isHttpClientError(error) && error.response !== undefined - ? error.response.status - : 500; - throw new BootstrapHttpError({ - message: `Failed to load server environment descriptor (${status}).`, - status, + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "fetch-environment-descriptor", + cause: error, + fallbackMessage: (status) => `Failed to load server environment descriptor (${status}).`, }); } diff --git a/apps/web/src/environments/primary/index.ts b/apps/web/src/environments/primary/index.ts index 305ced9c905..e888560539d 100644 --- a/apps/web/src/environments/primary/index.ts +++ b/apps/web/src/environments/primary/index.ts @@ -16,9 +16,11 @@ export { export { createServerPairingCredential, fetchSessionState, + isPrimaryEnvironmentRequestError, listServerClientSessions, listServerPairingLinks, peekPairingTokenFromUrl, + PrimaryEnvironmentRequestError, resolveInitialServerAuthGateState, revokeOtherServerClientSessions, revokeServerClientSession, @@ -36,7 +38,14 @@ export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionSta export { PrimaryEnvironmentHttpClient } from "./httpClient"; export { + DesktopEnvironmentBootstrapIncompleteError, + isDesktopEnvironmentBootstrapIncompleteError, + isPrimaryEnvironmentProtocolUnsupportedError, + isPrimaryEnvironmentUrlInvalidError, + PrimaryEnvironmentProtocolUnsupportedError, + PrimaryEnvironmentUrlInvalidError, readPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname, + type PrimaryEnvironmentTarget, } from "./target"; diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index c62907d3572..a14a99ec4dc 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -1,7 +1,72 @@ import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +const PrimaryEnvironmentTargetSource = Schema.Literals([ + "configured", + "window-origin", + "desktop-managed", +]); +type PrimaryEnvironmentTargetSource = typeof PrimaryEnvironmentTargetSource.Type; + +const PrimaryEnvironmentUrlKind = Schema.Literals([ + "http-base-url", + "websocket-base-url", + "development-server-url", + "window-location-url", +]); +type PrimaryEnvironmentUrlKind = typeof PrimaryEnvironmentUrlKind.Type; + +export class PrimaryEnvironmentUrlInvalidError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentUrlInvalidError", + { + source: PrimaryEnvironmentTargetSource, + urlKind: PrimaryEnvironmentUrlKind, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not parse ${this.urlKind} for the ${this.source} primary environment target.`; + } +} + +export class PrimaryEnvironmentProtocolUnsupportedError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentProtocolUnsupportedError", + { + source: PrimaryEnvironmentTargetSource, + protocol: Schema.String, + }, +) { + override get message(): string { + return `The ${this.source} primary environment target uses unsupported protocol ${this.protocol}.`; + } +} + +export class DesktopEnvironmentBootstrapIncompleteError extends Schema.TaggedErrorClass()( + "DesktopEnvironmentBootstrapIncompleteError", + { + hasHttpBaseUrl: Schema.Boolean, + hasWsBaseUrl: Schema.Boolean, + }, +) { + override get message(): string { + const missing = [ + ...(this.hasHttpBaseUrl ? [] : ["httpBaseUrl"]), + ...(this.hasWsBaseUrl ? [] : ["wsBaseUrl"]), + ]; + return `Desktop bootstrap is missing ${missing.join(" and ")} for the local environment.`; + } +} + +export const isPrimaryEnvironmentUrlInvalidError = Schema.is(PrimaryEnvironmentUrlInvalidError); +export const isPrimaryEnvironmentProtocolUnsupportedError = Schema.is( + PrimaryEnvironmentProtocolUnsupportedError, +); +export const isDesktopEnvironmentBootstrapIncompleteError = Schema.is( + DesktopEnvironmentBootstrapIncompleteError, +); export interface PrimaryEnvironmentTarget { - readonly source: "configured" | "window-origin" | "desktop-managed"; + readonly source: PrimaryEnvironmentTargetSource; readonly target: { readonly httpBaseUrl: string; readonly wsBaseUrl: string; @@ -14,15 +79,49 @@ function getDesktopLocalEnvironmentBootstrap(): DesktopEnvironmentBootstrap | nu return window.desktopBridge?.getLocalEnvironmentBootstrap() ?? null; } -function normalizeBaseUrl(rawValue: string): string { - return new URL(rawValue, window.location.origin).toString(); +function parseTargetUrl(input: { + readonly rawValue: string; + readonly baseUrl?: string; + readonly source: PrimaryEnvironmentTargetSource; + readonly urlKind: PrimaryEnvironmentUrlKind; +}): URL { + try { + return input.baseUrl === undefined + ? new URL(input.rawValue) + : new URL(input.rawValue, input.baseUrl); + } catch (cause) { + throw new PrimaryEnvironmentUrlInvalidError({ + source: input.source, + urlKind: input.urlKind, + cause, + }); + } +} + +function normalizeBaseUrl( + rawValue: string, + source: PrimaryEnvironmentTargetSource, + urlKind: PrimaryEnvironmentUrlKind, +): string { + return parseTargetUrl({ + rawValue, + baseUrl: window.location.origin, + source, + urlKind, + }).toString(); } function swapBaseUrlProtocol( rawValue: string, nextProtocol: "http:" | "https:" | "ws:" | "wss:", + urlKind: PrimaryEnvironmentUrlKind, ): string { - const url = new URL(normalizeBaseUrl(rawValue)); + const url = parseTargetUrl({ + rawValue, + baseUrl: window.location.origin, + source: "configured", + urlKind, + }); url.protocol = nextProtocol; return url.toString(); } @@ -38,15 +137,29 @@ export function isLoopbackHostname(hostname: string): boolean { return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname)); } -function resolveHttpRequestBaseUrl(httpBaseUrl: string): string { +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 = new URL(window.location.href); - const targetUrl = new URL(httpBaseUrl); - const devServerUrl = new URL(configuredDevServerUrl, currentUrl.origin); + const currentUrl = parseTargetUrl({ + rawValue: window.location.href, + source: "window-origin", + urlKind: "window-location-url", + }); + const targetUrl = parseTargetUrl({ + rawValue: httpBaseUrl, + source: primaryTarget.source, + urlKind: "http-base-url", + }); + const devServerUrl = parseTargetUrl({ + rawValue: configuredDevServerUrl, + baseUrl: currentUrl.origin, + source: "configured", + urlKind: "development-server-url", + }); const isCurrentOriginDevServer = (currentUrl.protocol === "http:" || currentUrl.protocol === "https:") && @@ -75,32 +188,39 @@ function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { const resolvedHttpBaseUrl = configuredHttpBaseUrl ?? (configuredWsBaseUrl?.startsWith("wss:") - ? swapBaseUrlProtocol(configuredWsBaseUrl, "https:") - : swapBaseUrlProtocol(configuredWsBaseUrl!, "http:")); + ? swapBaseUrlProtocol(configuredWsBaseUrl, "https:", "websocket-base-url") + : swapBaseUrlProtocol(configuredWsBaseUrl!, "http:", "websocket-base-url")); const resolvedWsBaseUrl = configuredWsBaseUrl ?? (configuredHttpBaseUrl?.startsWith("https:") - ? swapBaseUrlProtocol(configuredHttpBaseUrl, "wss:") - : swapBaseUrlProtocol(configuredHttpBaseUrl!, "ws:")); + ? swapBaseUrlProtocol(configuredHttpBaseUrl, "wss:", "http-base-url") + : swapBaseUrlProtocol(configuredHttpBaseUrl!, "ws:", "http-base-url")); return { source: "configured", target: { - httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl), - wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl), + httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl, "configured", "http-base-url"), + wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl, "configured", "websocket-base-url"), }, }; } function resolveWindowOriginPrimaryTarget(): PrimaryEnvironmentTarget { - const httpBaseUrl = normalizeBaseUrl(window.location.origin); - const url = new URL(httpBaseUrl); + const url = parseTargetUrl({ + rawValue: window.location.origin, + source: "window-origin", + urlKind: "http-base-url", + }); + const httpBaseUrl = url.toString(); if (url.protocol === "http:") { url.protocol = "ws:"; } else if (url.protocol === "https:") { url.protocol = "wss:"; } else { - throw new Error(`Unsupported HTTP base URL protocol: ${url.protocol}`); + throw new PrimaryEnvironmentProtocolUnsupportedError({ + source: "window-origin", + protocol: url.protocol, + }); } return { source: "window-origin", @@ -120,16 +240,25 @@ function resolveDesktopPrimaryTarget(): PrimaryEnvironmentTarget | null { return null; } if (!desktopBootstrap.httpBaseUrl || !desktopBootstrap.wsBaseUrl) { - throw new Error( - "Desktop bootstrap must provide both httpBaseUrl and wsBaseUrl for the local environment.", - ); + throw new DesktopEnvironmentBootstrapIncompleteError({ + hasHttpBaseUrl: Boolean(desktopBootstrap.httpBaseUrl), + hasWsBaseUrl: Boolean(desktopBootstrap.wsBaseUrl), + }); } return { source: "desktop-managed", target: { - httpBaseUrl: normalizeBaseUrl(desktopBootstrap.httpBaseUrl), - wsBaseUrl: normalizeBaseUrl(desktopBootstrap.wsBaseUrl), + httpBaseUrl: normalizeBaseUrl( + desktopBootstrap.httpBaseUrl, + "desktop-managed", + "http-base-url", + ), + wsBaseUrl: normalizeBaseUrl( + desktopBootstrap.wsBaseUrl, + "desktop-managed", + "websocket-base-url", + ), }, }; } @@ -139,11 +268,12 @@ export function resolvePrimaryEnvironmentHttpUrl( searchParams?: Record, ): string { const primaryTarget = readPrimaryEnvironmentTarget(); - if (!primaryTarget) { - throw new Error("Unable to resolve the primary environment HTTP base URL."); - } - const url = new URL(resolveHttpRequestBaseUrl(primaryTarget.target.httpBaseUrl)); + const url = parseTargetUrl({ + rawValue: resolveHttpRequestBaseUrl(primaryTarget), + source: primaryTarget.source, + urlKind: "http-base-url", + }); url.pathname = pathname; if (searchParams) { url.search = new URLSearchParams(searchParams).toString(); @@ -151,7 +281,7 @@ export function resolvePrimaryEnvironmentHttpUrl( return url.toString(); } -export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget | null { +export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget { return ( resolveDesktopPrimaryTarget() ?? resolveConfiguredPrimaryTarget() ??