Navigate

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.

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 in a standard Vite + React web app (no Electron).

View on GitHub


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

1. Clone and install

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

2. Configure environment variables

cp .env.example .env.local

Open .env.local and set:

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

npm run dev

How the Cephable SDK integration works

1. Install the SDK

npm install @cephable/cephable-web

2. Build the auth configuration

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:

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:

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):

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:

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:

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:

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:

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 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 for the pattern.

Next steps