# Custom Controller Creator

> An open source browser-based touchscreen controller designer that demonstrates OAuth callback handling in a pure web app, CephableApi device management, macro event dispatch, and command token sessions with the Cephable Web SDK.

Source: https://developers.cephable.com/docs/web-sdk/samples/custom-controller

---
**Cephable Custom Controller Creator** is an open source, browser-based tool for designing personalized touchscreen controllers — buttons, joysticks, and trackpads — that dispatch real keyboard, mouse, and macro events to a paired computer via the Cephable platform. It is built as a complete reference implementation of the [Cephable Web SDK](https://www.npmjs.com/package/@cephable/cephable-web) in a standard Vite + React web app (no Electron).

[View on GitHub](https://github.com/Cephable/Cephable-Custom-Controller)

---

## What this sample demonstrates

- **OAuth in a plain web app** — redirect-based sign-in with automatic callback detection, state-parameter CSRF protection, and hash-preservation across the OAuth roundtrip
- **Session-storage token refresh** — the SDK stores the refresh token in `localStorage` but the access token in `sessionStorage` (per-tab); the sample shows how to call `refreshToken()` on startup so reopened tabs don't send stale `Bearer null` headers
- **`CephableApi` device management** — listing a user's verified devices, fetching per-device custom controls, and generating command tokens via the SDK's static `CephableApi` surface
- **Command sessions** — minting a token, holding it for a play session, and using it to dispatch commands without re-authenticating on every button press
- **Macro event dispatch** — sending key-press, key-release, type, and multi-step macro events via `sendDeviceCommand` rather than named voice commands
- **Custom control phrases** — binding a button to a `CustomControl` phrase so the Cephable app on the target device executes the right action
- **URL-based layout sharing** — encoding the full controller layout in the URL hash for shareable links, with automatic restoration after the OAuth redirect

---

## Tech stack

| Layer | Technology |
|---|---|
| Build tool | Vite 6 |
| UI | React 19, Radix UI |
| State | Zustand |
| Styling | Custom CSS (no framework) |
| Voice / accessibility | @cephable/cephable-web |
| Language | TypeScript 5 |

---

## Getting started

### Prerequisites

- Node.js 20+
- npm 10+
- A Cephable project with SDK credentials — [create one free at developers.cephable.com](https://developers.cephable.com)

### 1. Clone and install

```bash
git clone https://github.com/Cephable/Cephable-Custom-Controller.git
cd Cephable-Custom-Controller
npm install
```

### 2. Configure environment variables

```bash
cp .env.example .env.local
```

Open `.env.local` and set:

```env
VITE_CEPHABLE_CLIENT_ID=your_client_id
VITE_CEPHABLE_CLIENT_SECRET=your_client_secret
```

Optional variables:

| Variable | Default | Purpose |
|---|---|---|
| `VITE_CEPHABLE_LOGIN_URL` | Cephable hosted sign-in | Override the OAuth entry URL |
| `VITE_CEPHABLE_LOCALE` | `en-US` | Locale used when fetching device custom controls |

The redirect URI is derived at runtime from `window.location` so the same credentials work in development, staging, and production without per-environment config.

### 3. Run in development mode

```bash
npm run dev
```

---

## How the Cephable SDK integration works

### 1. Install the SDK

```bash
npm install @cephable/cephable-web
```

### 2. Build the auth configuration

```ts
import { AuthenticationService } from '@cephable/cephable-web';

const redirectUri =
  window.location.origin + window.location.pathname; // same URL, no hash/query

const authConfig = {
  clientId:     import.meta.env.VITE_CEPHABLE_CLIENT_ID,
  clientSecret: import.meta.env.VITE_CEPHABLE_CLIENT_SECRET,
  redirectUri,
  autoRefresh:  true,
};

const authService = new AuthenticationService(authConfig);
```

The client secret is included in the browser bundle. Treat the `(clientId, clientSecret)` pair as a public identifier and register only safe endpoints in your Cephable app settings.

### 3. Seed the access token on startup

The SDK stores the refresh token in `localStorage` (persistent) but the access token in `sessionStorage` (per-tab). A freshly reopened tab starts with a stale `Bearer null` Authorization header until a refresh happens. Call this once at app load:

```ts
async function refreshTokenIfAuthenticated(): Promise<void> {
  if (!authService.isAuthenticated) return;
  const result = await authService.refreshToken();
  if (result.resultType !== 'Ok') {
    console.warn('[cephable] startup refresh failed:', result);
  }
}

// In your root component or entry point:
await refreshTokenIfAuthenticated();
```

### 4. OAuth sign-in (redirect flow)

Because this is a plain web app (no Electron), OAuth uses a full-page redirect rather than a deep link. The layout hash is saved to `sessionStorage` so shared layouts survive the roundtrip:

```ts
function startLogin(): void {
  const state = crypto.randomUUID();
  localStorage.setItem('CEPHABLE_AUTH_STATE_CHECK', state);

  // Preserve the URL hash (e.g. a shared layout) across the redirect.
  if (window.location.hash) {
    sessionStorage.setItem('ccc_pending_hash', window.location.hash);
  }

  const params = new URLSearchParams({
    client_id:    CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    state,
  });
  window.location.assign(`${LOGIN_URL}?${params.toString()}`);
}
```

### 5. Handle the OAuth callback

On return, the URL contains `?code=&state=`. Detect and exchange the code exactly once (React StrictMode runs effects twice in dev — the promise cache guards against a double exchange):

```ts
let callbackInFlight: Promise<{ ok: boolean; error?: string } | null> | null = null;

async function handleOAuthCallbackIfPresent() {
  if (callbackInFlight) return callbackInFlight;

  const url    = new URL(window.location.href);
  const code   = url.searchParams.get('code');
  const state  = url.searchParams.get('state');
  const errParam = url.searchParams.get('error');

  if (!code && !state && !errParam) return null;

  callbackInFlight = (async () => {
    // Strip callback params from the address bar immediately.
    url.searchParams.delete('code');
    url.searchParams.delete('state');
    url.searchParams.delete('error');
    url.searchParams.delete('error_description');
    window.history.replaceState(null, '', url.pathname + url.hash);

    if (errParam) return { ok: false, error: errParam };

    // Do NOT remove CEPHABLE_AUTH_STATE_CHECK here — the SDK reads it
    // inside authenticateFromCode and clears it afterwards. Removing it
    // early causes the SDK's own state check to fail.
    const result = await authService.authenticateFromCode(code!, state!);
    return result.resultType === 'Ok'
      ? { ok: true }
      : { ok: false, error: result.errors?.join(', ') };
  })();

  return callbackInFlight;
}
```

Call this during your app's initialization, before rendering any authenticated UI.

### 6. List devices and fetch custom controls

Once authenticated, use `CephableApi` to discover what devices the user has and what controls are registered for each:

```ts
import { CephableApi } from '@cephable/cephable-web';

// Get the user's registered devices and filter to verified ones.
const devicesResult = await CephableApi.getUserDevices();
const devices = devicesResult.data?.filter(d => d.isVerified) ?? [];

// Fetch the custom controls for a selected device.
const controlsResult = await CephableApi.getDeviceCustomControls(
  selectedDevice.userDeviceId,
  'en-US',
);
const controls = controlsResult.data?.controls ?? [];
```

### 7. Start a command session

Before dispatching button presses, mint a command token for the selected device. Hold this token for the lifetime of the play session:

```ts
async function startPlaySession(userDeviceId: string): Promise<string | null> {
  const result = await CephableApi.generateCommandToken(userDeviceId);

  if (result.resultType === 'Unauthorized') {
    // HTTP 403 — custom controllers are not enabled for this device.
    console.error('Custom controllers not enabled for this device.');
    return null;
  }
  if (result.resultType !== 'Ok' || !result.data) {
    console.error('Failed to generate command token:', result.errors);
    return null;
  }
  return result.data.token;
}
```

The SDK maps HTTP **403 → `resultType: 'Unauthorized'`** and HTTP 401 → `'Unexpected'`. Handle `'Unauthorized'` separately — it means the device or Cephable connection doesn't have command access, not that the token expired.

### 8. Dispatch button presses

Each button on the canvas has a `CephableBinding` that describes what to send. Convert it to a `(command, macro)` pair and call `sendDeviceCommand`:

```ts
import { CephableApi } from '@cephable/cephable-web';

// Named custom control — the Cephable app executes the matching voice phrase.
async function sendCustomControl(
  userDeviceId: string,
  token:        string,
  phrase:       string,
) {
  await CephableApi.sendDeviceCommand(userDeviceId, phrase, token);
}

// Key press macro — sends physical key events to the paired computer.
async function sendKeyPress(
  userDeviceId: string,
  token:        string,
  keys:         string[],
  holdMs:       number,
) {
  await CephableApi.sendDeviceCommand(
    userDeviceId,
    `keypress:${keys.join('+')}`,
    token,
    {
      name:     'Custom controller key press',
      commands: [],
      events:   [{ eventType: 'KeyPress', keys, holdTimeMilliseconds: holdMs }],
    },
  );
}

// Typing macro — types text into the focused input on the paired computer.
async function sendTyped(
  userDeviceId: string,
  token:        string,
  text:         string,
) {
  await CephableApi.sendDeviceCommand(
    userDeviceId,
    'type',
    token,
    {
      name:     'Custom controller typed input',
      commands: [],
      events:   [{ eventType: 'Type', typedPhrase: text }],
    },
  );
}
```

The `macro` parameter accepts a `MacroModel` object. Setting `commands: []` and populating `events` lets you drive arbitrary key/mouse sequences without needing a named voice command registered on the device.

---

## Binding types

The sample defines five binding kinds that map buttons to Cephable actions:

| Kind | What it does | Payload |
|---|---|---|
| `custom-control` | Executes a named voice control phrase | `command: phrase` |
| `key-press` | Presses and holds keys for a duration | `MacroModel` with `KeyPress` event |
| `key-release` | Releases held keys | `MacroModel` with `KeyRelease` event |
| `type` | Types a string | `MacroModel` with `Type` event |
| `macro` | Multi-step sequence | `MacroModel` with multiple events |

---

## URL-based layout sharing

The full controller layout is encoded in the URL hash using base64 + JSON, so layouts can be shared as a link with no server required. After an OAuth redirect strips the hash, the pending hash is restored from `sessionStorage`:

```ts
function restorePendingHash(): void {
  const pending = sessionStorage.getItem('ccc_pending_hash');
  if (!pending) return;
  sessionStorage.removeItem('ccc_pending_hash');
  window.history.replaceState(
    null, '',
    window.location.pathname + window.location.search + pending,
  );
}
```

Call this after `handleOAuthCallbackIfPresent()` completes so the shared layout reappears without a page reload.

---

## Adapting this for your own app

1. Create a project at [developers.cephable.com](https://developers.cephable.com) and copy your credentials into your environment file.
2. Replace the full-page redirect OAuth with a popup or your framework's router if you prefer not to navigate away.
3. If you only need to send named voice commands (no raw key events), skip the macro payload and pass just the `command` string to `sendDeviceCommand`.
4. Add Electron deep-link handling if you move this to a desktop shell — see the [Agent Dash sample](/docs/web-sdk/samples/agent-dash) for the pattern.

---

## Next steps

- [Agent Dash sample](/docs/web-sdk/samples/agent-dash) — Electron-based integration with AudioWorklet, CSP, prefix commands, and OAuth deep links
- [SellLocal sample](/docs/web-sdk/samples/local-crm) — simpler Electron integration covering guest mode and basic custom voice commands
- [Web SDK Overview](/docs/web-sdk/overview)
- [Web Quick Start](/docs/web-sdk/quick-start)
