Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
},
"author": "ArcBox Labs <team@arcbox.dev>",
"dependencies": {
"@better-auth/api-key": "1.7.0-rc.2",
"@better-auth/electron": "1.7.0-rc.2",
"@linkcode/cloud": "file:../../packages/vendor/linkcode-cloud-0.1.0.tgz",
"@linkcode/common": "workspace:*",
"@linkcode/daemon": "workspace:*",
"@linkcode/ipc": "workspace:*",
Expand Down
92 changes: 92 additions & 0 deletions apps/desktop/src/main/__tests__/cloud-hosted-billing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
handlers: new Map<string, (...args: unknown[]) => unknown>(),
createApiKey: vi.fn(),
getSession: vi.fn(),
openExternal: vi.fn(),
setAsDefaultProtocolClient: vi.fn(),
setupMain: vi.fn(),
}));

vi.mock('@better-auth/electron/client', () => ({
electronClient: () => ({}),
}));

vi.mock('@better-auth/api-key/client', () => ({
apiKeyClient: () => ({}),
}));

vi.mock('better-auth/client/plugins', () => ({
organizationClient: () => ({}),
}));

vi.mock('better-auth/client', () => ({
createAuthClient: () => ({
setupMain: mocks.setupMain,
getSession: mocks.getSession,
apiKey: { create: mocks.createApiKey },
}),
}));

vi.mock('electron', () => ({
app: {
commandLine: {
getSwitchValue: () => '',
hasSwitch: () => false,
},
isPackaged: false,
setAsDefaultProtocolClient: mocks.setAsDefaultProtocolClient,
},
BrowserWindow: { getAllWindows: () => [] },
dialog: { showErrorBox: vi.fn() },
ipcMain: {
handle: (channel: string, handler: () => unknown) => mocks.handlers.set(channel, handler),
},
shell: { openExternal: mocks.openExternal },
}));

vi.mock('../cloud-auth/storage', () => ({
createSafeStorage: () => ({}),
}));

describe('desktop hosted billing handoff', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.handlers.clear();
mocks.setAsDefaultProtocolClient.mockReturnValue(true);
mocks.openExternal.mockResolvedValue(undefined);
mocks.getSession.mockResolvedValue({
data: { session: { activeOrganizationId: 'org_1' } },
error: null,
});
mocks.createApiKey.mockResolvedValue({ data: { key: 'lc-gateway-secret' }, error: null });
});

it('opens the SDK URL with a channel-specific native return target', async () => {
const { setupCloudAuth } = await import('../cloud-auth/client');
const { CLOUD_OPEN_HOSTED_BILLING_CHANNEL } = await import('../../shared/cloud');
setupCloudAuth();

await mocks.handlers.get(CLOUD_OPEN_HOSTED_BILLING_CHANNEL)?.();

expect(mocks.setAsDefaultProtocolClient).toHaveBeenCalled();
expect(mocks.openExternal).toHaveBeenCalledWith(
'https://console.linkcode.ai/billing?returnTarget=linkcode-dev%3A%2F%2Fbilling%2Freturn',
);
});

it('mints a Gateway key in the signed-in session organization', async () => {
const { setupCloudAuth } = await import('../cloud-auth/client');
const { CLOUD_CREATE_GATEWAY_KEY_CHANNEL } = await import('../../shared/cloud');
setupCloudAuth();

await expect(
mocks.handlers.get(CLOUD_CREATE_GATEWAY_KEY_CHANNEL)?.({}, 'LinkCode Gateway'),
).resolves.toBe('lc-gateway-secret');
expect(mocks.createApiKey).toHaveBeenCalledWith({
name: 'LinkCode Gateway',
organizationId: 'org_1',
});
});
});
49 changes: 41 additions & 8 deletions apps/desktop/src/main/cloud-auth/client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { resolve } from 'node:path';
import { apiKeyClient } from '@better-auth/api-key/client';
import { electronClient } from '@better-auth/electron/client';
import { createHostedBillingUrl } from '@linkcode/cloud';
import { createAuthClient } from 'better-auth/client';
import { app, BrowserWindow, ipcMain } from 'electron';
import { CLOUD_CLAIM_DEEP_LINK_CHANNEL } from '../../shared/cloud';
import { organizationClient } from 'better-auth/client/plugins';
import { app, BrowserWindow, ipcMain, shell } from 'electron';
import { z } from 'zod';
import {
CLOUD_CLAIM_DEEP_LINK_CHANNEL,
CLOUD_CREATE_GATEWAY_KEY_CHANNEL,
CLOUD_OPEN_HOSTED_BILLING_CHANNEL,
} from '../../shared/cloud';
import { CHANNEL } from '../constants';
import { createSafeStorage } from './storage';

Expand Down Expand Up @@ -41,11 +49,33 @@ export const authClient = createAuthClient({
// privileged scheme); the footer renders initials.
userImageProxy: { enabled: false },
}),
organizationClient(),
apiKeyClient(),
],
});

export type CloudAuthClient = typeof authClient;

function claimDeepLink(): boolean {
return process.defaultApp && typeof process.argv[1] === 'string'
? app.setAsDefaultProtocolClient(CLOUD_AUTH_SCHEME, process.execPath, [
resolve(process.argv[1]),
])
: app.setAsDefaultProtocolClient(CLOUD_AUTH_SCHEME);
}

async function createGatewayKey(name: unknown): Promise<string> {
const parsedName = z.string().trim().min(1).max(80).parse(name);
const session = await authClient.getSession();
if (session.error) throw new Error(session.error.message);
const organizationId = session.data?.session.activeOrganizationId;
if (!organizationId) throw new Error('Sign in to LinkCode Cloud, then try again');

const created = await authClient.apiKey.create({ name: parsedName, organizationId });
if (created.error) throw new Error(created.error.message);
return created.data.key;
}

/**
* Wire the auth client into main. Once a config object is passed, every feature must be opted into
* explicitly: `scheme` = protocol + deep-link handlers, `bridges` = the IPC handlers the preload
Expand All @@ -61,11 +91,14 @@ export function setupCloudAuth(): void {
// Re-assert this app as the scheme's OS default right before sign-in, so the callback routes to
// THIS running app even if another instance registered the scheme after startup. Mirrors the
// plugin's own registration (dev shells must pass execPath + entry argv, packaged builds don't).
ipcMain.handle(CLOUD_CLAIM_DEEP_LINK_CHANNEL, () =>
process.defaultApp && typeof process.argv[1] === 'string'
? app.setAsDefaultProtocolClient(CLOUD_AUTH_SCHEME, process.execPath, [
resolve(process.argv[1]),
])
: app.setAsDefaultProtocolClient(CLOUD_AUTH_SCHEME),
ipcMain.handle(CLOUD_CLAIM_DEEP_LINK_CHANNEL, claimDeepLink);
ipcMain.handle(CLOUD_OPEN_HOSTED_BILLING_CHANNEL, async () => {
claimDeepLink();
await shell.openExternal(
createHostedBillingUrl({ returnTarget: `${CLOUD_AUTH_SCHEME}://billing/return` }),
);
});
ipcMain.handle(CLOUD_CREATE_GATEWAY_KEY_CHANNEL, (_event, name: unknown) =>
createGatewayKey(name),
);
}
4 changes: 4 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createElectronSystemBridge } from '@linkcode/ipc/electron-renderer';
import { contextBridge, ipcRenderer } from 'electron';
import {
CLOUD_CLAIM_DEEP_LINK_CHANNEL,
CLOUD_CREATE_GATEWAY_KEY_CHANNEL,
CLOUD_IM_BINDINGS_CHANNEL,
CLOUD_IM_CREATE_BINDING_CHANNEL,
CLOUD_IM_DELETE_BINDING_CHANNEL,
Expand All @@ -13,6 +14,7 @@ import {
CLOUD_IM_UNLINK_TELEGRAM_CHANNEL,
CLOUD_IM_UPDATE_BINDING_CHANNEL,
CLOUD_LIST_HOSTS_CHANNEL,
CLOUD_OPEN_HOSTED_BILLING_CHANNEL,
} from '../shared/cloud';

/**
Expand All @@ -33,6 +35,8 @@ setupRenderer();
contextBridge.exposeInMainWorld('linkcodeCloud', {
listHosts: () => ipcRenderer.invoke(CLOUD_LIST_HOSTS_CHANNEL),
claimDeepLink: () => ipcRenderer.invoke(CLOUD_CLAIM_DEEP_LINK_CHANNEL),
openHostedBilling: () => ipcRenderer.invoke(CLOUD_OPEN_HOSTED_BILLING_CHANNEL),
createGatewayKey: (name: string) => ipcRenderer.invoke(CLOUD_CREATE_GATEWAY_KEY_CHANNEL, name),
im: {
overview: () => ipcRenderer.invoke(CLOUD_IM_OVERVIEW_CHANNEL),
bindings: () => ipcRenderer.invoke(CLOUD_IM_BINDINGS_CHANNEL),
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/renderer/src/cloud-auth/bridges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export interface CloudDataBridges {
* called right before a sign-in. Resolves to whether the OS accepted it.
*/
claimDeepLink: () => Promise<boolean>;
/** Opens Cloud's hosted billing surface in the system browser. */
openHostedBilling: () => Promise<void>;
/** Mints a LinkCode Gateway key in the signed-in user's active Cloud organization. */
createGatewayKey: (name: string) => Promise<string>;
/** IM Channel management (`/im/*`); same session-in-main model as listHosts. */
im: CloudImSource;
};
Expand All @@ -47,6 +51,10 @@ const cloudSource = window.linkcodeCloud;
export const cloudDataBridge: CloudDataBridges['linkcodeCloud'] = {
listHosts: () => traceRendererIpc('cloud.list-hosts', () => cloudSource.listHosts()),
claimDeepLink: () => traceRendererIpc('cloud.claim-deep-link', () => cloudSource.claimDeepLink()),
openHostedBilling: () =>
traceRendererIpc('cloud.open-hosted-billing', () => cloudSource.openHostedBilling()),
createGatewayKey: (name) =>
traceRendererIpc('cloud.create-gateway-key', () => cloudSource.createGatewayKey(name)),
im: {
overview: () => traceRendererIpc('cloud.im.overview', () => cloudSource.im.overview()),
bindings: () => traceRendererIpc('cloud.im.bindings', () => cloudSource.im.bindings()),
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/renderer/src/settings/billing-tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { BillingSettingsPanel } from '@linkcode/ui';
import { cloudDataBridge } from '../cloud-auth/bridges';

export function BillingTab(): React.ReactNode {
return (
<BillingSettingsPanel
onOpenBilling={() => {
void cloudDataBridge.openHostedBilling();
}}
/>
);
}
14 changes: 13 additions & 1 deletion apps/desktop/src/renderer/src/settings/providers-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import { ProvidersSettingsPanel } from '@linkcode/workbench';
import { cloudDataBridge } from '../cloud-auth/bridges';
import { useCloudAccount } from '../cloud-auth/use-cloud-account';

// A transport-backed workbench container: reachable above the connection gate (the `ungated`
// slot), degrading to loading/error while the daemon is unreachable — like the history-import tab.
export function ProvidersTab(): React.ReactNode {
return <ProvidersSettingsPanel />;
const cloud = useCloudAccount();
return (
<ProvidersSettingsPanel
linkCodeGateway={{
signedIn: cloud.account !== null,
signingIn: cloud.authenticating,
signIn: cloud.signIn,
createKey: cloudDataBridge.createGatewayKey,
}}
/>
);
}
12 changes: 12 additions & 0 deletions apps/desktop/src/renderer/src/settings/settings-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
BellIcon,
BotIcon,
CodeXmlIcon,
CreditCardIcon,
HistoryIcon,
InfoIcon,
KeyRoundIcon,
Expand All @@ -37,6 +38,7 @@ import { DEFAULT_LAYOUT } from '../shell/store/model';
import { AboutTab } from './about-tab';
import { AgentsTab } from './agents-tab';
import { AppearanceTab } from './appearance-tab';
import { BillingTab } from './billing-tab';
import { DeveloperTab } from './developer-tab';
import { GeneralTab } from './general-tab';
import { HistoryImportTab } from './history-import-tab';
Expand Down Expand Up @@ -149,6 +151,14 @@ export function SettingsView(): React.ReactNode {
active: category === 'providers',
onClick: () => setCategory('providers'),
},
{
key: 'billing',
icon: <CreditCardIcon className="size-4" />,
label: t('tabs.billing'),
keywords: searchKeywords.billing,
active: category === 'billing',
onClick: () => setCategory('billing'),
},
{
key: 'plugins',
icon: <PuzzleIcon className="size-4" />,
Expand Down Expand Up @@ -309,6 +319,8 @@ function renderSettingsPanel(
return <AboutTab />;
case 'providers':
return <ProvidersTab />;
case 'billing':
return <BillingTab />;
case 'plugins':
return <PluginsTab />;
case 'agents':
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/src/settings/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type SettingsCategory =
| 'developer'
| 'notifications'
| 'about'
| 'billing'
| 'providers'
| 'agents'
| 'imChannel'
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/src/shell/desktop-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export function DesktopShell({
onDownloadAgent,
onContinueUnverified,
onOpenProviderSettings,
onOpenBilling,
conversation,
respondingRequestIds,
responseErrors,
Expand Down Expand Up @@ -456,6 +457,7 @@ export function DesktopShell({
cwd={active?.cwd}
runtimeCues={runtimeCues}
onOpenProviderSettings={onOpenProviderSettings}
onOpenBilling={onOpenBilling}
respondingRequestIds={respondingRequestIds}
responseErrors={responseErrors}
TerminalBlockComponent={TerminalBlockComponent}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { WorkbenchShellProps } from '@linkcode/workbench';
import { useNavigationHistoryStore, useProvidersSettingsStore } from '@linkcode/workbench';
import { systemBridge } from '@renderer/ipc';
import { cloudDataBridge } from '../cloud-auth/bridges';
import { openDesktopSettings, useDesktopSettingsStore } from '../settings/store';
import { DesktopShell } from './desktop-shell';

Expand All @@ -16,6 +17,9 @@ export function DesktopWorkbenchShell({ header, ...props }: WorkbenchShellProps)
useProvidersSettingsStore.getState().startAdd();
openDesktopSettings('providers');
}}
onOpenBilling={() => {
void cloudDataBridge.openHostedBilling();
}}
onOpenAutomations={() => useNavigationHistoryStore.getState().openOverlay('automations')}
onImportHistory={() => openDesktopSettings('history-import')}
themeType={theme}
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/shared/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ export const CLOUD_LIST_HOSTS_CHANNEL = 'linkcode.cloud.list-hosts';
// renderer invokes it right before a sign-in so the OAuth deep-link callback comes back here.
export const CLOUD_CLAIM_DEEP_LINK_CHANNEL = 'linkcode.cloud.claim-deep-link';

// Opens the Cloud-owned billing surface; all billing and checkout state stays in the browser.
export const CLOUD_OPEN_HOSTED_BILLING_CHANNEL = 'linkcode.cloud.open-hosted-billing';

// Mints a LinkCode Gateway key from the authenticated Cloud session. The secret crosses this
// bridge once, then the renderer hands it to the daemon-owned account vault.
export const CLOUD_CREATE_GATEWAY_KEY_CHANNEL = 'linkcode.cloud.create-gateway-key';

// IM Channel management (`/im/*` on the cloud API).
export const CLOUD_IM_OVERVIEW_CHANNEL = 'linkcode.cloud.im.overview';
export const CLOUD_IM_BINDINGS_CHANNEL = 'linkcode.cloud.im.bindings';
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/vite.main.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export default defineConfig({
formats: ['cjs'],
},
rolldownOptions: {
external: nodeExternals(),
// @linkcode/cloud is ESM-only; bundle its tiny URL builder into the CJS main process.
external: nodeExternals(['@linkcode/cloud']),
output: {
entryFileNames: '[name].js',
assetFileNames: 'chunks/[name]-[hash][extname]',
Expand Down
4 changes: 3 additions & 1 deletion apps/webview/e2e/browser-smoke.e2e.mts
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,9 @@ async function verifyProductionEntry(browser: Browser): Promise<void> {
await page.locator('#root > *').waitFor();
await page.getByRole('link', { name: 'Open settings' }).click();
await page.waitForURL(`${server.origin}/settings`);
await page.goto(`${server.origin}/settings`, { waitUntil: 'domcontentloaded' });
await page.goto(`${server.origin}/settings/billing`, { waitUntil: 'domcontentloaded' });
await page.getByText('LinkCode does not read or process billing or checkout data.').waitFor();
await page.getByRole('button', { name: 'Manage on the web' }).waitFor();
await page.getByRole('link', { name: 'Back' }).waitFor();
await page.getByRole('link', { name: 'Back' }).click();
await page.waitForURL(`${server.origin}/`);
Expand Down
1 change: 1 addition & 0 deletions apps/webview/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
},
"dependencies": {
"@hookform/resolvers": "^5.5.7",
"@linkcode/cloud": "file:../../packages/vendor/linkcode-cloud-0.1.0.tgz",
"@linkcode/common": "workspace:*",
"@linkcode/schema": "workspace:*",
"@linkcode/sdk": "workspace:*",
Expand Down
2 changes: 2 additions & 0 deletions apps/webview/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AutomationsRoute } from '@webview/routes/automations';
import { RootLayout } from '@webview/routes/root-layout';
import { AgentsSettings } from '@webview/routes/settings/agents';
import { AppearanceSettings } from '@webview/routes/settings/appearance';
import { BillingSettings } from '@webview/routes/settings/billing';
import { DeveloperSettings } from '@webview/routes/settings/developer';
import { GeneralSettings } from '@webview/routes/settings/general';
import { MessagingSettings } from '@webview/routes/settings/messaging';
Expand Down Expand Up @@ -33,6 +34,7 @@ export function createWebviewRouter(
{ path: 'developer', element: <DeveloperSettings /> },
{ path: 'notifications', element: <NotificationsSettings /> },
{ path: 'providers', element: <ProvidersSettings /> },
{ path: 'billing', element: <BillingSettings /> },
{ path: 'plugins', element: <PluginsSettings /> },
{ path: 'agents', element: <AgentsSettings /> },
{ path: 'messaging', element: <MessagingSettings /> },
Expand Down
Loading