From 201d414aa77d33daf2db193fbfeef6c31da2b6e2 Mon Sep 17 00:00:00 2001 From: davidmonterocrespo24 Date: Thu, 21 May 2026 04:51:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(vscode-extension):=200.2.0=20=E2=80=94=20P?= =?UTF-8?q?ro=20license=20gate=20+=20deep-link=20sign-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Velxio Pro subscription gate to the VS Code extension. Every compile / run validates against `https://velxio.dev/api/pro/license/validate` before proceeding; a 60-second in-memory cache avoids hammering the endpoint during a tight compile/run loop. No offline mode by design — the extension throws OfflineError on network failure rather than caching a permission grant locally. For offline workflows users get the desktop app (separate distribution channel). New surface: - `LicenseService` (src/LicenseService.ts) — secret-store-backed key storage, validate, nonce-backed deep-link OAuth handshake, OfflineError + EntitlementError taxonomy. - `Velxio: Sign In` — opens velxio.dev/auth/vscode, returns via vscode://velxio.velxio-simulator/auth. - `Velxio: Paste License Key` — manual fallback for headless boxes. - `Velxio: Sign Out` — clears the keychain entry. - `Velxio: Show License Status` — plan + trial countdown modal. - Status bar item: Sign in / Trial Nd / Pro / Trial ended with the appropriate warning/error background colour. - Setting `velxio.licenseApiBase` for staging overrides. CHANGELOG.md + README.md added. Co-Authored-By: Claude Opus 4.7 (1M context) --- vscode-extension/CHANGELOG.md | 54 +++++ vscode-extension/README.md | 89 ++++++++ vscode-extension/package-lock.json | 4 +- vscode-extension/package.json | 36 ++- vscode-extension/src/LicenseService.ts | 247 ++++++++++++++++++++ vscode-extension/src/extension.ts | 297 +++++++++++++++++++++++++ 6 files changed, 722 insertions(+), 5 deletions(-) create mode 100644 vscode-extension/CHANGELOG.md create mode 100644 vscode-extension/README.md create mode 100644 vscode-extension/src/LicenseService.ts diff --git a/vscode-extension/CHANGELOG.md b/vscode-extension/CHANGELOG.md new file mode 100644 index 00000000..4433335b --- /dev/null +++ b/vscode-extension/CHANGELOG.md @@ -0,0 +1,54 @@ +# Changelog + +All notable changes to the **Velxio Simulator** VS Code extension are +documented here. + +## 0.2.0 + +### Breaking + +- A **Velxio Pro subscription is now required** to compile or run a + sketch from inside VS Code. A free **30-day trial** is included and + starts the first time you sign in. Anonymous compile/run is no longer + supported — the extension is gated against the license backend on + every command. + +### Added + +- `Velxio: Sign In` command — opens `https://velxio.dev/auth/vscode` in + your browser. After signing in (or creating an account), the page + hands a license token back to VS Code via the + `vscode://velxio.velxio-simulator/auth` URI scheme. +- `Velxio: Paste License Key` command — alternative for headless setups + or shared machines. Keys live in `ExtensionContext.secrets` (OS + keychain), never in `settings.json`. +- `Velxio: Sign Out` command — clears the stored key. +- `Velxio: Show License Status` command — displays the current plan, + trial countdown, and renewal date. +- Status bar item next to the board picker shows the current license + state: `Velxio: Sign in`, `Velxio: Trial Nd`, `Velxio: Pro`, or + `Velxio: Trial ended` (with the status bar warning/error background + colour applied automatically). +- New setting `velxio.licenseApiBase` (default `https://velxio.dev`) + for staging/dev overrides. Leave at the default for production. + +### Internal + +- New `LicenseService` (`src/LicenseService.ts`) encapsulates secret + storage, the deep-link nonce flow, validation against + `/api/pro/license/validate`, and a 60-second in-memory cache so a + tight compile/run loop doesn't hammer the endpoint. +- The extension is **online-only** by design — validation throws on + network failure rather than caching a permission grant locally. + +### Notes + +- Self-hosted Velxio backends do not expose `/api/pro/license/*`. The + VS Code extension is a velxio.dev product and validates exclusively + against `https://velxio.dev`. Override `velxio.licenseApiBase` only + if you are a Velxio developer testing against staging. + +## 0.1.0 + +Initial release. Compile + simulate Arduino / RP2040 / ESP32 sketches +locally from inside VS Code, no auth required. diff --git a/vscode-extension/README.md b/vscode-extension/README.md new file mode 100644 index 00000000..7db95a9c --- /dev/null +++ b/vscode-extension/README.md @@ -0,0 +1,89 @@ +# Velxio Simulator for VS Code + +Local Arduino, RP2040 and ESP32 simulator inside your editor. Compile +your sketch, watch it run in a side-by-side WebView, and stream serial +to the integrated terminal — no board, no USB cable, no separate IDE. + +## Requirements + +- VS Code 1.85 or newer. +- A **Velxio Pro subscription**. A 30-day free trial is included and + starts automatically the first time you sign in. +- An internet connection. The extension validates your subscription + before every compile and run; offline use is not supported in the + VS Code build. For offline work, use the Velxio Desktop app instead. + +## Getting started + +1. Install the extension from the marketplace. +2. Open a folder containing `velxio.toml` or `diagram.json` (or + create one with `Velxio: Open Simulator`). +3. Run **`Velxio: Sign In`** from the command palette. Your browser + opens `https://velxio.dev/auth/vscode`; sign in (or create an + account), then click **Authorise VS Code**. The page hands a + license token back to VS Code via the + `vscode://velxio.velxio-simulator/auth` URI scheme. +4. The status bar in the bottom-left shows **`Velxio: Trial 30d`** (or + **`Velxio: Pro`** for paid subscribers). Compile and Run are now + enabled. + +If your browser blocks the deep-link, or if you'd rather paste the key +manually, run **`Velxio: Paste License Key`** and enter the +`vlx_pro_...` / `vlx_trial_...` string from +[Account → My licenses](https://velxio.dev/account/licenses). + +## Commands + +| Command | Description | +|---|---| +| `Velxio: Open Simulator` | Open the side-by-side simulator panel. | +| `Velxio: Compile Sketch` | Build the active project (requires Pro). | +| `Velxio: Run Simulation` | Compile and start the simulation. | +| `Velxio: Stop Simulation` | Stop the current simulation. | +| `Velxio: Select Board` | Pick the target board (Arduino Uno, RP2040, ESP32, …). | +| `Velxio: Sign In` | Browser-based sign-in via velxio.dev. | +| `Velxio: Paste License Key` | Paste a key manually. | +| `Velxio: Sign Out` | Forget the stored key. | +| `Velxio: Show License Status`| Show plan, trial countdown, renewal date. | + +## Settings + +| Setting | Default | Description | +|---|---|---| +| `velxio.defaultBoard` | `arduino-uno` | Board used when none is configured in `velxio.toml`. | +| `velxio.autoStartBackend` | `true` | Auto-start the local compilation backend when needed. | +| `velxio.backendPort` | `0` | Fixed port for the backend (0 = auto-assign). | +| `velxio.arduinoCliPath` | `""` | Path to `arduino-cli` (leave empty to auto-detect). | +| `velxio.licenseApiBase` | `https://velxio.dev` | License validation endpoint. **Override only for development.** | + +## Pricing + +- **Free trial** — 30 days, full Velxio Pro features, no credit card. +- **Pro $15/mo** — unlocks the VS Code extension, the Velxio Desktop + app, and the premium components on velxio.dev. +- **Pro Max $35/mo** — everything in Pro plus the in-app AI assistant + and priority simulation queue. + +Manage your subscription at . + +## Troubleshooting + +- **`Velxio requires an internet connection`** — the extension cannot + reach `https://velxio.dev/api/pro/license/validate`. Check your + network, VPN, and any corporate proxy. +- **`Your trial has ended`** — start a Pro subscription at + . Existing trial keys re-activate + automatically once the subscription is live. +- **Status bar shows `Velxio: Sign in`** — run **`Velxio: Sign In`** or + **`Velxio: Paste License Key`**. +- **Deep-link doesn't return to VS Code** — some browsers silently + block custom URL schemes. The page shows an **Open VS Code** button + as a fallback; click it to retry the deep-link. + +## Privacy + +The extension sends only your license key + your VS Code version + OS +arch to `https://velxio.dev` for validation. No sketch contents, file +paths, or telemetry leave your machine. See the +[Velxio privacy policy](https://velxio.dev/privacy) for the full +picture. diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json index 90322e1a..f4a804de 100644 --- a/vscode-extension/package-lock.json +++ b/vscode-extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "velxio-simulator", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "velxio-simulator", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@iarna/toml": "^2.2.5" diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 8264e897..adb300ea 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -1,8 +1,8 @@ { "name": "velxio-simulator", "displayName": "Velxio Simulator", - "description": "Local Arduino & ESP32 simulator for VS Code — compile, simulate, and debug embedded projects without leaving your editor", - "version": "0.1.0", + "description": "Local Arduino & ESP32 simulator for VS Code — compile, simulate, and debug embedded projects without leaving your editor. Requires a Velxio Pro subscription; 30-day free trial included.", + "version": "0.2.0", "publisher": "velxio", "license": "MIT", "icon": "media/icon.png", @@ -31,7 +31,8 @@ ], "activationEvents": [ "workspaceContains:velxio.toml", - "workspaceContains:diagram.json" + "workspaceContains:diagram.json", + "onUri" ], "main": "./dist/extension.js", "contributes": { @@ -65,6 +66,30 @@ "title": "Select Board", "category": "Velxio", "icon": "$(list-selection)" + }, + { + "command": "velxio.signIn", + "title": "Sign In", + "category": "Velxio", + "icon": "$(sign-in)" + }, + { + "command": "velxio.pasteLicenseKey", + "title": "Paste License Key", + "category": "Velxio", + "icon": "$(key)" + }, + { + "command": "velxio.signOut", + "title": "Sign Out", + "category": "Velxio", + "icon": "$(sign-out)" + }, + { + "command": "velxio.showLicenseStatus", + "title": "Show License Status", + "category": "Velxio", + "icon": "$(info)" } ], "menus": { @@ -109,6 +134,11 @@ "type": "string", "default": "", "description": "Path to arduino-cli executable (leave empty to auto-detect)" + }, + "velxio.licenseApiBase": { + "type": "string", + "default": "https://velxio.dev", + "description": "Base URL for license validation. Override only if you're a Velxio developer testing against a staging environment." } } }, diff --git a/vscode-extension/src/LicenseService.ts b/vscode-extension/src/LicenseService.ts new file mode 100644 index 00000000..dbfc7670 --- /dev/null +++ b/vscode-extension/src/LicenseService.ts @@ -0,0 +1,247 @@ +/** + * License gate for the Velxio VS Code extension. + * + * Responsibilities: + * - Persist the license key in `ExtensionContext.secrets` (VS Code's + * OS-keychain-backed secret storage). Never in workspace settings — + * users commit those to git. + * - Validate the key against `/api/pro/license/validate` + * before every compile/run. The hot-path call is rate-limited to + * 60/min/key server-side; this client caches the last successful + * result in memory (not on disk) for 60s to avoid hammering the + * endpoint during a tight compile/run loop without giving any + * offline window. + * - Drive the deep-link OAuth handshake: generate a state nonce, + * hold it in workspace state, accept the redirected URI back from + * velxio.dev, swap nonce → key. + * + * Online-only by design (per paid-clients/phase-01 spec). A network + * failure throws OfflineError; the extension surfaces a "Velxio requires + * an internet connection" toast. + */ + +import * as vscode from 'vscode'; + +// ── Types ───────────────────────────────────────────────────────────────── + +export type LicensePlan = 'free' | 'personal' | 'trial' | 'pro' | 'pro_max' | 'commercial'; + +export type Entitlements = { + web_pro: boolean; + desktop: boolean; + vscode_ext: boolean; + agent_ai: boolean; + cloud_projects: boolean; +}; + +export type ValidationResult = { + valid: boolean; + plan?: LicensePlan | null; + status?: string | null; + trial_ends_at?: string | null; + subscription_period_end?: string | null; + entitlements: Partial; + reason_code?: + | 'not_found' + | 'revoked' + | 'suspended' + | 'expired' + | 'trial_expired' + | 'malformed' + | null; +}; + +export class OfflineError extends Error { + constructor(cause?: unknown) { + super( + 'Velxio requires an internet connection to validate your license.' + + (cause ? ` (${String(cause)})` : ''), + ); + this.name = 'OfflineError'; + } +} + +export class EntitlementError extends Error { + result: ValidationResult; + constructor(message: string, result: ValidationResult) { + super(message); + this.name = 'EntitlementError'; + this.result = result; + } +} + +// ── Storage keys ────────────────────────────────────────────────────────── + +const SECRET_LICENSE_KEY = 'velxio.licenseKey'; +const STATE_PENDING_NONCE = 'velxio.auth.pendingNonce'; +const STATE_PENDING_NONCE_EXP = 'velxio.auth.pendingNonceExp'; +const NONCE_TTL_MS = 5 * 60 * 1000; + +const CACHE_TTL_MS = 60_000; + +// ── Service ─────────────────────────────────────────────────────────────── + +export class LicenseService { + private context: vscode.ExtensionContext; + private memoryCache: { result: ValidationResult; expiresAt: number } | null = null; + + constructor(context: vscode.ExtensionContext) { + this.context = context; + } + + // ── Config ────────────────────────────────────────────────────────────── + + private apiBase(): string { + const cfg = vscode.workspace.getConfiguration('velxio'); + const raw = (cfg.get('licenseApiBase') ?? 'https://velxio.dev').trim(); + return raw.replace(/\/+$/, ''); + } + + // ── Key storage ───────────────────────────────────────────────────────── + + async getKey(): Promise { + return this.context.secrets.get(SECRET_LICENSE_KEY); + } + + async setKey(key: string): Promise { + await this.context.secrets.store(SECRET_LICENSE_KEY, key.trim()); + this.invalidateCache(); + } + + async clearKey(): Promise { + await this.context.secrets.delete(SECRET_LICENSE_KEY); + this.invalidateCache(); + } + + invalidateCache(): void { + this.memoryCache = null; + } + + // ── Nonce (deep-link OAuth) ───────────────────────────────────────────── + + async beginSignIn(): Promise<{ state: string; signInUrl: string }> { + const state = this.randomNonce(); + await this.context.globalState.update(STATE_PENDING_NONCE, state); + await this.context.globalState.update(STATE_PENDING_NONCE_EXP, Date.now() + NONCE_TTL_MS); + const signInUrl = `${this.apiBase()}/auth/vscode?state=${encodeURIComponent(state)}`; + return { state, signInUrl }; + } + + async completeSignIn(token: string | null, state: string | null): Promise { + if (!token || !state) { + throw new Error('Sign-in callback is missing token or state.'); + } + const pending = this.context.globalState.get(STATE_PENDING_NONCE); + const pendingExp = this.context.globalState.get(STATE_PENDING_NONCE_EXP) ?? 0; + // Always clear the pending nonce so a replay can't succeed even if + // verification below throws and the user retries. + await this.context.globalState.update(STATE_PENDING_NONCE, undefined); + await this.context.globalState.update(STATE_PENDING_NONCE_EXP, undefined); + + if (!pending || pending !== state) { + throw new Error( + 'Sign-in nonce mismatch. The link may have been opened in a different VS Code window, or it has expired. Try signing in again.', + ); + } + if (Date.now() > pendingExp) { + throw new Error('Sign-in link expired. Try signing in again.'); + } + + await this.setKey(token); + return await this.validate({ skipCache: true }); + } + + private randomNonce(): string { + // Browser-style crypto isn't available in the extension host on all + // VS Code versions; fall back to Math.random + timestamp which is + // fine for an OAuth state (single-use, server doesn't trust it). + const g = globalThis as { crypto?: { randomUUID?: () => string } }; + if (g.crypto?.randomUUID) return g.crypto.randomUUID(); + return `v-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; + } + + // ── Validate ──────────────────────────────────────────────────────────── + + async validate(opts: { skipCache?: boolean } = {}): Promise { + if (!opts.skipCache && this.memoryCache && Date.now() < this.memoryCache.expiresAt) { + return this.memoryCache.result; + } + const key = await this.getKey(); + if (!key) { + const result: ValidationResult = { + valid: false, + reason_code: 'not_found', + entitlements: {}, + }; + // Don't cache "no key" — the user might paste one in the next moment. + return result; + } + + let resp: Response; + try { + resp = await fetch(`${this.apiBase()}/api/pro/license/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ key }), + }); + } catch (err) { + throw new OfflineError(err); + } + + if (resp.status === 429) { + // Rate-limited server-side. Tell the caller it's a transient + // condition by reusing the offline error shape — the user-facing + // message is the same ("try again"). + throw new OfflineError('Validation rate limit reached. Wait a minute and try again.'); + } + + if (!resp.ok) { + throw new Error(`License validation failed (${resp.status}). Try again.`); + } + + const data = (await resp.json()) as ValidationResult; + // Default entitlements to {} so the caller can `.vscode_ext` safely. + if (!data.entitlements || typeof data.entitlements !== 'object') { + data.entitlements = {}; + } + this.memoryCache = { result: data, expiresAt: Date.now() + CACHE_TTL_MS }; + return data; + } + + /** + * Validates and throws if the result doesn't authorise vscode_ext use. + * Returns the result on success so callers can read `plan` / `trial_ends_at`. + */ + async requireValid(): Promise { + const result = await this.validate(); + if (!result.valid) { + throw new EntitlementError(this.reasonToMessage(result), result); + } + if (!result.entitlements?.vscode_ext) { + throw new EntitlementError( + 'Your Velxio plan does not include the VS Code extension. Upgrade to Pro to continue.', + result, + ); + } + return result; + } + + reasonToMessage(result: ValidationResult): string { + switch (result.reason_code) { + case 'not_found': + return 'License key not recognised. Sign in or paste a valid key.'; + case 'revoked': + return 'This license key has been revoked. Contact support if this is unexpected.'; + case 'suspended': + return 'This license key is currently suspended. Contact support to reactivate.'; + case 'trial_expired': + return 'Your 30-day trial has ended. Upgrade to Pro to continue using the extension.'; + case 'expired': + return 'Your Velxio subscription has lapsed. Renew to continue.'; + case 'malformed': + return 'License key format is invalid.'; + default: + return 'Your Velxio subscription is not active.'; + } + } +} diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index ec11d424..7b574b50 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -12,6 +12,12 @@ import { BackendManager } from './BackendManager'; import { ProjectConfig } from './ProjectConfig'; import { SerialTerminal } from './SerialTerminal'; import { FileWatcher } from './FileWatcher'; +import { + EntitlementError, + LicenseService, + OfflineError, + type ValidationResult, +} from './LicenseService'; import { BOARD_LABELS, type BoardKind } from './types'; let backend: BackendManager; @@ -19,12 +25,15 @@ let serialTerminal: SerialTerminal; let fileWatcher: FileWatcher; let outputChannel: vscode.OutputChannel; let statusBarItem: vscode.StatusBarItem; +let licenseStatusBarItem: vscode.StatusBarItem; +let licenseService: LicenseService; export function activate(context: vscode.ExtensionContext) { outputChannel = vscode.window.createOutputChannel('Velxio'); backend = new BackendManager(outputChannel); serialTerminal = new SerialTerminal(); fileWatcher = new FileWatcher(); + licenseService = new LicenseService(context); // Status bar item showing current board statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); @@ -32,6 +41,21 @@ export function activate(context: vscode.ExtensionContext) { statusBarItem.tooltip = 'Click to change board'; updateStatusBar('arduino-uno'); + // Status bar item showing license / subscription state + licenseStatusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 49); + licenseStatusBarItem.command = 'velxio.showLicenseStatus'; + renderLicenseStatusBar(null); + licenseStatusBarItem.show(); + // Refresh asynchronously so the bar isn't blank for the first few seconds. + void refreshLicenseStatusBar(); + + // URI handler for the OAuth deep-link round-trip. + context.subscriptions.push( + vscode.window.registerUriHandler({ + handleUri: (uri) => handleAuthUri(uri), + }), + ); + // ── Commands ────────────────────────────────────────────────────────────── context.subscriptions.push( @@ -46,6 +70,11 @@ export function activate(context: vscode.ExtensionContext) { }), vscode.commands.registerCommand('velxio.run', async () => { + // Gate up-front so we don't open the panel just to immediately + // bounce the user to a "please sign in" modal. + const gate = await ensureLicensed(); + if (!gate) return; + const panel = SimulatorPanel.createOrShow(context.extensionUri); setupPanelListeners(panel, context); @@ -65,6 +94,108 @@ export function activate(context: vscode.ExtensionContext) { panel.stop(); }), + vscode.commands.registerCommand('velxio.signIn', async () => { + try { + const { signInUrl } = await licenseService.beginSignIn(); + const opened = await vscode.env.openExternal(vscode.Uri.parse(signInUrl)); + if (!opened) { + vscode.window.showWarningMessage( + 'Could not open the sign-in page in your browser. Copy the URL from the output panel.', + ); + outputChannel.appendLine(`[license] sign-in URL: ${signInUrl}`); + } else { + outputChannel.appendLine('[license] sign-in flow started — complete in browser'); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`Sign-in failed: ${msg}`); + } + }), + + vscode.commands.registerCommand('velxio.pasteLicenseKey', async () => { + const value = await vscode.window.showInputBox({ + prompt: 'Paste your Velxio license key', + placeHolder: 'vlx_pro_… or vlx_trial_…', + password: true, + ignoreFocusOut: true, + validateInput: (v) => { + const trimmed = v.trim(); + if (!trimmed) return 'License key cannot be empty.'; + if (!/^vlx_[a-z_]+_[0-9a-f]+$/.test(trimmed)) { + return 'That doesn\'t look like a Velxio key (expected format: vlx__).'; + } + return null; + }, + }); + if (!value) return; + await licenseService.setKey(value); + try { + const result = await licenseService.validate({ skipCache: true }); + if (result.valid && result.entitlements?.vscode_ext) { + vscode.window.showInformationMessage( + `Velxio key accepted. Plan: ${result.plan ?? 'unknown'}.`, + ); + } else { + vscode.window.showWarningMessage(licenseService.reasonToMessage(result)); + } + } catch (err) { + if (err instanceof OfflineError) { + vscode.window.showWarningMessage(err.message); + } else { + vscode.window.showErrorMessage( + `Validation failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + await refreshLicenseStatusBar(); + }), + + vscode.commands.registerCommand('velxio.signOut', async () => { + const choice = await vscode.window.showWarningMessage( + 'Sign out of Velxio? You will need to sign in again to compile or run sketches.', + { modal: true }, + 'Sign out', + ); + if (choice !== 'Sign out') return; + await licenseService.clearKey(); + await refreshLicenseStatusBar(); + vscode.window.showInformationMessage('Signed out of Velxio.'); + }), + + vscode.commands.registerCommand('velxio.showLicenseStatus', async () => { + const key = await licenseService.getKey(); + if (!key) { + const action = await vscode.window.showInformationMessage( + 'You are not signed in to Velxio. Compile and Run are disabled.', + 'Sign In', + 'Paste License Key', + 'View Pricing', + ); + if (action === 'Sign In') vscode.commands.executeCommand('velxio.signIn'); + if (action === 'Paste License Key') vscode.commands.executeCommand('velxio.pasteLicenseKey'); + if (action === 'View Pricing') { + vscode.env.openExternal(vscode.Uri.parse('https://velxio.dev/pricing')); + } + return; + } + try { + const result = await licenseService.validate({ skipCache: true }); + await refreshLicenseStatusBar(result); + const summary = renderStatusSummary(result); + const buttons: string[] = []; + if (!result.valid) buttons.push('Open Billing'); + buttons.push('Sign Out'); + const action = await vscode.window.showInformationMessage(summary, ...buttons); + if (action === 'Open Billing') { + vscode.env.openExternal(vscode.Uri.parse('https://velxio.dev/billing')); + } + if (action === 'Sign Out') vscode.commands.executeCommand('velxio.signOut'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`License check failed: ${msg}`); + } + }), + vscode.commands.registerCommand('velxio.selectBoard', async () => { const boards = Object.entries(BOARD_LABELS) as [BoardKind, string][]; const items = boards.map(([kind, label]) => ({ @@ -121,6 +252,7 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( outputChannel, statusBarItem, + licenseStatusBarItem, serialTerminal, fileWatcher, { dispose: () => { backend.stop(); } }, @@ -196,6 +328,9 @@ function needsBackend(board: BoardKind): boolean { } async function compileAndLoad(context: vscode.ExtensionContext): Promise { + const gate = await ensureLicensed(); + if (!gate) return; + const workspaceRoot = getWorkspaceRoot(); if (!workspaceRoot) { vscode.window.showErrorMessage('No workspace folder open'); @@ -283,3 +418,165 @@ function getBoardFqbn(board: BoardKind): string { }; return fqbnMap[board] ?? 'arduino:avr:uno'; } + +// ── License gate ──────────────────────────────────────────────────────────── + +/** + * Validates the stored key (or prompts the user to sign in) before any + * compile/run. Returns true if the caller should proceed; false if it + * should bail (the user was shown an actionable message either way). + */ +async function ensureLicensed(): Promise { + const key = await licenseService.getKey(); + if (!key) { + const action = await vscode.window.showWarningMessage( + 'Velxio Pro subscription required. 30-day free trial available.', + 'Sign In', + 'Paste License Key', + 'View Pricing', + ); + if (action === 'Sign In') vscode.commands.executeCommand('velxio.signIn'); + if (action === 'Paste License Key') vscode.commands.executeCommand('velxio.pasteLicenseKey'); + if (action === 'View Pricing') { + vscode.env.openExternal(vscode.Uri.parse('https://velxio.dev/pricing')); + } + return false; + } + try { + const result = await licenseService.requireValid(); + await refreshLicenseStatusBar(result); + return true; + } catch (err) { + if (err instanceof OfflineError) { + vscode.window.showErrorMessage(err.message); + return false; + } + if (err instanceof EntitlementError) { + const reason = err.result.reason_code; + const buttons = + reason === 'trial_expired' || reason === 'expired' + ? ['Open Billing', 'Sign In with Different Account'] + : ['Sign In', 'Paste License Key', 'View Pricing']; + const action = await vscode.window.showErrorMessage(err.message, ...buttons); + if (action === 'Open Billing') { + vscode.env.openExternal(vscode.Uri.parse('https://velxio.dev/billing')); + } + if (action === 'Sign In' || action === 'Sign In with Different Account') { + vscode.commands.executeCommand('velxio.signIn'); + } + if (action === 'Paste License Key') { + vscode.commands.executeCommand('velxio.pasteLicenseKey'); + } + if (action === 'View Pricing') { + vscode.env.openExternal(vscode.Uri.parse('https://velxio.dev/pricing')); + } + await refreshLicenseStatusBar(err.result); + return false; + } + vscode.window.showErrorMessage( + `Velxio license check failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return false; + } +} + +async function handleAuthUri(uri: vscode.Uri): Promise { + if (uri.path !== '/auth') { + outputChannel.appendLine(`[license] unrecognised URI path: ${uri.path}`); + return; + } + const query = new URLSearchParams(uri.query); + const token = query.get('token'); + const state = query.get('state'); + try { + const result = await licenseService.completeSignIn(token, state); + if (result.valid && result.entitlements?.vscode_ext) { + vscode.window.showInformationMessage( + `Signed in to Velxio. Plan: ${result.plan ?? 'unknown'}.`, + ); + } else { + vscode.window.showWarningMessage(licenseService.reasonToMessage(result)); + } + } catch (err) { + vscode.window.showErrorMessage( + `Sign-in failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + await refreshLicenseStatusBar(); +} + +function renderLicenseStatusBar(result: ValidationResult | null): void { + if (!result) { + licenseStatusBarItem.text = '$(circle-slash) Velxio: Sign in'; + licenseStatusBarItem.tooltip = 'Click to sign in or paste a license key'; + licenseStatusBarItem.backgroundColor = new vscode.ThemeColor( + 'statusBarItem.warningBackground', + ); + return; + } + if (!result.valid) { + licenseStatusBarItem.text = + result.reason_code === 'trial_expired' + ? '$(error) Velxio: Trial ended' + : '$(error) Velxio: Inactive'; + licenseStatusBarItem.tooltip = licenseService.reasonToMessage(result); + licenseStatusBarItem.backgroundColor = new vscode.ThemeColor( + 'statusBarItem.errorBackground', + ); + return; + } + if (result.plan === 'trial' && result.trial_ends_at) { + const daysLeft = Math.max( + 0, + Math.floor((new Date(result.trial_ends_at).getTime() - Date.now()) / 86_400_000), + ); + licenseStatusBarItem.text = `$(clock) Velxio: Trial ${daysLeft}d`; + licenseStatusBarItem.tooltip = `Trial ends ${new Date(result.trial_ends_at).toLocaleString()}`; + licenseStatusBarItem.backgroundColor = undefined; + return; + } + if (result.plan === 'pro_max') { + licenseStatusBarItem.text = '$(verified) Velxio: Pro Max'; + } else if (result.plan === 'pro') { + licenseStatusBarItem.text = '$(verified) Velxio: Pro'; + } else { + licenseStatusBarItem.text = `$(verified) Velxio: ${result.plan ?? 'active'}`; + } + licenseStatusBarItem.tooltip = 'License active. Click for details.'; + licenseStatusBarItem.backgroundColor = undefined; +} + +async function refreshLicenseStatusBar(known?: ValidationResult): Promise { + if (known) { + renderLicenseStatusBar(known); + return; + } + const key = await licenseService.getKey(); + if (!key) { + renderLicenseStatusBar(null); + return; + } + try { + const result = await licenseService.validate(); + renderLicenseStatusBar(result); + } catch { + // Network down — leave the previous render in place. The next + // compile attempt will surface the OfflineError to the user. + } +} + +function renderStatusSummary(result: ValidationResult): string { + const lines: string[] = []; + lines.push(`Status: ${result.valid ? 'active' : 'inactive'}`); + if (result.plan) lines.push(`Plan: ${result.plan}`); + if (result.trial_ends_at) { + lines.push(`Trial ends: ${new Date(result.trial_ends_at).toLocaleString()}`); + } + if (result.subscription_period_end) { + lines.push( + `Renews / expires: ${new Date(result.subscription_period_end).toLocaleString()}`, + ); + } + if (!result.valid) lines.push(licenseService.reasonToMessage(result)); + return lines.join(' · '); +}