feat(vscode-extension): 0.2.0 — Pro license gate + deep-link sign-in
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) <noreply@anthropic.com>
This commit is contained in:
parent
04d93d4f46
commit
201d414aa7
|
|
@ -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.
|
||||
|
|
@ -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 <https://velxio.dev/billing>.
|
||||
|
||||
## 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
|
||||
<https://velxio.dev/billing>. 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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 `<apiBase>/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<Entitlements>;
|
||||
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<string>('licenseApiBase') ?? 'https://velxio.dev').trim();
|
||||
return raw.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
// ── Key storage ─────────────────────────────────────────────────────────
|
||||
|
||||
async getKey(): Promise<string | undefined> {
|
||||
return this.context.secrets.get(SECRET_LICENSE_KEY);
|
||||
}
|
||||
|
||||
async setKey(key: string): Promise<void> {
|
||||
await this.context.secrets.store(SECRET_LICENSE_KEY, key.trim());
|
||||
this.invalidateCache();
|
||||
}
|
||||
|
||||
async clearKey(): Promise<void> {
|
||||
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<ValidationResult> {
|
||||
if (!token || !state) {
|
||||
throw new Error('Sign-in callback is missing token or state.');
|
||||
}
|
||||
const pending = this.context.globalState.get<string>(STATE_PENDING_NONCE);
|
||||
const pendingExp = this.context.globalState.get<number>(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<ValidationResult> {
|
||||
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<ValidationResult> {
|
||||
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.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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_<plan>_<hex>).';
|
||||
}
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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(' · ');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue