A minimal Electron reference integration for the TONE3000 API. On first launch a welcome screen connects the user's TONE3000 account via the standard OAuth flow (embedded in a panel beside the app's own sidebar). Once signed in, the app shows tabs for tones Loaded on Acme, plus the user's TONE3000 Favorites, Created, and Recents (downloaded) lists via the REST API — any of them can be loaded into the app with one click. "Browse Tones on TONE3000" launches the select flow, where the user picks a tone in TONE3000's own catalog UI. Loaded tones expose their downloadable model files. It is the desktop counterpart to the web demos in the TONE3000 API examples.
Built with electron-vite (Electron + Vite + React + TypeScript).
The app's UI — sidebar, header, chrome — stays mounted the whole time. Both OAuth flows
(the welcome screen's "Log into TONE3000" standard authorization and the select flow's
"Browse Tones") open TONE3000 in an embedded
WebContentsView
laid into the content region beside the sidebar; the only difference is whether the
authorize URL carries prompt=select_tone. When TONE3000 redirects back, the main
process removes the view and hands the app the outcome (tokens, plus the chosen tone for
the select flow) — no popup, no second window, and the app never navigates away from
itself. After sign-in, the Favorites / Created / Recents tabs call the REST endpoints
(/api/v1/tones/favorited, /created, /downloaded) directly from the renderer via
T3KClient. Two things are handled specially to fit a desktop app.
Tokens are persisted so the user stays signed in across app restarts. They're stored
with Electron's built-in
safeStorage — encrypted
with the OS keychain (macOS Keychain / Windows DPAPI / Linux libsecret) and written to
userData/tokens.enc.
src/main/tokenStore.ts— encrypt/decrypt ↔userData/tokens.encsrc/preload/index.ts— exposeswindow.t3k.tokens.{get,set,clear}src/renderer/src/tone3000-client.ts—T3KClienthydrates from the store at boot and again when a select flow completes, and writes through on every change;getTokens()stays synchronous.src/renderer/src/App.tsx— awaits hydration before first render, so the app comes up already connected.
The renderer measures the DOM slot beside its sidebar and asks the main process to start
the flow (window.t3k.beginSelect(config, bounds)). Main generates the PKCE challenge,
creates a WebContentsView at those bounds, loads the authorize URL into it, and watches
that view's web contents for the redirect back to redirect_uri. On the redirect it
exchanges the code for tokens, persists them (above), records which tone was chosen,
then destroys the view and pushes the outcome to the renderer. The renderer re-hydrates
its token client from the store (it never reloaded, so its in-memory tokens are stale)
and loads the tone.
src/main/index.ts—beginSelect()creates the embedded view and installs a navigation interceptor on its web contents (will-redirect/will-navigate, plus 4xx and load-failure safety nets and an Escape escape-hatch);finishFlow()exchanges the code, persists tokens, tears the view down, and sendsoauth:selectComplete.- The renderer keeps the view aligned to its slot via
window.t3k.setSelectBounds(bounds)(fired from aResizeObserver+ windowresize), and hears the result throughwindow.t3k.onSelectComplete(...). Seesrc/renderer/src/apps/SelectApp.tsx.
Why OAuth lives in main: the redirect is captured on the view's web contents, which
the renderer can't attach navigation listeners to. So main owns the view and therefore
initiation + code→token exchange. The renderer keeps only the live session — T3KClient
and its token refresh (tone3000-client.ts), which need just the refresh_token already
in hand. (The renderer no longer tears down mid-flow, so this could move to the renderer;
keeping it in main is what lets TONE3000 render in a view with no bridge — see below.)
Keyboard focus must be given to the view explicitly. Adding a
WebContentsView and loading a URL into it does not reliably move keyboard focus
off the host renderer (and on some Electron versions in-page navigations reset it
— see electron#42578). If you
skip this, the embedded TONE3000 page renders and responds to clicks but typing —
e.g. into the search field — goes to your app's UI instead, and the field appears
dead. beginSelect() calls view.webContents.focus() right after attaching the
view and re-asserts it on every did-finish-load; destroyActiveView() hands
focus back to the app renderer (win.webContents.focus()) when the flow ends.
Bounds are in device-independent pixels. The renderer's getBoundingClientRect and
main's view.setBounds share the window's content-area coordinate space, so they line up
at the default zoom. A production app that supports zoom should scale by
webContents.getZoomFactor().
The redirect_uri is a sentinel — nothing needs to serve it, because the navigation is
intercepted before it loads. It must still be registered in your API key's allowed
redirect URIs (localhost origins are auto-allowed in development).
API requests are made directly from the renderer: the TONE3000 API returns
Access-Control-Allow-Origin: * and uses Bearer auth, so cross-origin fetch works
from the app.
The embedded view that loads www.tone3000.com is created with no preload, so
window.t3k simply doesn't exist there — a third-party page can never reach the token
store. As defense-in-depth, the app's own preload also gates window.t3k to our origin
(file:// when packaged, or the dev renderer URL), so the guarantee holds even if that
preload were ever attached to a window that navigates elsewhere. See the origin check in
src/preload/index.ts.
- Log in to tone3000.com → Settings → API Keys
- Create a key — you'll get a
t3k_pub_…publishable key - Add your redirect URI (default
http://localhost:3001/callback) to the key's allowed redirect URIs. Localhost origins are auto-allowed in development.
cp .env.example .envSet VITE_PUBLISHABLE_KEY in .env. Optionally override VITE_REDIRECT_URI and
VITE_T3K_API_DOMAIN.
npm install
npm run devAn Electron window opens on the welcome screen. Click Log into TONE3000 — TONE3000
fills the area beside the sidebar (the app UI stays visible) and you sign in; the panel
closes and the main screen appears. From there, load tones from the T3K Favorites /
Created / Recents tabs with one click, or click Browse Tones on TONE3000 to pick a
tone from the catalog via the select flow. Loaded tones collect under Loaded on Acme
with their downloadable models. Use TONE3000's own close button (menubar=true) or press
Escape to close the embedded panel without finishing a flow.
Verify persistence: fully quit the app (Cmd+Q) and relaunch. It comes up already
showing "Signed in as @you" with no login — the token is read back from safeStorage
and used to call the API on boot. On macOS the persisted file is
~/Library/Application Support/electron-select/tokens.enc (ciphertext). Clicking
Disconnect deletes it.
npm run dist # installer for the current OS via electron-builder
npm run dist:dir # unpacked app directory (faster, for local testing)The packaged app runs from file://; the redirect capture and token storage work the
same way, with no local server.
src/
main/
index.ts # window lifecycle; embedded select view (PKCE + redirect capture + exchange); token IPC
tokenStore.ts # safeStorage-encrypted token file in userData
preload/
index.ts # contextBridge → window.t3k (origin-gated); beginSelect / setSelectBounds / endSelect / onSelectComplete / tokens
renderer/
index.html
src/
main.tsx
App.tsx # hydrate tokens, then render
apps/SelectApp.tsx # select-flow UI: sidebar + embedded browse panel (measures bounds, syncs the view)
tone3000-client.ts # live API client + token refresh (session only; OAuth lives in main)
client.ts # shared T3KClient instance
config.ts # env config
components/ # ToneCard, ModelList (download-only), etc.
shared/
types.ts # types shared across main, preload, and renderer (the IPC surface)
- This
zipbranch wires up the Download Tone endpoint: each loaded tone card gets a Download .zip button that fetchesGET /api/v1/tones/{id}/download(viaT3KClient.getToneDownload) and then downloads the returned temporary URL — a zip archive of all the tone's models (T3KClient.downloadToneZip). The URL expires an hour after being issued, so it's requested fresh on every click. The pre-signed URL needs no auth header, but the storage host serving it doesn't send CORS headers, so the renderer can't fetch it directly — the bytes are fetched in the main process (download:fetchZipIPC, Electronnet.fetch) and saved from the renderer via a blob anchor. Note this endpoint is available to approved partners only; other API clients receive403(surfaced in the card UI). For most integrations, download individual models viamodel_urlinstead. - Model files are downloaded via authenticated Bearer requests (see
T3KClient.downloadModel). The web demo's in-app WASM preview player is omitted to keep the example focused. - This example does not set a Content-Security-Policy; a production app should.
- Full API reference: tone3000.com/api.