Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 2 additions & 28 deletions apps/desktop/src/ipc/methods/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ import {
DesktopPreviewWebviewConfigSchema,
PreviewAnnotationSubmissionResultSchema,
PreviewCaptureFailure,
DEFAULT_BROWSER_PROFILE_ID,
INCOGNITO_BROWSER_PROFILE_ID,
PreviewForwardedShortcut,
MAX_KEYBINDINGS_COUNT,
} from "@t3tools/contracts";
Expand All @@ -31,6 +29,8 @@ import * as Option from "effect/Option";
import * as NodeURL from "node:url";

import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import { resolvePartitionScope } from "../../preview/BrowserProfileScope.ts";
export { resolvePartitionScope } from "../../preview/BrowserProfileScope.ts";
import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts";
import * as PreviewManager from "../../preview/Manager.ts";
import { normalizeCaptureFailure } from "../../preview/captureErrors.ts";
Expand Down Expand Up @@ -262,32 +262,6 @@ const clearCache = DesktopIpc.makeIpcMethod({
}),
});

/**
* The default profile keeps the bare environment id it used before profiles
* existed, so upgrading does not strand anyone's existing logins in an orphaned
* partition.
*/
export function resolvePartitionScope(
environmentId: string,
profileId: string | undefined,
): {
readonly scope: string;
readonly persistent: boolean;
readonly namespace?: "profile";
} {
if (profileId === undefined || profileId === DEFAULT_BROWSER_PROFILE_ID) {
return { scope: environmentId, persistent: true };
}
// JSON's tuple framing is injective for strings, including lone UTF-16
// surrogates (which it escapes). URI encoding throws on those supported ids,
// while replacing them with U+FFFD would collapse distinct identities.
return {
scope: JSON.stringify([environmentId, profileId]),
persistent: profileId !== INCOGNITO_BROWSER_PROFILE_ID,
namespace: "profile" as const,
};
}

/**
* Clearing without a profile keeps the historical "everything" behaviour for
* an explicit all-profiles action; naming a profile confines it to that
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/preview/BrowserProfileScope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";

export function resolvePartitionScope(
environmentId: string,
profileId: string | undefined,
): { readonly scope: string; readonly persistent: boolean; readonly namespace?: "profile" } {
// Preserve the legacy default partition so existing sign-ins survive profile support.
if (profileId === undefined || profileId === DEFAULT_BROWSER_PROFILE_ID)
return { scope: environmentId, persistent: true };
return {
scope: JSON.stringify([environmentId, profileId]),
persistent: profileId !== INCOGNITO_BROWSER_PROFILE_ID,
namespace: "profile",
};
}
60 changes: 60 additions & 0 deletions apps/desktop/src/preview/ClaudeConsoleBilling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { readClaudeConsoleCredits } from "./ClaudeConsoleBilling.ts";

const organizationId = "11111111-1111-4111-8111-111111111111";
const connection = { profileId: "default", organizationId };

describe("desktop Claude Console billing", () => {
it("reads only the bound API organization's credits through its existing session", async () => {
const fetch = vi
.fn<Electron.Session["fetch"]>()
.mockResolvedValueOnce(
Response.json([
{ uuid: "22222222-2222-4222-8222-222222222222", capabilities: ["chat"] },
{ uuid: organizationId, capabilities: ["api"] },
]),
)
.mockResolvedValueOnce(Response.json({ currency: "USD", amount: 7310 }));
expect(await readClaudeConsoleCredits(connection, fetch)).toEqual({
currency: "USD",
amount: 7310,
});
expect(fetch.mock.calls.map(([url]) => url)).toEqual([
"https://platform.claude.com/api/organizations",
`https://platform.claude.com/api/organizations/${organizationId}/prepaid/credits`,
]);
for (const [, options] of fetch.mock.calls)
expect(options).toMatchObject({ method: "GET", credentials: "include", redirect: "error" });
});
it("rejects subscription organizations before requesting a balance", async () => {
const fetch = vi
.fn<Electron.Session["fetch"]>()
.mockResolvedValueOnce(Response.json([{ uuid: organizationId, capabilities: ["chat"] }]));
await expect(readClaudeConsoleCredits(connection, fetch)).rejects.toMatchObject({
reason: "organizationUnavailable",
});
expect(fetch).toHaveBeenCalledOnce();
});
it("reports a sign-in requirement without exposing private response contents", async () => {
const fetch = vi
.fn<Electron.Session["fetch"]>()
.mockResolvedValueOnce(
Response.json({ privateData: "invented confidential text" }, { status: 403 }),
);
await expect(readClaudeConsoleCredits(connection, fetch)).rejects.toMatchObject({
reason: "signInRequired",
});
expect(fetch).toHaveBeenCalledOnce();
});
it("rejects incognito profiles and malformed organization bindings without network access", async () => {
const fetch = vi.fn<Electron.Session["fetch"]>();
for (const input of [
{ ...connection, profileId: "incognito" },
{ ...connection, organizationId: "../unrelated" },
])
await expect(readClaudeConsoleCredits(input, fetch)).rejects.toMatchObject({
reason: "invalidBinding",
});
expect(fetch).not.toHaveBeenCalled();
});
});
56 changes: 56 additions & 0 deletions apps/desktop/src/preview/ClaudeConsoleBilling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {
ClaudeConsoleBillingReadError,
ClaudeConsoleBillingConnection,
INCOGNITO_BROWSER_PROFILE_ID,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";

const isConnection = Schema.is(ClaudeConsoleBillingConnection);
const isBillingReadError = Schema.is(ClaudeConsoleBillingReadError);
const ORIGIN = "https://platform.claude.com";

export async function readClaudeConsoleCredits(
connection: ClaudeConsoleBillingConnection,
fetch: Electron.Session["fetch"],
): Promise<unknown> {
if (!isConnection(connection) || connection.profileId === INCOGNITO_BROWSER_PROFILE_ID)
throw new ClaudeConsoleBillingReadError({ reason: "invalidBinding" });
try {
const options = {
method: "GET",
credentials: "include",
redirect: "error",
signal: AbortSignal.timeout(8_000),
} as const;
const organizationsResponse = await fetch(`${ORIGIN}/api/organizations`, options);
if ([401, 403].includes(organizationsResponse.status))
throw new ClaudeConsoleBillingReadError({ reason: "signInRequired" });
if (!organizationsResponse.ok)
throw new ClaudeConsoleBillingReadError({ reason: "requestFailed" });
const organizations: unknown = await organizationsResponse.json();
if (
!Array.isArray(organizations) ||
!organizations.some((entry: unknown) => {
if (entry === null || typeof entry !== "object") return false;
const organization = entry as { uuid?: unknown; capabilities?: unknown };
return (
organization.uuid === connection.organizationId &&
Array.isArray(organization.capabilities) &&
organization.capabilities.includes("api")
);
})
)
throw new ClaudeConsoleBillingReadError({ reason: "organizationUnavailable" });
const response = await fetch(
`${ORIGIN}/api/organizations/${encodeURIComponent(connection.organizationId)}/prepaid/credits`,
options,
);
if ([401, 403].includes(response.status))
throw new ClaudeConsoleBillingReadError({ reason: "signInRequired" });
if (!response.ok) throw new ClaudeConsoleBillingReadError({ reason: "requestFailed" });
return await response.json();
} catch (error) {
if (isBillingReadError(error)) throw error;
throw new ClaudeConsoleBillingReadError({ reason: "requestFailed" });
}
}
43 changes: 43 additions & 0 deletions apps/desktop/src/preview/DesktopBrowserHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
import { describe, expect, it } from "@effect/vitest";
import { DesktopBrowserEvent } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Fiber from "effect/Fiber";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as NodeEvents from "node:events";

import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
import * as BrowserSession from "./BrowserSession.ts";

const decodeEvent = Schema.decodeUnknownSync(Schema.fromJsonString(DesktopBrowserEvent));
const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
Expand Down Expand Up @@ -52,6 +54,47 @@ const takeEvents = (host: DesktopBrowserHost.DesktopBrowserHost["Service"], coun
);

describe("DesktopBrowserHost", () => {
it.effect("refreshes billing through the saved environment profile while no tab is open", () =>
Effect.gen(function* () {
const organizationId = "11111111-1111-4111-8111-111111111111";
const profiles: unknown[] = [];
const fetch: Electron.Session["fetch"] = async (url) =>
String(url).endsWith("/api/organizations")
? Response.json([{ uuid: organizationId, capabilities: ["api"] }])
: Response.json({ currency: "USD", amount: 7310 });
const host = yield* DesktopBrowserHost.make.pipe(
Effect.provide(
Layer.mock(BrowserSession.BrowserSession)({
isPartition: () => false,
getSession: (scope, persistent, namespace) => {
profiles.push({ scope, persistent, namespace });
return Effect.succeed({ fetch } as Electron.Session);
},
}),
),
);
const reader = yield* Effect.forkScoped(takeEvents(host, 1), { startImmediately: true });
yield* host.handleCommandLine(
encodeJson({
type: "claudeBilling",
requestId: "synthetic-request",
environmentId: "synthetic-environment",
connection: { profileId: "default", organizationId },
}),
);
expect(yield* Fiber.join(reader)).toEqual([
{
type: "claudeBilling",
requestId: "synthetic-request",
result: { _tag: "Success", credits: { currency: "USD", amount: 7310 } },
},
]);
expect(profiles).toEqual([
{ scope: "synthetic-environment", persistent: true, namespace: undefined },
]);
}).pipe(Effect.scoped),
);

it.effect("announces tabs already attached to a backend that starts later", () =>
Effect.gen(function* () {
const host = yield* DesktopBrowserHost.make;
Expand Down
51 changes: 51 additions & 0 deletions apps/desktop/src/preview/DesktopBrowserHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import {
DesktopBrowserCommand,
DesktopBrowserEvent,
ClaudeConsoleBillingReadError,
type DesktopBrowserEvent as DesktopBrowserEventType,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
Expand All @@ -19,9 +20,13 @@ import * as Stream from "effect/Stream";

import { createCdpRelayConnection, type CdpRelayConnection } from "./CdpRelay.ts";
import { sendPageCommand } from "./GuestInput.ts";
import * as BrowserSession from "./BrowserSession.ts";
import { resolvePartitionScope } from "./BrowserProfileScope.ts";
import { readClaudeConsoleCredits } from "./ClaudeConsoleBilling.ts";

const encodeEvent = Schema.encodeSync(Schema.fromJsonString(DesktopBrowserEvent));
const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBrowserCommand));
const isBillingReadError = Schema.is(ClaudeConsoleBillingReadError);
const lineEncoder = new TextEncoder();
const AGENT_INPUT_COMMAND =
/"method":"(?:Input\.|Page\.navigate"|Runtime\.(?:evaluate|callFunctionOn)")/;
Expand Down Expand Up @@ -73,6 +78,7 @@ export class DesktopBrowserHost extends Context.Service<
>()("@t3tools/desktop/preview/DesktopBrowserHost") {}

export const make = Effect.gen(function* () {
const browserSessions = yield* Effect.serviceOption(BrowserSession.BrowserSession);
const outbox = yield* PubSub.unbounded<DesktopBrowserEventType>();
const pointers = yield* PubSub.sliding<{
readonly key: DesktopBrowserTabKey;
Expand Down Expand Up @@ -165,6 +171,51 @@ export const make = Effect.gen(function* () {
Effect.sync(() => {
const command = decodeCommand(line);
if (Option.isNone(command)) return;
if (command.value.type === "claudeBilling") {
const { requestId, environmentId, connection } = command.value;
runFork(
Effect.gen(function* () {
if (Option.isNone(browserSessions))
return yield* new ClaudeConsoleBillingReadError({ reason: "requestFailed" });
const { scope, persistent, namespace } = resolvePartitionScope(
environmentId,
connection.profileId,
);
if (!persistent)
return yield* new ClaudeConsoleBillingReadError({ reason: "invalidBinding" });
const session = yield* browserSessions.value
.getSession(scope, persistent, namespace)
.pipe(
Effect.mapError(
() => new ClaudeConsoleBillingReadError({ reason: "requestFailed" }),
),
);
return yield* Effect.tryPromise({
try: () => readClaudeConsoleCredits(connection, session.fetch.bind(session)),
catch: (error) =>
isBillingReadError(error)
? error
: new ClaudeConsoleBillingReadError({ reason: "requestFailed" }),
});
}).pipe(
Effect.matchEffect({
onSuccess: (credits) =>
PubSub.publish(outbox, {
type: "claudeBilling",
requestId,
result: { _tag: "Success", credits },
}),
onFailure: (error) =>
PubSub.publish(outbox, {
type: "claudeBilling",
requestId,
result: { _tag: "Failure", error },
}),
}),
),
);
return;
}
const tab = tabs.get(keyOf(command.value));
if (!tab) return;
if (command.value.type === "pointer") {
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/ComposerModelControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export function ComposerModelControl(props: {
<ProviderInstanceIcon
iconUrl={props.modelOption?.providerIconUrl}
provider={props.modelOption?.providerDriver}
instanceId={props.modelOption?.selection.instanceId ?? props.selection?.instanceId}
displayName={badge.displayName}
accentColor={badge.accentColor}
showBadge
Expand All @@ -66,6 +67,7 @@ export function ComposerModelControl(props: {
<ProviderIcon
iconUrl={props.modelOption?.providerIconUrl}
provider={props.modelOption?.providerDriver}
instanceId={props.modelOption?.selection.instanceId ?? props.selection?.instanceId}
size={size}
/>
)
Expand Down
12 changes: 10 additions & 2 deletions apps/mobile/src/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import {
providerInstanceInitials,
} from "@t3tools/client-runtime/state/provider-instance-display";
import { useId, useState } from "react";
import { resolveProviderIcon } from "@t3tools/provider-core/client";
import { resolveOfficialAcpRegistryIconUrl } from "@t3tools/contracts";
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import { AppText as Text } from "./ui/AppText";
import { getProviderClient } from "../lib/providerClients";

type ProviderIconProps = {
readonly provider: string | null | undefined;
readonly instanceId?: string | null | undefined;
readonly iconUrl?: string | null | undefined;
readonly size?: number;
};
Expand Down Expand Up @@ -98,7 +100,7 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}

const packageIcon = getProviderClient(props.provider)?.icon;
const packageIcon = resolveProviderIcon(getProviderClient(props.provider), props.instanceId);
if (packageIcon) {
const themeFill = (fill: { readonly light: string; readonly dark: string }) =>
isDarkMode ? fill.dark : fill.light;
Expand Down Expand Up @@ -154,6 +156,7 @@ export function ProviderIcon(props: ProviderIconProps) {
export function ProviderInstanceIcon(props: {
readonly iconUrl?: string | null;
readonly provider: string | null | undefined;
readonly instanceId?: string | null | undefined;
readonly size?: number;
readonly displayName: string;
readonly accentColor?: string;
Expand All @@ -164,7 +167,12 @@ export function ProviderInstanceIcon(props: {
return (
<View style={{ position: "relative" }}>
<View style={{ opacity: 0.6 }}>
<ProviderIcon iconUrl={props.iconUrl} provider={props.provider} size={props.size} />
<ProviderIcon
iconUrl={props.iconUrl}
provider={props.provider}
instanceId={props.instanceId}
size={props.size}
/>
</View>
{props.showBadge ? (
// A wide right-aligned box lets the badge fit its initials. Alone, an
Expand Down
Loading
Loading