Skip to content
Merged
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
12 changes: 12 additions & 0 deletions messages/lightning.preview.app.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ Type of device to emulate in preview.

For mobile virtual devices, specify the device ID to preview. If omitted, the first available virtual device will be used.

# error.username

Org must have a valid user

# error.identitydata

Couldn't find identity data while generating preview arguments

# error.identitydata.entityid

Couldn't find entity ID while generating preview arguments

# error.no-project

This command is required to run from within a Salesforce project directory. %s
Expand Down
4 changes: 2 additions & 2 deletions messages/shared.utils.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ The workspace name of the local lwc dev server

Valid workspace value is "SalesforceCLI" OR "mrt"

# config-utils.token-desc
# config-utils.data-desc

The Base64-encoded identity token of the local web server
The identity data is a data structure that links the local web server's identity token to the user's configured Salesforce orgs.

# config-utils.cert-desc

Expand Down
60 changes: 53 additions & 7 deletions src/commands/lightning/preview/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import path from 'node:path';
import * as readline from 'node:readline';
import { Logger, Messages, SfProject } from '@salesforce/core';
import { Connection, Logger, Messages, SfProject } from '@salesforce/core';
import {
AndroidAppPreviewConfig,
AndroidVirtualDevice,
Expand All @@ -21,6 +21,7 @@ import chalk from 'chalk';
import { OrgUtils } from '../../../shared/orgUtils.js';
import { startLWCServer } from '../../../lwc-dev-server/index.js';
import { PreviewUtils } from '../../../shared/previewUtils.js';
import { ConfigUtils, IdentityTokenService } from '../../../shared/configUtils.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'lightning.preview.app');
Expand All @@ -38,6 +39,22 @@ export const androidSalesforceAppPreviewConfig = {

const maxInt32 = 2_147_483_647; // maximum 32-bit signed integer value

class AppServerIdentityTokenService implements IdentityTokenService {
private connection: Connection;
public constructor(connection: Connection) {
this.connection = connection;
}

public async saveTokenToServer(token: string): Promise<string> {
const sobject = this.connection.sobject('UserLocalWebServerIdentity');
const result = await sobject.insert({ LocalWebServerIdentityToken: token });
if (result.success) {
return result.id;
}
throw new Error('Could not save the token to the server');
}
}

export default class LightningPreviewApp extends SfCommand<void> {
public static readonly summary = messages.getMessage('summary');
public static readonly description = messages.getMessage('description');
Expand Down Expand Up @@ -157,13 +174,23 @@ export default class LightningPreviewApp extends SfCommand<void> {
return Promise.reject(new Error(messages.getMessage('error.no-project', [(error as Error)?.message ?? ''])));
}

logger.debug('Configuring local web server identity');
const connection = targetOrg.getConnection(undefined);
const username = connection.getUsername();
if (!username) {
return Promise.reject(new Error(messages.getMessage('error.username')));
}

const tokenService = new AppServerIdentityTokenService(connection);
const token = await ConfigUtils.getOrCreateIdentityToken(username, tokenService);

let appId: string | undefined;
if (appName) {
logger.debug(`Determining App Id for ${appName}`);

// The appName is optional but if the user did provide an appName then it must be
// a valid one.... meaning that it should resolve to a valid appId.
appId = await OrgUtils.getAppId(targetOrg.getConnection(undefined), appName);
appId = await OrgUtils.getAppId(connection, appName);
if (!appId) {
return Promise.reject(new Error(messages.getMessage('error.fetching.app-id', [appName])));
}
Expand All @@ -179,13 +206,17 @@ export default class LightningPreviewApp extends SfCommand<void> {
const ldpServerUrl = PreviewUtils.generateWebSocketUrlForLocalDevServer(platform, serverPort, logger);
logger.debug(`Local Dev Server url is ${ldpServerUrl}`);

const entityId = await PreviewUtils.getEntityId(username);

if (platform === Platform.desktop) {
await this.desktopPreview(sfdxProjectRootPath, serverPort, ldpServerUrl, appId, logger);
await this.desktopPreview(sfdxProjectRootPath, serverPort, token, entityId, ldpServerUrl, appId, logger);
} else {
await this.mobilePreview(
platform,
sfdxProjectRootPath,
serverPort,
token,
entityId,
ldpServerUrl,
appName,
appId,
Expand All @@ -198,6 +229,8 @@ export default class LightningPreviewApp extends SfCommand<void> {
private async desktopPreview(
sfdxProjectRootPath: string,
serverPort: number,
token: string,
entityId: string,
ldpServerUrl: string,
appId: string | undefined,
logger: Logger
Expand Down Expand Up @@ -230,10 +263,15 @@ export default class LightningPreviewApp extends SfCommand<void> {
this.log(`\n${messages.getMessage('trust.local.dev.server')}`);
}

const launchArguments = PreviewUtils.generateDesktopPreviewLaunchArguments(ldpServerUrl, appId, targetOrg);
const launchArguments = PreviewUtils.generateDesktopPreviewLaunchArguments(
ldpServerUrl,
entityId,
appId,
targetOrg
);

// Start the LWC Dev Server
await startLWCServer(logger, sfdxProjectRootPath, serverPort);
await startLWCServer(logger, sfdxProjectRootPath, token, serverPort);

// Open the browser and navigate to the right page
await this.config.runCommand('org:open', launchArguments);
Expand All @@ -243,6 +281,8 @@ export default class LightningPreviewApp extends SfCommand<void> {
platform: Platform.ios | Platform.android,
sfdxProjectRootPath: string,
serverPort: number,
token: string,
entityId: string,
ldpServerUrl: string,
appName: string | undefined,
appId: string | undefined,
Expand Down Expand Up @@ -316,11 +356,17 @@ export default class LightningPreviewApp extends SfCommand<void> {
}

// Start the LWC Dev Server
await startLWCServer(logger, sfdxProjectRootPath, serverPort, certData);

await startLWCServer(logger, sfdxProjectRootPath, token, serverPort, certData);

// Launch the native app for previewing (launchMobileApp will show its own spinner)
// eslint-disable-next-line camelcase
appConfig.launch_arguments = PreviewUtils.generateMobileAppPreviewLaunchArguments(ldpServerUrl, appName, appId);
appConfig.launch_arguments = PreviewUtils.generateMobileAppPreviewLaunchArguments(
ldpServerUrl,
entityId,
appName,
appId
);
await PreviewUtils.launchMobileApp(platform, appConfig, resolvedDeviceId, emulatorPort, bundlePath, logger);
} finally {
// stop progress & spinner UX (that may still be running in case of an error)
Expand Down
12 changes: 6 additions & 6 deletions src/configMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { ConfigPropertyMeta, ConfigValue, Messages } from '@salesforce/core';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'shared.utils');
const IDENTITY_TOKEN_DESC = messages.getMessage('config-utils.token-desc');
const IDENTITY_DATA_DESC = messages.getMessage('config-utils.data-desc');
const LOCAL_DEV_SERVER_CERT_DESC = messages.getMessage('config-utils.cert-desc');
const LOCAL_DEV_SERVER_CERT_ERROR_MESSAGE = messages.getMessage('config-utils.cert-error-message');
const LOCAL_DEV_SERVER_PORT_DESC = messages.getMessage('config-utils.port-desc');
Expand All @@ -27,10 +27,10 @@ export type SerializedSSLCertificateData = {

export const enum ConfigVars {
/**
* The Base64-encoded identity token of the local web server, used to
* validate the web server's identity to the hmr-client.
* The identity data is a data structure that links the local web server's
* identity token to the user's configured Salesforce orgs.
*/
LOCAL_WEB_SERVER_IDENTITY_TOKEN = 'local-web-server-identity-token',
LOCAL_WEB_SERVER_IDENTITY_DATA = 'local-web-server-identity-data',

/**
* The SSL certificate data to be used by local dev server
Expand All @@ -50,8 +50,8 @@ export const enum ConfigVars {

export default [
{
key: ConfigVars.LOCAL_WEB_SERVER_IDENTITY_TOKEN,
description: IDENTITY_TOKEN_DESC,
key: ConfigVars.LOCAL_WEB_SERVER_IDENTITY_DATA,
description: IDENTITY_DATA_DESC,
hidden: true,
encrypted: true,
},
Expand Down
12 changes: 6 additions & 6 deletions src/lwc-dev-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ function mapLogLevel(cliLogLevel: number): number {
async function createLWCServerConfig(
logger: Logger,
rootDir: string,
token: string,
serverPort?: number,
certData?: SSLCertificateData,
workspace?: Workspace,
token?: string
workspace?: Workspace
): Promise<ServerConfig> {
const sfdxConfig = path.resolve(rootDir, 'sfdx-project.json');

Expand Down Expand Up @@ -83,7 +83,7 @@ async function createLWCServerConfig(
paths: namespacePaths,
// use custom workspace if any is provided, or fetch from config file (if any), otherwise use the default workspace
workspace: workspace ?? (await ConfigUtils.getLocalDevServerWorkspace()) ?? LOCAL_DEV_SERVER_DEFAULT_WORKSPACE,
identityToken: token ?? (await ConfigUtils.getOrCreateIdentityToken()),
identityToken: token,
logLevel: mapLogLevel(logger.getLevel()),
};

Expand All @@ -100,12 +100,12 @@ async function createLWCServerConfig(
export async function startLWCServer(
logger: Logger,
rootDir: string,
token: string,
serverPort?: number,
certData?: SSLCertificateData,
workspace?: Workspace,
token?: string
workspace?: Workspace
): Promise<LWCServer> {
const config = await createLWCServerConfig(logger, rootDir, serverPort, certData, workspace, token);
const config = await createLWCServerConfig(logger, rootDir, token, serverPort, certData, workspace);

logger.trace(`Starting LWC Dev Server with config: ${JSON.stringify(config)}`);
let lwcDevServer: LWCServer | null = await startLwcDevServer(config);
Expand Down
82 changes: 55 additions & 27 deletions src/shared/configUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,29 @@ import { CryptoUtils, SSLCertificateData } from '@salesforce/lwc-dev-mobile-core
import { Config, ConfigAggregator } from '@salesforce/core';
import configMeta, { ConfigVars, SerializedSSLCertificateData } from './../configMeta.js';

export type IdentityTokenService = {
saveTokenToServer(token: string): Promise<string>;
};

export const LOCAL_DEV_SERVER_DEFAULT_PORT = 8081;
export const LOCAL_DEV_SERVER_DEFAULT_WORKSPACE = Workspace.SfCli;

export type LocalWebServerIdentityData = {
identityToken: string;
usernameToServerEntityIdMap: Record<string, string>;
};

export class ConfigUtils {
static #config: Config;
static #localConfig: Config;
static #globalConfig: Config;

public static async getConfig(): Promise<Config> {
if (this.#config) {
return this.#config;
public static async getLocalConfig(): Promise<Config> {
if (this.#localConfig) {
return this.#localConfig;
}
this.#config = await Config.create({ isGlobal: false });
this.#localConfig = await Config.create({ isGlobal: false });
Config.addAllowedProperties(configMeta);
return this.#config;
return this.#localConfig;
}

public static async getGlobalConfig(): Promise<Config> {
Expand All @@ -35,32 +44,39 @@ export class ConfigUtils {
return this.#globalConfig;
}

public static async getOrCreateIdentityToken(): Promise<string> {
let token = await this.getIdentityToken();
if (!token) {
token = CryptoUtils.generateIdentityToken();
await this.writeIdentityToken(token);
public static async getOrCreateIdentityToken(username: string, tokenService: IdentityTokenService): Promise<string> {
let identityData = await this.getIdentityData();
if (!identityData) {
const token = CryptoUtils.generateIdentityToken();
const entityId = await tokenService.saveTokenToServer(token);
identityData = {
identityToken: token,
usernameToServerEntityIdMap: {},
};
identityData.usernameToServerEntityIdMap[username] = entityId;
await this.writeIdentityData(identityData);
return token;
} else {
let entityId = identityData.usernameToServerEntityIdMap[username];
if (!entityId) {
entityId = await tokenService.saveTokenToServer(identityData.identityToken);
identityData.usernameToServerEntityIdMap[username] = entityId;
await this.writeIdentityData(identityData);
}
return identityData.identityToken;
}
return token;
}

public static async getIdentityToken(): Promise<string | undefined> {
const config = await ConfigAggregator.create({ customConfigMeta: configMeta });
// Need to reload to make sure the values read are decrypted
await config.reload();
const identityToken = config.getPropertyValue(ConfigVars.LOCAL_WEB_SERVER_IDENTITY_TOKEN);

return identityToken as string;
}

public static async writeIdentityToken(token: string): Promise<void> {
const config = await this.getConfig();
config.set(ConfigVars.LOCAL_WEB_SERVER_IDENTITY_TOKEN, token);
public static async writeIdentityData(identityData: LocalWebServerIdentityData): Promise<void> {
const config = await this.getLocalConfig();
// TODO: JSON needs to be stringified in order for config.write to encrypt. When config.write()
// can encrypt JSON data to write it into config we shall remove stringify().
config.set(ConfigVars.LOCAL_WEB_SERVER_IDENTITY_DATA, JSON.stringify(identityData));
await config.write();
}

public static async getCertData(): Promise<SSLCertificateData | undefined> {
const config = await this.getGlobalConfig();
const config = await this.getLocalConfig();
const serializedData = config.get(ConfigVars.LOCAL_DEV_SERVER_HTTPS_CERT_DATA) as SerializedSSLCertificateData;
if (serializedData) {
const deserializedData: SSLCertificateData = {
Expand Down Expand Up @@ -89,16 +105,28 @@ export class ConfigUtils {
}

public static async getLocalDevServerPort(): Promise<number | undefined> {
const config = await this.getConfig();
const config = await this.getLocalConfig();
const configPort = config.get(ConfigVars.LOCAL_DEV_SERVER_PORT) as number;

return configPort;
}

public static async getLocalDevServerWorkspace(): Promise<Workspace | undefined> {
const config = await this.getConfig();
const config = await this.getLocalConfig();
const configWorkspace = config.get(ConfigVars.LOCAL_DEV_SERVER_WORKSPACE) as Workspace;

return configWorkspace;
}

public static async getIdentityData(): Promise<LocalWebServerIdentityData | undefined> {
const config = await ConfigAggregator.create({ customConfigMeta: configMeta });
// Need to reload to make sure the values read are decrypted
await config.reload();
const identityJson = config.getPropertyValue(ConfigVars.LOCAL_WEB_SERVER_IDENTITY_DATA);

if (identityJson) {
return JSON.parse(identityJson as string) as LocalWebServerIdentityData;
}
return undefined;
}
}
Loading