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
40 changes: 27 additions & 13 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,22 +1,36 @@
# Frontend: leave empty when the browser should use same-origin requests.
# Set only when the frontend is deployed separately from the backend bridge.
# Renderer/frontend API origin.
# Leave empty in local Electron development so the renderer keeps using
# first-party `/auth`, `/me`, and `/library` routes and Vite proxies them to
# the local Electron bridge.
# Never point this at `https://cloud.boosteroid.com` or any Boosteroid origin.
# VITE_API_BASE_URL=

# Cloudflare Turnstile site key.
# For local development, use the Cloudflare test key that always passes:
# VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
# For production, leave unset to use the default production key,
# or set explicitly:
# VITE_TURNSTILE_SITE_KEY=0x4AAAAAAB83Vz-GpH08brQi

# Backend bridge configuration.
# Electron/local bridge configuration.
# SERVER_PORT=3001
# UPSTREAM_BASE_URL=https://cloud.boosteroid.com
# SESSION_SECRET=replace-with-a-long-random-secret
# SESSION_COOKIE_NAME=openstroid_session
# SESSION_TTL_SECONDS=2592000
# COOKIE_SECURE=false
# APP_ORIGIN=http://localhost:3000
# APP_ORIGIN=http://127.0.0.1:3000
# AUTH_CAPTURE_ARTIFACT_DIR=/absolute/path/to/OpenStroid/.runtime/auth-captures
# BROWSER_USER_DATA_DIR=/absolute/path/to/OpenStroid/.runtime/browser-profile
# BROWSER_LOGIN_TIMEOUT_MS=300000
# BROWSER_LOGIN_POLL_INTERVAL_MS=1500
# BROWSER_LAUNCH_NAVIGATE_TIMEOUT_MS=30000
# BROWSER_HEADLESS=false
# BROWSER_CHANNEL=chrome
# BROWSER_EXECUTABLE_PATH=/absolute/path/to/chrome-or-chromium
# BROWSER_LOCALE=en-US
# BROWSER_LAUNCH_ARGS=--start-maximized

# Electron development renderer URL.
# ELECTRON_RENDERER_URL=http://127.0.0.1:3000

# Optional: override the Vite dev proxy target if your local bridge runs elsewhere.
# BACKEND_PROXY_TARGET=http://127.0.0.1:3001

# Optional: override the Vite dev proxy target if your backend runs elsewhere.
# BACKEND_PROXY_TARGET=http://localhost:3001
# Chrome extension local dev:
# - Load unpacked extension from extension/openstroid-capture/
# - In the extension popup, keep the backend URL set to http://127.0.0.1:3001
# - Paste the pairing code shown in OpenStroid Desktop into the extension popup before logging in
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ dist-ssr
.env

build
.runtime
163 changes: 99 additions & 64 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,110 +1,145 @@
# OpenStroid
# OpenStroid Desktop

Open-source cloud gaming client. Built with React, TypeScript, Mantine, Vite, and an Express auth bridge.
Electron-first cloud gaming client. OpenStroid Desktop runs a local bridge on localhost, coordinates Boosteroid auth capture, and displays the user-facing desktop UI. A companion Chrome extension installed in the user's normal Chrome profile captures Boosteroid auth/session state from the real browser session and sends it to the desktop bridge.

## Quick start
## Desktop-first architecture

OpenStroid is now an Electron-first desktop client.

- The Electron app is the primary product shell.
- Electron starts and owns the local HTTP bridge on `http://127.0.0.1:3001`.
- The React UI is rendered inside the Electron window.
- The Chrome extension in `extension/openstroid-capture/` runs in the user's real Chrome profile and talks to the Electron bridge over localhost.
- The desktop bridge validates captured upstream state, persists raw artifacts to `.runtime/auth-captures/`, creates the encrypted OpenStroid session, and continues to proxy normalized `/auth`, `/me`, and `/library` routes.
- A backend-owned browser fallback still exists, but it is secondary to the extension flow.

## Local development

```bash
npm install
cp .env.example .env
npm run dev
```

- Frontend dev server: [http://localhost:3000](http://localhost:3000)
- Backend auth bridge: [http://localhost:3001](http://localhost:3001)
What runs in development:
- Vite renderer: `http://127.0.0.1:3000`
- Electron desktop shell: launched automatically by `npm run dev`
- Electron-managed local bridge: `http://127.0.0.1:3001`

`npm run dev` starts both processes. The browser talks only to first-party `/auth`, `/me`, and `/library` routes. The backend bridge owns the upstream Boosteroid session and proxies authenticated requests to `https://cloud.boosteroid.com`.
Use `npm run dev:bridge` only if you need the local bridge without launching Electron.

## Auth bridge architecture
## Chrome extension setup

OpenStroid is no longer a browser-direct Boosteroid client.
1. Open `chrome://extensions` in your normal Chrome profile.
2. Enable Developer Mode.
3. Click **Load unpacked**.
4. Select `extension/openstroid-capture/` from this repo.
5. Open the extension popup.
6. Set the backend URL to `http://127.0.0.1:3001`.
7. When OpenStroid Desktop shows a pairing code, paste it into the extension popup.
8. Start desktop extension capture from the Electron app.
Comment on lines +39 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The setup sequence in @README.md is reversed: the pairing code does not exist until the desktop app starts an extension capture (startLoginCapture('extension') returns extensionPairingCode and LoginPage only renders it after that call succeeds), so a user following these steps literally cannot complete step 7 before step 8. This breaks the primary documented onboarding flow for the extension path. Swap the steps so capture starts first and the pairing code is entered second.

# README.md
5. Open the extension popup.
6. Set the backend URL to `http://127.0.0.1:3001`.
7. When OpenStroid Desktop shows a pairing code, paste it into the extension popup.
8. Start desktop extension capture from the Electron app.
Suggested change
7. When OpenStroid Desktop shows a pairing code, paste it into the extension popup.
8. Start desktop extension capture from the Electron app.
7. Start desktop extension capture from the Electron app.
8. When OpenStroid Desktop shows a pairing code, paste it into the extension popup.

9. Log in on `https://boosteroid.com` in the same Chrome profile.

- The frontend sends login, session bootstrap, logout, and library requests to first-party endpoints on the OpenStroid origin.
- The backend bridge talks to `https://cloud.boosteroid.com` for:
- `POST /api/v1/auth/login`
- `POST /api/v1/auth/refresh-token`
- `POST /api/v2/auth/logout`
- `GET /api/v1/user`
- `GET /api/v1/boostore/applications/installed`
- Upstream access and refresh tokens are stored only inside encrypted, HttpOnly first-party cookies.
- The browser no longer stores raw upstream tokens in `localStorage`.
- Session bootstrap uses `GET /auth/session` instead of reading browser storage.
- Upstream 401s are refreshed server-side with a shared refresh lock to avoid duplicate refresh races.
## Desktop capture flow

## API surface
1. OpenStroid Desktop starts an extension capture session.
2. The desktop UI shows a user-issued pairing code.
3. The user pastes that code into the Chrome extension popup.
4. The extension requests the active ingest session from the Electron bridge using that pairing code.
5. The user logs in to Boosteroid in real Chrome.
6. The extension captures relevant cookies, response metadata, and observed JSON auth/session payloads.
7. The extension submits the capture artifact to `POST /auth/extension/capture` on the Electron bridge.
8. The bridge validates upstream state with `GET /api/v1/user`, persists the raw artifact, and establishes the encrypted OpenStroid session.
9. The desktop UI polls `/auth/login/status` and transitions into the game library after success.

The backend exposes normalized first-party endpoints:
## Local bridge API surface

| Method | Route | Description |
|---|---|---|
| `POST` | `/auth/login` | Login with email/password/Turnstile, create first-party session cookie, return `{ authenticated, user }` |
| `POST` | `/auth/logout` | Clear first-party session and attempt upstream logout |
| `POST` | `/auth/login/start` | Start a capture session from the desktop UI (`extension` primary, `browser` fallback) |
| `GET` | `/auth/login/status` | Read latest capture status and establish the local OpenStroid session on success |
| `GET` | `/auth/login/status/:id` | Read status for a specific capture session |
| `POST` | `/auth/login/cancel` | Cancel the active capture |
| `POST` | `/auth/extension/active` | Extension-only route to fetch the active pending capture after presenting the user pairing code |
| `POST` | `/auth/extension/capture` | Extension-only route to submit captured upstream cookies/payloads |
| `GET` | `/auth/debug/capture` | Return the latest raw upstream capture artifact for inspection |
| `POST` | `/auth/logout` | Clear the OpenStroid session and attempt upstream logout |
| `GET` | `/auth/session` | Validate/refresh current session and return `{ authenticated, user }` |
| `GET` | `/me` | Return `{ user }` for authenticated clients |
| `GET` | `/library/installed` | Return `{ games }` from the upstream installed library API |
| `GET` | `/health` | Lightweight backend health check |
| `GET` | `/health` | Local desktop bridge health check |

## Environment variables

| Variable | Default | Description |
|---|---|---|
| `VITE_API_BASE_URL` | *(empty)* | Frontend API origin. Leave empty for same-origin deployments. |
| `VITE_TURNSTILE_SITE_KEY` | `0x4AAAAAAB83Vz-GpH08brQi` | Cloudflare Turnstile site key. Use `1x00000000000000000000AA` for local dev. |
| `SERVER_PORT` | `3001` | Backend bridge port. |
| `VITE_API_BASE_URL` | *(empty)* | Renderer API origin. Leave empty in local Electron dev so Vite proxies first-party routes. Never point this at a Boosteroid origin. |
| `SERVER_PORT` | `3001` | Local Electron bridge port. |
| `UPSTREAM_BASE_URL` | `https://cloud.boosteroid.com` | Upstream Boosteroid base URL. |
| `SESSION_SECRET` | `openstroid-development-session-secret` | Secret used to encrypt/authenticate the session cookie. Replace in production. |
| `SESSION_SECRET` | `openstroid-development-session-secret` | Secret used to encrypt/authenticate the OpenStroid session cookie. Replace in production. |
| `SESSION_COOKIE_NAME` | `openstroid_session` | First-party auth cookie name. |
| `SESSION_TTL_SECONDS` | `2592000` | Cookie/session lifetime in seconds. |
| `COOKIE_SECURE` | `false` in dev, `true` in production | Whether to mark the auth cookie as `Secure`. |
| `APP_ORIGIN` | *(unset)* | Optional allowed browser origin when frontend and backend run on different origins. |
| `BACKEND_PROXY_TARGET` | `http://localhost:3001` | Vite-only proxy target for local frontend development. |

## Production notes

- Build with `npm run build`.
- Start the bridge with `npm run start`.
- Serve the frontend and backend from the same origin when possible.
- Set a strong `SESSION_SECRET` and keep `COOKIE_SECURE=true` in production.
- If you deploy the frontend separately, set `VITE_API_BASE_URL` to the backend origin and `APP_ORIGIN` to the frontend origin.
| `APP_ORIGIN` | *(unset)* | Optional allowed renderer/browser origin if frontend and bridge are split. |
| `AUTH_CAPTURE_ARTIFACT_DIR` | `<project>/.runtime/auth-captures` | Directory where raw capture JSON artifacts are written. |
| `BROWSER_USER_DATA_DIR` | `<project>/.runtime/browser-profile` | Persistent profile directory for the browser fallback path. |
| `BROWSER_LOGIN_TIMEOUT_MS` | `300000` | Maximum time allowed for manual upstream login before timing out. |
| `BROWSER_LOGIN_POLL_INTERVAL_MS` | `1500` | Interval used while checking whether upstream auth cookies/tokens are ready. |
| `BROWSER_LAUNCH_NAVIGATE_TIMEOUT_MS` | `30000` | Initial page navigation timeout for the browser fallback. |
| `BROWSER_HEADLESS` | `false` | Whether to launch the browser fallback headlessly. Visible mode is recommended. |
| `BROWSER_CHANNEL` | `chrome` when no explicit executable is found | Preferred browser channel for the fallback path. |
| `BROWSER_EXECUTABLE_PATH` | auto-detects system Chrome/Chromium | Optional explicit browser executable path. |
| `BROWSER_LOCALE` | `en-US` | Locale for the browser fallback profile. |
| `BROWSER_LAUNCH_ARGS` | *(unset)* | Comma-separated extra browser launch arguments. |
| `BACKEND_PROXY_TARGET` | `http://127.0.0.1:3001` | Vite-only proxy target for local renderer development. |
| `ELECTRON_RENDERER_URL` | `http://127.0.0.1:3000` | Dev-only renderer URL opened by Electron. |

## Turnstile and capture notes

- The extension-first path is the primary auth mechanism because it operates inside the user's real Chrome profile.
- The extension can read relevant Boosteroid cookies via Chrome cookie APIs, including HttpOnly cookies when host permissions are granted.
- Response metadata is captured via `webRequest`, while JSON auth/session payloads are captured from page `fetch`/XHR instrumentation on Boosteroid pages.
- The extension never automates the Turnstile widget or login form. It passively observes the real session after the user acts normally.
- The pairing code remains required before the extension can discover an active ingest token from the local bridge.
- The Electron-managed browser fallback remains secondary and should not be treated as the primary product path.

## Project structure

```text
electron/
└── main.ts # Electron main process, window creation, bridge startup
server/
├── config.ts # Runtime config for the auth bridge
├── index.ts # Express server + first-party endpoints
├── app.ts # Reusable local bridge app and startup helpers
├── config.ts # Runtime config for bridge + fallback browser
├── index.ts # Standalone bridge entrypoint for non-Electron use
└── lib/
├── crypto.ts # Encrypted cookie helpers
├── session.ts # Session cookie read/write helpers
└── upstream.ts # Boosteroid upstream client + refresh handling
├── crypto.ts # Encrypted cookie helpers
├── session.ts # Session cookie read/write helpers
├── upstream.ts # Boosteroid upstream client + refresh handling
└── authCapture.ts # Extension-first capture/session orchestration
src/
├── api/ # First-party API client and endpoint wrappers
├── auth/ # AuthContext + legacy storage cleanup
├── components/ # Shared UI components
├── layouts/ # Page layout shells
├── pages/ # Route-level page components
├── theme/ # Mantine theme customization
└── types/ # Shared TypeScript interfaces
├── api/ # First-party API client and endpoint wrappers
├── auth/ # AuthContext + legacy storage cleanup
├── components/ # Shared UI components
├── layouts/ # Desktop page layout shells
├── pages/ # Desktop route-level page components
├── theme/ # Mantine theme customization
└── types/ # Shared TypeScript interfaces
extension/
└── openstroid-capture/ # Unpacked Chrome extension for real-browser Boosteroid capture
```

## Current features (auth bridge refactor)

- **Secure login flow** — email/password authentication with Cloudflare Turnstile routed through the backend bridge
- **Server-managed session** — session bootstrap checks `/auth/session` and keeps upstream tokens out of browser JavaScript
- **My Games library** — installed games loaded through first-party backend routes with existing loading, empty, and error states
- **Logout** — clears the OpenStroid session and attempts upstream logout
- **Server-side refresh** — upstream token refresh happens on the backend with refresh de-duplication for concurrent requests
- **Local dev proxy** — Vite proxies first-party backend routes to the local bridge instead of proxying directly to Boosteroid

## Scripts

| Command | Description |
|---|---|
| `npm run dev` | Start backend bridge and frontend dev server together |
| `npm run dev:server` | Start backend bridge in watch mode |
| `npm run dev:web` | Start Vite frontend dev server |
| `npm run build` | Type-check and build backend + frontend for production |
| `npm run start` | Run the built backend bridge |
| `npm run dev` | Start Vite and launch the Electron desktop shell |
| `npm run dev:web` | Start the Vite renderer only |
| `npm run dev:electron` | Launch Electron against the dev renderer |
| `npm run dev:bridge` | Run the local bridge without Electron |
| `npm run build` | Type-check and build renderer, bridge, and Electron main process |
| `npm run start` | Run the built Electron desktop app |
| `npm run start:bridge` | Run only the built local bridge |
| `npm run preview` | Preview the frontend build |
| `npm run lint` | Run ESLint |

Expand Down
69 changes: 69 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { AddressInfo } from 'node:net';
import { app, BrowserWindow, shell } from 'electron';
import { startBridgeServer } from '../server/app.js';
import { serverConfig } from '../server/config.js';

const DEV_RENDERER_URL = process.env.ELECTRON_RENDERER_URL ?? 'http://127.0.0.1:3000';

let bridgePort = serverConfig.port;

function createMainWindow() {
const window = new BrowserWindow({
width: 1440,
height: 960,
minWidth: 1100,
minHeight: 760,
title: 'OpenStroid',
autoHideMenuBar: true,
backgroundColor: '#11131a',
webPreferences: {
contextIsolation: true,
sandbox: true,
},
});

window.webContents.setWindowOpenHandler(({ url }) => {
void shell.openExternal(url);
return { action: 'deny' };
});

if (serverConfig.isProduction) {
void window.loadURL(`http://127.0.0.1:${bridgePort}`);
} else {
void window.loadURL(DEV_RENDERER_URL);
}
}

async function bootstrapDesktopApp() {
const server = startBridgeServer(serverConfig.port);
const address = server.address();
if (address && typeof address !== 'string') {
bridgePort = (address as AddressInfo).port;
}

createMainWindow();

app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
}
});

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});

app.on('before-quit', () => {
server.close();
});
}

app.whenReady().then(() => {
app.setName('OpenStroid');
if (process.platform === 'win32') {
app.setAppUserModelId('ai.capy.openstroid');
}
void bootstrapDesktopApp();
});
Loading