arduino-ide/arduino-ide-extension/src/node/boards-service-impl.ts
Akos Kitta 4217c0001d fix: add missing installed version to the platform
Closes arduino/arduino-ide#2378

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2024-02-21 17:01:28 +01:00

624 lines
19 KiB
TypeScript

import { ILogger } from '@theia/core/lib/common/logger';
import { nls } from '@theia/core/lib/common/nls';
import { notEmpty } from '@theia/core/lib/common/objects';
import { Mutable } from '@theia/core/lib/common/types';
import { inject, injectable } from '@theia/core/shared/inversify';
import {
BoardDetails,
BoardSearch,
BoardUserField,
BoardWithPackage,
BoardsPackage,
BoardsService,
CheckDebugEnabledParams,
ConfigOption,
ConfigValue,
DetectedPorts,
Installable,
NotificationServiceServer,
Programmer,
ResponseService,
SortGroup,
createPlatformIdentifier,
platformIdentifierEquals,
platformInstallFailed,
sortComponents,
} from '../common/protocol';
import { BoardDiscovery } from './board-discovery';
import {
BoardDetailsRequest,
BoardDetailsResponse,
BoardListAllRequest,
BoardListAllResponse,
BoardSearchRequest,
} from './cli-protocol/cc/arduino/cli/commands/v1/board_pb';
import { PlatformSummary } from './cli-protocol/cc/arduino/cli/commands/v1/common_pb';
import {
PlatformInstallRequest,
PlatformSearchRequest,
PlatformSearchResponse,
PlatformUninstallRequest,
} from './cli-protocol/cc/arduino/cli/commands/v1/core_pb';
import { IsDebugSupportedRequest } from './cli-protocol/cc/arduino/cli/commands/v1/debug_pb';
import {
ListProgrammersAvailableForUploadRequest,
ListProgrammersAvailableForUploadResponse,
SupportedUserFieldsRequest,
SupportedUserFieldsResponse,
} from './cli-protocol/cc/arduino/cli/commands/v1/upload_pb';
import { CoreClientAware } from './core-client-provider';
import { ExecuteWithProgress } from './grpc-progressible';
import { ServiceError } from './service-error';
@injectable()
export class BoardsServiceImpl
extends CoreClientAware
implements BoardsService
{
@inject(ILogger)
protected logger: ILogger;
@inject(ResponseService)
protected readonly responseService: ResponseService;
@inject(NotificationServiceServer)
protected readonly notificationService: NotificationServiceServer;
@inject(BoardDiscovery)
protected readonly boardDiscovery: BoardDiscovery;
async getDetectedPorts(): Promise<DetectedPorts> {
return this.boardDiscovery.detectedPorts;
}
async getBoardDetails(options: {
fqbn: string;
}): Promise<BoardDetails | undefined> {
const coreClient = await this.coreClient;
const { client, instance } = coreClient;
const { fqbn } = options;
const detailsReq = new BoardDetailsRequest();
detailsReq.setInstance(instance);
detailsReq.setFqbn(fqbn);
const detailsResp = await new Promise<BoardDetailsResponse | undefined>(
(resolve, reject) =>
client.boardDetails(detailsReq, (err, resp) => {
if (err) {
if (isMissingPlatformError(err)) {
resolve(undefined);
return;
}
reject(err);
return;
}
resolve(resp);
})
);
if (!detailsResp) {
return undefined;
}
const requiredTools = detailsResp.getToolsDependenciesList().map((t) => ({
name: t.getName(),
packager: t.getPackager(),
version: t.getVersion(),
}));
const configOptions = detailsResp.getConfigOptionsList().map(
(c) =>
<ConfigOption>{
label: c.getOptionLabel(),
option: c.getOption(),
values: c.getValuesList().map(
(v) =>
<ConfigValue>{
value: v.getValue(),
label: v.getValueLabel(),
selected: v.getSelected(),
}
),
}
);
const listReq = new ListProgrammersAvailableForUploadRequest();
listReq.setInstance(instance);
listReq.setFqbn(fqbn);
const listResp =
await new Promise<ListProgrammersAvailableForUploadResponse>(
(resolve, reject) =>
client.listProgrammersAvailableForUpload(listReq, (err, resp) => {
if (err) {
reject(err);
return;
}
resolve(resp);
})
);
const programmers = listResp.getProgrammersList().map(
(p) =>
<Programmer>{
id: p.getId(),
name: p.getName(),
platform: p.getPlatform(),
}
);
const defaultProgrammerId = detailsResp.getDefaultProgrammerId();
let VID = 'N/A';
let PID = 'N/A';
const prop = detailsResp
.getIdentificationPropertiesList()
.map((item) => item.getPropertiesMap())
.find(notEmpty);
if (prop) {
VID = prop.get('vid') || '';
PID = prop.get('pid') || '';
}
const buildProperties = detailsResp.getBuildPropertiesList();
return {
fqbn,
requiredTools,
configOptions,
programmers,
VID,
PID,
buildProperties,
...(defaultProgrammerId ? { defaultProgrammerId } : {}),
};
}
async checkDebugEnabled(params: CheckDebugEnabledParams): Promise<string> {
const { fqbn, programmer } = params;
const { client, instance } = await this.coreClient;
const req = new IsDebugSupportedRequest()
.setInstance(instance)
.setFqbn(fqbn)
.setProgrammer(programmer ?? '');
try {
const debugFqbn = await new Promise<string>((resolve, reject) =>
client.isDebugSupported(req, (err, resp) => {
if (err) {
reject(err);
return;
}
if (resp.getDebuggingSupported()) {
const debugFqbn = resp.getDebugFqbn();
if (debugFqbn) {
resolve(debugFqbn);
}
}
reject(new Error(`Debugging is not supported.`));
})
);
return debugFqbn;
} catch (err) {
console.error(`Failed to get debug config: ${fqbn}, ${programmer}`, err);
throw err;
}
}
async getBoardPackage(options: {
id: string;
}): Promise<BoardsPackage | undefined> {
const { id: expectedId } = options;
if (!expectedId) {
return undefined;
}
const packages = await this.search({ query: expectedId });
return packages.find(({ id }) => id === expectedId);
}
async getContainerBoardPackage(options: {
fqbn: string;
}): Promise<BoardsPackage | undefined> {
const { fqbn: expectedFqbn } = options;
if (!expectedFqbn) {
return undefined;
}
const packages = await this.search({});
return packages.find(({ boards }) =>
boards.some(({ fqbn }) => fqbn === expectedFqbn)
);
}
async searchBoards({
query,
}: {
query?: string;
}): Promise<BoardWithPackage[]> {
const { instance, client } = await this.coreClient;
const req = new BoardSearchRequest();
req.setSearchArgs(query || '');
req.setInstance(instance);
return this.handleListBoards(client.boardSearch.bind(client), req);
}
async getInstalledBoards(): Promise<BoardWithPackage[]> {
const { instance, client } = await this.coreClient;
const req = new BoardListAllRequest();
req.setInstance(instance);
return this.handleListBoards(client.boardListAll.bind(client), req);
}
async getInstalledPlatforms(): Promise<BoardsPackage[]> {
const { instance, client } = await this.coreClient;
const resp = await new Promise<PlatformSearchResponse>(
(resolve, reject) => {
client.platformSearch(
new PlatformSearchRequest()
.setInstance(instance)
.setManuallyInstalled(true), // include core manually installed to the sketchbook
(err, resp) => (err ? reject(err) : resolve(resp))
);
}
);
const searchOutput = resp.getSearchOutputList();
return searchOutput
.map((message) => message.toObject(false))
.filter((summary) => summary.installedVersion) // only installed ones
.map(createBoardsPackage)
.filter(notEmpty);
}
private async handleListBoards(
getBoards: (
request: BoardListAllRequest | BoardSearchRequest,
callback: (
error: ServiceError | null,
response: BoardListAllResponse
) => void
) => void,
request: BoardListAllRequest | BoardSearchRequest
): Promise<BoardWithPackage[]> {
const boards = await new Promise<BoardWithPackage[]>((resolve, reject) => {
getBoards(request, (error, resp) => {
if (error) {
reject(error);
return;
}
const boards: Array<BoardWithPackage> = [];
for (const board of resp.getBoardsList()) {
const platform = board.getPlatform();
if (platform) {
const metadata = platform.getMetadata();
if (!metadata) {
console.warn(
`Platform metadata is missing for platform: ${JSON.stringify(
platform.toObject(false)
)}. Skipping`
);
continue;
}
const platformId = metadata.getId();
const release = platform.getRelease();
if (!release) {
console.warn(
`Platform release is missing for platform: ${platformId}. Skipping`
);
continue;
}
const fqbn = board.getFqbn() || undefined; // prefer undefined over empty string
const parsedPlatformId = createPlatformIdentifier(platformId);
if (!parsedPlatformId) {
console.warn(
`Could not create platform identifier from platform ID input: ${platformId}. Skipping`
);
continue;
}
if (fqbn) {
const checkPlatformId = createPlatformIdentifier(board.getFqbn());
if (!checkPlatformId) {
console.warn(
`Could not create platform identifier from FQBN input: ${board.getFqbn()}. Skipping`
);
continue;
}
if (
!platformIdentifierEquals(parsedPlatformId, checkPlatformId)
) {
console.warn(
`Mismatching platform identifiers. Platform: ${JSON.stringify(
parsedPlatformId
)}, FQBN: ${JSON.stringify(checkPlatformId)}. Skipping`
);
continue;
}
}
boards.push({
name: board.getName(),
fqbn: board.getFqbn(),
packageId: parsedPlatformId,
packageName: release.getName(),
manuallyInstalled: metadata.getManuallyInstalled(),
});
}
}
resolve(boards);
});
});
return boards;
}
async getBoardUserFields(options: {
fqbn: string;
protocol: string;
}): Promise<BoardUserField[]> {
const coreClient = await this.coreClient;
const { client, instance } = coreClient;
const req = new SupportedUserFieldsRequest();
req.setInstance(instance);
req.setFqbn(options.fqbn);
req.setProtocol(options.protocol);
const resp = await new Promise<SupportedUserFieldsResponse | undefined>(
(resolve, reject) => {
client.supportedUserFields(req, (err, resp) => {
if (err) {
if (isMissingPlatformError(err)) {
resolve(undefined);
return;
}
reject(err);
return;
}
resolve(resp);
});
}
);
if (!resp) {
return [];
}
return resp.getUserFieldsList().map((e) => ({
toolId: e.getToolId(),
name: e.getName(),
label: e.getLabel(),
secret: e.getSecret(),
value: '',
}));
}
async search(options: BoardSearch): Promise<BoardsPackage[]> {
const coreClient = await this.coreClient;
const { client, instance } = coreClient;
// `core search` returns with all platform versions when the command is executed via gRPC or with `--format json`
// The `--all` flag is applicable only when filtering for the human-readable (`--format text`) output of the CLI
const resp = await new Promise<PlatformSearchResponse>(
(resolve, reject) => {
client.platformSearch(
new PlatformSearchRequest()
.setInstance(instance)
.setSearchArgs(options.query ?? ''),
(err, resp) => (err ? reject(err) : resolve(resp))
);
}
);
const typeFilter = this.typePredicate(options);
const searchOutput = resp.getSearchOutputList();
const boardsPackages = searchOutput
.map((message) => message.toObject(false))
.map(createBoardsPackage)
.filter(notEmpty)
.filter(typeFilter);
return sortComponents(boardsPackages, boardsPackageSortGroup);
}
private typePredicate(
options: BoardSearch
): (item: BoardsPackage) => boolean {
const { type } = options;
if (!type || type === 'All') {
return () => true;
}
switch (options.type) {
case 'Updatable':
return Installable.Updateable;
case 'Arduino':
case 'Partner':
case 'Arduino@Heart':
case 'Contributed':
case 'Arduino Certified':
return ({ types }: BoardsPackage) => !!types && types?.includes(type);
default:
throw new Error(`Unhandled type: ${options.type}`);
}
}
async install(options: {
item: BoardsPackage;
progressId?: string;
version?: Installable.Version;
noOverwrite?: boolean;
skipPostInstall?: boolean;
}): Promise<void> {
const item = options.item;
const version = !!options.version
? options.version
: item.availableVersions[0];
const coreClient = await this.coreClient;
const { client, instance } = coreClient;
const [platform, architecture] = item.id.split(':');
const req = new PlatformInstallRequest();
req.setInstance(instance);
req.setArchitecture(architecture);
req.setPlatformPackage(platform);
req.setVersion(version);
req.setNoOverwrite(Boolean(options.noOverwrite));
if (options.skipPostInstall) {
req.setSkipPostInstall(true);
}
console.info('>>> Starting boards package installation...', item);
// stop the board discovery
await this.boardDiscovery.stop();
const resp = client.platformInstall(req);
resp.on(
'data',
ExecuteWithProgress.createDataCallback({
progressId: options.progressId,
responseService: this.responseService,
})
);
await new Promise<void>((resolve, reject) => {
resp.on('end', () => {
this.boardDiscovery.start(); // TODO: remove discovery dependency from boards service. See https://github.com/arduino/arduino-ide/pull/1107 why this is here.
resolve();
});
resp.on('error', (error) => {
this.responseService.appendToOutput({
chunk: `${platformInstallFailed(item.id, version)}\n`,
});
this.responseService.appendToOutput({
chunk: `${error.toString()}\n`,
});
reject(error);
});
});
const items = await this.search({});
const updated =
items.find((other) => BoardsPackage.equals(other, item)) || item;
this.notificationService.notifyPlatformDidInstall({ item: updated });
console.info('<<< Boards package installation done.', item);
}
async uninstall(options: {
item: BoardsPackage;
progressId?: string;
}): Promise<void> {
const { item, progressId } = options;
const coreClient = await this.coreClient;
const { client, instance } = coreClient;
const [platform, architecture] = item.id.split(':');
const req = new PlatformUninstallRequest();
req.setInstance(instance);
req.setArchitecture(architecture);
req.setPlatformPackage(platform);
console.info('>>> Starting boards package uninstallation...', item);
// stop the board discovery
await this.boardDiscovery.stop();
const resp = client.platformUninstall(req);
resp.on(
'data',
ExecuteWithProgress.createDataCallback({
progressId,
responseService: this.responseService,
})
);
await new Promise<void>((resolve, reject) => {
resp.on('end', () => {
this.boardDiscovery.start(); // TODO: remove discovery dependency from boards service. See https://github.com/arduino/arduino-ide/pull/1107 why this is here.
resolve();
});
resp.on('error', reject);
});
// Here, unlike at `install` we send out the argument `item`. Otherwise, we would not know about the board FQBN.
this.notificationService.notifyPlatformDidUninstall({ item });
console.info('<<< Boards package uninstallation done.', item);
}
}
function isMissingPlatformError(error: unknown): boolean {
if (ServiceError.is(error)) {
const message = error.details;
// TODO: check gRPC status code? `9 FAILED_PRECONDITION` (https://grpc.github.io/grpc/core/md_doc_statuscodes.html)
// When installing a 3rd party core that depends on a missing Arduino core.
// https://github.com/arduino/arduino-cli/issues/954
if (
message.includes('missing platform release') &&
message.includes('referenced by board')
) {
return true;
}
// When the platform is not installed.
if (message.includes('platform') && message.includes('not installed')) {
return true;
}
// It's a hack to handle https://github.com/arduino/arduino-cli/issues/1262 gracefully.
if (message.includes('unknown package')) {
return true;
}
}
return false;
}
function boardsPackageSortGroup(boardsPackage: BoardsPackage): SortGroup {
const types: string[] = [];
if (boardsPackage.types.includes('Arduino')) {
types.push('Arduino');
}
if (boardsPackage.deprecated) {
types.push('Retired');
}
return types.join('-') as SortGroup;
}
function createBoardsPackage(
summary: PlatformSummary.AsObject
): BoardsPackage | undefined {
if (!isPlatformSummaryWithMetadata(summary)) {
return undefined;
}
const versionReleaseMap = new Map(summary.releasesMap);
const actualRelease =
versionReleaseMap.get(summary.installedVersion) ??
versionReleaseMap.get(summary.latestVersion);
if (!actualRelease) {
return undefined;
}
const { name, typeList, boardsList, deprecated, compatible } = actualRelease;
if (!compatible) {
return undefined; // never show incompatible platforms
}
const { id, website, maintainer } = summary.metadata;
const availableVersions = Array.from(versionReleaseMap.keys())
.sort(Installable.Version.COMPARATOR)
.reverse();
const boardsPackage: Mutable<BoardsPackage> = {
id,
name,
summary: nls.localize(
'arduino/component/boardsIncluded',
'Boards included in this package:'
),
description: boardsList.map(({ name }) => name).join(', '),
boards: boardsList,
types: typeList,
moreInfoLink: website,
author: maintainer,
deprecated,
availableVersions,
};
if (summary.installedVersion) {
boardsPackage.installedVersion = summary.installedVersion;
}
return boardsPackage;
}
type PlatformSummaryWithMetadata = PlatformSummary.AsObject &
Required<Pick<PlatformSummary.AsObject, 'metadata'>>;
function isPlatformSummaryWithMetadata(
summary: PlatformSummary.AsObject
): summary is PlatformSummaryWithMetadata {
return Boolean(summary.metadata);
}