arduino-ide/arduino-ide-extension/src/node/grpc-progressible.ts
Akos Kitta 153e34f11b chore(deps): update dependencies
To fix all security vulnerabilities detected by `Dependabot`.

 - remove `shelljs`. replace with `fs` and `console`.
 - remove `uuid`. replace with `@phosphor/coreutils`.

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-10-13 08:50:39 +02:00

402 lines
13 KiB
TypeScript

import { UUID } from '@theia/core/shared/@phosphor/coreutils';
import type {
IndexType,
IndexUpdateDidCompleteParams,
IndexUpdateDidFailParams,
IndexUpdateSummary,
IndexUpdateWillStartParams,
} from '../common/protocol';
import {
ProgressMessage,
ResponseService,
} from '../common/protocol/response-service';
import {
UpdateIndexResponse,
UpdateLibrariesIndexResponse,
} from './cli-protocol/cc/arduino/cli/commands/v1/commands_pb';
import {
DownloadProgress,
DownloadProgressEnd,
DownloadProgressStart,
DownloadProgressUpdate,
TaskProgress,
} from './cli-protocol/cc/arduino/cli/commands/v1/common_pb';
import { CompileResponse } from './cli-protocol/cc/arduino/cli/commands/v1/compile_pb';
import {
PlatformInstallResponse,
PlatformUninstallResponse,
} from './cli-protocol/cc/arduino/cli/commands/v1/core_pb';
import {
LibraryInstallResponse,
LibraryUninstallResponse,
ZipLibraryInstallResponse,
} from './cli-protocol/cc/arduino/cli/commands/v1/lib_pb';
import {
BurnBootloaderResponse,
UploadResponse,
UploadUsingProgrammerResponse,
} from './cli-protocol/cc/arduino/cli/commands/v1/upload_pb';
type LibraryProgressResponse =
| LibraryInstallResponse
| LibraryUninstallResponse
| ZipLibraryInstallResponse;
namespace LibraryProgressResponse {
export function is(response: unknown): response is LibraryProgressResponse {
return (
response instanceof LibraryInstallResponse ||
response instanceof LibraryUninstallResponse ||
response instanceof ZipLibraryInstallResponse
);
}
export function workUnit(response: LibraryProgressResponse): UnitOfWork {
return {
task: response.getTaskProgress(),
...(response instanceof LibraryInstallResponse && {
download: response.getProgress(),
}),
};
}
}
type PlatformProgressResponse =
| PlatformInstallResponse
| PlatformUninstallResponse;
namespace PlatformProgressResponse {
export function is(response: unknown): response is PlatformProgressResponse {
return (
response instanceof PlatformInstallResponse ||
response instanceof PlatformUninstallResponse
);
}
export function workUnit(response: PlatformProgressResponse): UnitOfWork {
return {
task: response.getTaskProgress(),
...(response instanceof PlatformInstallResponse && {
download: response.getProgress(),
}),
};
}
}
type IndexProgressResponse = UpdateIndexResponse | UpdateLibrariesIndexResponse;
namespace IndexProgressResponse {
export function is(response: unknown): response is IndexProgressResponse {
return (
response instanceof UpdateIndexResponse ||
response instanceof UpdateLibrariesIndexResponse
);
}
export function workUnit(response: IndexProgressResponse): UnitOfWork {
return {
download: response.getDownloadProgress(),
};
}
}
/**
* These responses have neither `task` nor `progress` property but for the sake of completeness
* on typings (from the gRPC API) and UX, these responses represent an indefinite progress.
*/
type IndefiniteProgressResponse =
| UploadResponse
| UploadUsingProgrammerResponse
| BurnBootloaderResponse;
namespace IndefiniteProgressResponse {
export function is(
response: unknown
): response is IndefiniteProgressResponse {
return (
response instanceof UploadResponse ||
response instanceof UploadUsingProgrammerResponse ||
response instanceof BurnBootloaderResponse
);
}
}
type DefiniteProgressResponse = CompileResponse;
namespace DefiniteProgressResponse {
export function is(response: unknown): response is DefiniteProgressResponse {
return response instanceof CompileResponse;
}
}
type CoreProgressResponse =
| DefiniteProgressResponse
| IndefiniteProgressResponse;
namespace CoreProgressResponse {
export function is(response: unknown): response is CoreProgressResponse {
return (
DefiniteProgressResponse.is(response) ||
IndefiniteProgressResponse.is(response)
);
}
export function workUnit(response: CoreProgressResponse): UnitOfWork {
if (DefiniteProgressResponse.is(response)) {
return { task: response.getProgress() };
}
return UnitOfWork.Unknown;
}
}
export type ProgressResponse =
| LibraryProgressResponse
| PlatformProgressResponse
| IndexProgressResponse
| CoreProgressResponse;
interface UnitOfWork {
task?: TaskProgress;
download?: DownloadProgress;
}
namespace UnitOfWork {
export const Unknown: UnitOfWork = {};
}
/**
* It's solely a dev thing. Flip it to `true` if you want to debug the progress from the CLI responses.
*/
const DEBUG = false;
export namespace ExecuteWithProgress {
export interface Options {
/**
* _unknown_ progress if falsy.
*/
readonly progressId?: string;
readonly responseService: Partial<ResponseService>;
/**
* It's only relevant for index updates to build a summary of possible client (4xx) and server (5xx) errors when downloading the files during the index update. It's missing for lib/platform installations.
*/
readonly reportResult?: (result: DownloadResult) => void;
}
export function createDataCallback<R extends ProgressResponse>({
responseService,
progressId,
reportResult,
}: ExecuteWithProgress.Options): (response: R) => void {
const uuid = UUID.uuid4();
let message = '';
let url = '';
return (response: R) => {
if (DEBUG) {
const json = toJson(response);
if (json) {
console.debug(`[gRPC progress] Progress response [${uuid}]: ${json}`);
}
}
const unitOfWork = resolve(response);
const { task, download } = unitOfWork;
if (!download && !task) {
// Report a fake unknown progress if progress ID is available.
// When a progress ID is available, a connected client is setting the progress ID.
// Hence, it's listening to progress updates.
if (unitOfWork === UnitOfWork.Unknown && progressId) {
if (progressId) {
responseService.reportProgress?.({
progressId,
message: '',
work: { done: Number.NaN, total: Number.NaN },
});
}
return;
}
if (DEBUG) {
// This is still an API error from the CLI, but IDE2 ignores it.
// Technically, it does not cause an error, but could mess up the progress reporting.
// See an example of an empty object `{}` repose here: https://github.com/arduino/arduino-ide/issues/906#issuecomment-1171145630.
console.warn(
`Implementation error. None of the following properties were available on the response: 'task', 'download'`
);
}
return;
}
if (task && download) {
throw new Error(
"Implementation error. Both 'download' and 'task' are available."
);
}
if (task) {
const message = task.getName() || task.getMessage();
const percent = task.getPercent();
if (message) {
if (progressId) {
responseService.reportProgress?.({
progressId,
message,
work: { done: Number.NaN, total: Number.NaN },
});
}
responseService.appendToOutput?.({ chunk: `${message}\n` });
} else if (percent) {
if (progressId) {
responseService.reportProgress?.({
progressId,
message,
work: { done: percent, total: 100 },
});
}
}
} else if (download) {
const phase = phaseOf(download);
if (phase instanceof DownloadProgressStart) {
message = phase.getLabel();
url = phase.getUrl();
responseService.appendToOutput?.({ chunk: `${message}\n` });
} else if (phase instanceof DownloadProgressUpdate) {
if (progressId && message) {
responseService.reportProgress?.({
progressId,
message,
work: {
total: phase.getTotalSize(),
done: phase.getDownloaded(),
},
});
}
} else if (phase instanceof DownloadProgressEnd) {
if (url && reportResult) {
reportResult({
url,
message: phase.getMessage(),
success: phase.getSuccess(),
});
}
message = '';
url = '';
}
}
};
}
function resolve(response: unknown): Readonly<Partial<UnitOfWork>> {
if (LibraryProgressResponse.is(response)) {
return LibraryProgressResponse.workUnit(response);
} else if (PlatformProgressResponse.is(response)) {
return PlatformProgressResponse.workUnit(response);
} else if (IndexProgressResponse.is(response)) {
return IndexProgressResponse.workUnit(response);
} else if (CoreProgressResponse.is(response)) {
return CoreProgressResponse.workUnit(response);
}
console.warn('Unhandled gRPC response', response);
return {};
}
function toJson(response: ProgressResponse): string | undefined {
return JSON.stringify(response.toObject(false));
}
function phaseOf(
download: DownloadProgress
): DownloadProgressStart | DownloadProgressUpdate | DownloadProgressEnd {
let start: undefined | DownloadProgressStart = undefined;
let update: undefined | DownloadProgressUpdate = undefined;
let end: undefined | DownloadProgressEnd = undefined;
if (download.hasStart()) {
start = download.getStart();
} else if (download.hasUpdate()) {
update = download.getUpdate();
} else if (download.hasEnd()) {
end = download.getEnd();
} else {
throw new Error(
`Download progress does not have a 'start', 'update', and 'end'. ${JSON.stringify(
download.toObject(false)
)}`
);
}
if (start) {
return start;
} else if (update) {
return update;
} else if (end) {
return end;
} else {
throw new Error(
`Download progress does not have a 'start', 'update', and 'end'. ${JSON.stringify(
download.toObject(false)
)}`
);
}
}
}
export class IndexesUpdateProgressHandler {
private done = 0;
private readonly total: number;
readonly progressId: string;
readonly results: DownloadResult[];
constructor(
private types: IndexType[],
additionalUrlsCount: number,
private readonly options: {
onProgress: (progressMessage: ProgressMessage) => void;
onError?: (params: IndexUpdateDidFailParams) => void;
onStart?: (params: IndexUpdateWillStartParams) => void;
onComplete?: (params: IndexUpdateDidCompleteParams) => void;
}
) {
this.progressId = UUID.uuid4();
this.results = [];
this.total = IndexesUpdateProgressHandler.total(types, additionalUrlsCount);
// Note: at this point, the IDE2 backend might not have any connected clients, so this notification is not delivered to anywhere
// Hence, clients must handle gracefully when no `willStart` event is received before any `didProgress`.
this.options.onStart?.({ progressId: this.progressId, types });
}
reportEnd(): void {
const updatedAt = new Date().toISOString();
this.options.onComplete?.({
progressId: this.progressId,
summary: this.types.reduce((summary, type) => {
summary[type] = updatedAt;
return summary;
}, {} as IndexUpdateSummary),
});
}
reportProgress(message: string): void {
this.options.onProgress({
message,
progressId: this.progressId,
work: { total: this.total, done: ++this.done },
});
}
reportError(message: string): void {
this.options.onError?.({
progressId: this.progressId,
message,
types: this.types,
});
}
reportResult(result: DownloadResult): void {
this.results.push(result);
}
private static total(
types: IndexType[],
additionalUrlsCount: number
): number {
let total = 0;
if (types.includes('library')) {
// The `library_index.json.gz` and `library_index.json.sig` when running the library index update.
total += 2;
}
if (types.includes('platform')) {
// +1 for the `package_index.tar.bz2` when updating the platform index.
total += additionalUrlsCount + 1;
}
// +1 for the `initInstance` call after the index update (`reportEnd`)
return total + 1;
}
}
export interface DownloadResult {
readonly url: string;
readonly success: boolean;
readonly message?: string;
}
export namespace DownloadResult {
export function isError(
arg: DownloadResult
): arg is DownloadResult & { message: string } {
return !!arg.message && !arg.success;
}
}