diff --git a/packages/simulator-server b/packages/simulator-server index 7783234f4..05c2eb82d 160000 --- a/packages/simulator-server +++ b/packages/simulator-server @@ -1 +1 @@ -Subproject commit 7783234f442f68d8781f9df462d988c0a69ace37 +Subproject commit 05c2eb82d898c49b7aa8b1b0fae3c1b5b8613317 diff --git a/packages/vscode-extension/src/devices/preview.ts b/packages/vscode-extension/src/devices/preview.ts index d0e673473..15ea7d24d 100644 --- a/packages/vscode-extension/src/devices/preview.ts +++ b/packages/vscode-extension/src/devices/preview.ts @@ -4,6 +4,7 @@ import { exec, ChildProcess, lineReader } from "../utilities/subprocess"; import { Logger } from "../Logger"; import { RecordingData, TouchPoint } from "../common/Project"; import { simulatorServerBinary } from "../utilities/simulatorServerBinary"; +import { watchLicenseTokenChange } from "../utilities/license"; interface VideoRecordingPromiseHandlers { resolve: (value: RecordingData) => void; @@ -13,12 +14,14 @@ interface VideoRecordingPromiseHandlers { export class Preview implements Disposable { private videoRecordingPromises = new Map(); private subprocess?: ChildProcess; + private tokenChangeListener?: Disposable; public streamURL?: string; constructor(private args: string[]) {} dispose() { this.subprocess?.kill(); + this.tokenChangeListener?.dispose(); } private sendCommandOrThrow(command: string) { @@ -63,6 +66,12 @@ export class Preview implements Disposable { }); this.subprocess = subprocess; + this.tokenChangeListener = watchLicenseTokenChange((token) => { + if (token) { + this.sendCommandOrThrow(`token ${token}\n`); + } + }); + return new Promise((resolve, reject) => { subprocess.catch(reject).then(() => { // we expect the preview server to produce a line with the URL diff --git a/packages/vscode-extension/src/project/project.ts b/packages/vscode-extension/src/project/project.ts index 7b47c303a..4669f6819 100644 --- a/packages/vscode-extension/src/project/project.ts +++ b/packages/vscode-extension/src/project/project.ts @@ -5,7 +5,6 @@ import stripAnsi from "strip-ansi"; import { minimatch } from "minimatch"; import { isEqual } from "lodash"; import { - ActivateDeviceResult, AppPermissionType, DeviceSettings, InspectData, @@ -33,7 +32,12 @@ import { Devtools } from "./devtools"; import { AppEvent, DeviceSession, EventDelegate } from "./deviceSession"; import { BuildCache } from "../builders/BuildCache"; import { PanelLocation } from "../common/WorkspaceConfig"; -import { activateDevice, getLicenseToken } from "../utilities/license"; +import { + activateDevice, + watchLicenseTokenChange, + getLicenseToken, + refreshTokenPeriodically, +} from "../utilities/license"; const DEVICE_SETTINGS_KEY = "device_settings_v4"; const LAST_SELECTED_DEVICE_KEY = "last_selected_device"; @@ -56,6 +60,8 @@ export class Project private isCachedBuildStale: boolean; private fileWatcher: Disposable; + private licenseWatcher: Disposable; + private licenseUpdater: Disposable; private deviceSession: DeviceSession | undefined; @@ -96,6 +102,11 @@ export class Project this.fileWatcher = watchProjectFiles(() => { this.checkIfNativeChanged(); }); + this.licenseUpdater = refreshTokenPeriodically(); + this.licenseWatcher = watchLicenseTokenChange(async () => { + const hasActiveLicense = await this.hasActiveLicense(); + this.eventEmitter.emit("licenseActivationChanged", hasActiveLicense); + }); } //#region Build progress @@ -278,6 +289,8 @@ export class Project this.devtools?.dispose(); this.deviceManager.removeListener("deviceRemoved", this.removeDeviceListener); this.fileWatcher.dispose(); + this.licenseWatcher.dispose(); + this.licenseUpdater.dispose(); } private async reloadMetro() { @@ -495,9 +508,6 @@ export class Project public async activateLicense(activationKey: string) { const computerName = os.hostname(); const activated = await activateDevice(activationKey, computerName); - if (activated === ActivateDeviceResult.succeeded) { - this.eventEmitter.emit("licenseActivationChanged", true); - } return activated; } diff --git a/packages/vscode-extension/src/utilities/license.ts b/packages/vscode-extension/src/utilities/license.ts index ddcf44f3b..95887cbef 100644 --- a/packages/vscode-extension/src/utilities/license.ts +++ b/packages/vscode-extension/src/utilities/license.ts @@ -1,13 +1,54 @@ -import fetch from "node-fetch"; +import fetch, { Response } from "node-fetch"; import { extensionContext } from "./extensionContext"; import { exec } from "./subprocess"; import { Logger } from "../Logger"; import { simulatorServerBinary } from "./simulatorServerBinary"; import { ActivateDeviceResult } from "../common/Project"; +import { throttleAsync } from "./throttle"; const TOKEN_KEY = "RNIDE_license_token_key"; +const TOKEN_KEY_TIMESTAMP = "RNIDE_license_token_key_timestamp"; const BASE_CUSTOMER_PORTAL_URL = "https://portal.ide.swmansion.com/"; +const LICENCE_TOKEN_REFRESH_INTERVAL = 1000 * 60 * 60 * 24; // 24 hours – how often to refresh the token (given successful token verification) +const LICENCE_TOKEN_REFRESH_RETRY_INTERVAL = 1000 * 60; // 1 minute – how often to retry refreshing the token + +export enum ServerResponseStatusCode { + success = "S001", + badRequest = "E001", + noSubscription = "E002", + allSeatTaken = "E003", + seatRemoved = "E004", + licenseExpired = "E005", + licenseCancelled = "E006", + noProductForSubscription = "E007", + internalError = "E101", +} + +export enum SimServerLicenseValidationResult { + Success, + Corrupted, + Expired, + FingerprintMismatch, +} + +async function saveTokenIfValid(response: Response) { + const responseBody = await response.json(); + if (response.ok) { + const newToken = responseBody.token as string; + const checkResult = await checkLicenseToken(newToken); + if (checkResult === SimServerLicenseValidationResult.Success && newToken) { + await extensionContext.secrets.store(TOKEN_KEY, newToken); + await extensionContext.globalState.update(TOKEN_KEY_TIMESTAMP, Date.now()); + return ServerResponseStatusCode.success; + } else { + Logger.warn("Fetched token is invalid, reason:", checkResult); + return ServerResponseStatusCode.noSubscription; + } + } + return responseBody.code as ServerResponseStatusCode; +} + export async function activateDevice( licenseKey: string, username: string @@ -29,57 +70,126 @@ export async function activateDevice( licenseKey, }; - let response; - try { - response = await fetch(url, { + const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(body), }); + const errorCode = await saveTokenIfValid(response); + switch (errorCode) { + case ServerResponseStatusCode.success: + return ActivateDeviceResult.succeeded; + case ServerResponseStatusCode.noSubscription: + return ActivateDeviceResult.keyVerificationFailed; + case ServerResponseStatusCode.allSeatTaken: + return ActivateDeviceResult.notEnoughSeats; + case ServerResponseStatusCode.badRequest: + default: + return ActivateDeviceResult.unableToVerify; + } } catch (e) { Logger.warn("Creating license token with license key failed", e); return ActivateDeviceResult.connectionFailed; } +} - const responseBody = await response.json(); +export function refreshTokenPeriodically() { + const refreshIfNeeded = throttleAsync(async () => { + const lastRefreshTimestamp = extensionContext.globalState.get(TOKEN_KEY_TIMESTAMP) || 0; + const timeSinceLastRefresh = Date.now() - lastRefreshTimestamp; + if (timeSinceLastRefresh > LICENCE_TOKEN_REFRESH_INTERVAL) { + const token = await getLicenseToken(); + if (token) { + await refreshToken(token); + } + } + }, 1); + const intervalId = setInterval(refreshIfNeeded, LICENCE_TOKEN_REFRESH_RETRY_INTERVAL); + refreshIfNeeded(); // trigger initial call as setInterval will wait for the first interval to pass + return { + dispose: () => { + clearInterval(intervalId); + }, + }; +} - if (response.ok) { - const newToken = responseBody.token as string; - await extensionContext.secrets.store(TOKEN_KEY, newToken ?? ""); - return ActivateDeviceResult.succeeded; - } +async function refreshToken(token: string) { + try { + const url = new URL("/api/refresh-token", BASE_CUSTOMER_PORTAL_URL); - if ( - response.status === 404 && - responseBody.error.startsWith("Could not find a subscription associated with license key") - ) { - return ActivateDeviceResult.keyVerificationFailed; - } + const body = { + token: token, + }; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + const errorCode = await saveTokenIfValid(response); - if ( - response.status === 400 && - responseBody.error.startsWith("All seats for a license with a key") - ) { - return ActivateDeviceResult.notEnoughSeats; + switch (errorCode) { + case ServerResponseStatusCode.success: + case ServerResponseStatusCode.internalError: + // in case of internal error with license server, we don't want to remove the license + // obviously, we also don't remove it on success + return; + default: + // in all the other cases when we get erroneous response, we remove the license + Logger.warn("Saved license can no longer be used, reason:", errorCode); + await removeLicense(); + return; + } + } catch (e) { + Logger.warn("Refreshing license token failed", e); } - - return ActivateDeviceResult.unableToVerify; -} - -async function generateDeviceFingerprint() { - const simControllerBinary = simulatorServerBinary(); - const { stdout } = await exec(simControllerBinary, ["fingerprint"]); - return stdout; } export async function removeLicense() { await extensionContext.secrets.delete(TOKEN_KEY); + await extensionContext.globalState.update(TOKEN_KEY_TIMESTAMP, undefined); } export async function getLicenseToken() { - const token = await extensionContext.secrets.get(TOKEN_KEY); - return token; + return await extensionContext.secrets.get(TOKEN_KEY); +} + +export function watchLicenseTokenChange(callback: (token: string | undefined) => void) { + getLicenseToken().then(callback); + return extensionContext.secrets.onDidChange((changeEvent) => { + if (changeEvent.key === TOKEN_KEY) { + getLicenseToken().then(callback); + } + }); +} + +export async function checkLicenseToken(token: string) { + const simControllerBinary = simulatorServerBinary(); + const { stdout } = await exec(simControllerBinary, ["verify_token", token]); + if (stdout === "token_valid") { + return SimServerLicenseValidationResult.Success; + } else { + try { + const reason = stdout.split(" ", 2)[1]; + switch (reason) { + case "expired": + return SimServerLicenseValidationResult.Expired; + case "fingerprint_mismatch": + return SimServerLicenseValidationResult.FingerprintMismatch; + } + } catch (e) { + Logger.error("Error parsing license token verification result", e); + } + return SimServerLicenseValidationResult.Corrupted; + } +} + +async function generateDeviceFingerprint() { + const simControllerBinary = simulatorServerBinary(); + const { stdout } = await exec(simControllerBinary, ["fingerprint"]); + return stdout; }