Files
arduino-ide/arduino-ide-extension/src/browser/create/create-api.ts
2022-03-08 17:11:18 +01:00

534 lines
15 KiB
TypeScript

import { injectable, inject } from 'inversify';
import * as createPaths from './create-paths';
import { posix } from './create-paths';
import { AuthenticationClientService } from '../auth/authentication-client-service';
import { ArduinoPreferences } from '../arduino-preferences';
import { SketchCache } from '../widgets/cloud-sketchbook/cloud-sketch-cache';
import { Create, CreateError } from './typings';
export interface ResponseResultProvider {
(response: Response): Promise<any>;
}
export namespace ResponseResultProvider {
export const NOOP: ResponseResultProvider = async () => undefined;
export const TEXT: ResponseResultProvider = (response) => response.text();
export const JSON: ResponseResultProvider = (response) => response.json();
}
export function Utf8ArrayToStr(array: Uint8Array): string {
let out, i, c;
let char2, char3;
out = '';
const len = array.length;
i = 0;
while (i < len) {
c = array[i++];
switch (c >> 4) {
case 0:
case 1:
case 2:
case 3:
case 4:
case 5:
case 6:
case 7:
// 0xxxxxxx
out += String.fromCharCode(c);
break;
case 12:
case 13:
// 110x xxxx 10xx xxxx
char2 = array[i++];
out += String.fromCharCode(((c & 0x1f) << 6) | (char2 & 0x3f));
break;
case 14:
// 1110 xxxx 10xx xxxx 10xx xxxx
char2 = array[i++];
char3 = array[i++];
out += String.fromCharCode(
((c & 0x0f) << 12) | ((char2 & 0x3f) << 6) | ((char3 & 0x3f) << 0)
);
break;
}
}
return out;
}
type ResourceType = 'f' | 'd';
@injectable()
export class CreateApi {
@inject(SketchCache)
protected sketchCache: SketchCache;
protected authenticationService: AuthenticationClientService;
protected arduinoPreferences: ArduinoPreferences;
public init(
authenticationService: AuthenticationClientService,
arduinoPreferences: ArduinoPreferences
): CreateApi {
this.authenticationService = authenticationService;
this.arduinoPreferences = arduinoPreferences;
return this;
}
getSketchSecretStat(sketch: Create.Sketch): Create.Resource {
return {
href: `${sketch.href}${posix.sep}${Create.arduino_secrets_file}`,
modified_at: sketch.modified_at,
created_at: sketch.created_at,
name: `${Create.arduino_secrets_file}`,
path: `${sketch.path}${posix.sep}${Create.arduino_secrets_file}`,
mimetype: 'text/x-c++src; charset=utf-8',
type: 'file',
};
}
async sketch(id: string): Promise<Create.Sketch> {
const url = new URL(`${this.domain()}/sketches/byID/${id}`);
url.searchParams.set('user_id', 'me');
const headers = await this.headers();
const result = await this.run<Create.Sketch>(url, {
method: 'GET',
headers,
});
return result;
}
async sketches(limit = 50): Promise<Create.Sketch[]> {
const url = new URL(`${this.domain()}/sketches`);
url.searchParams.set('user_id', 'me');
url.searchParams.set('limit', limit.toString());
const headers = await this.headers();
const result: { sketches: Create.Sketch[] } = { sketches: [] };
let partialSketches: Create.Sketch[] = [];
let currentOffset = 0;
do {
url.searchParams.set('offset', currentOffset.toString());
partialSketches = (
await this.run<{ sketches: Create.Sketch[] }>(url, {
method: 'GET',
headers,
})
).sketches;
if (partialSketches.length != 0) {
result.sketches = result.sketches.concat(partialSketches);
}
currentOffset = currentOffset + limit;
} while (partialSketches.length != 0);
result.sketches.forEach((sketch) => this.sketchCache.addSketch(sketch));
return result.sketches;
}
async createSketch(
posixPath: string,
content: string = CreateApi.defaultInoContent
): Promise<Create.Sketch> {
const url = new URL(`${this.domain()}/sketches`);
const headers = await this.headers();
const payload = {
ino: btoa(content),
path: posixPath,
user_id: 'me',
};
const init = {
method: 'PUT',
body: JSON.stringify(payload),
headers,
};
const result = await this.run<Create.Sketch>(url, init);
return result;
}
async readDirectory(
posixPath: string,
options: {
recursive?: boolean;
match?: string;
skipSketchCache?: boolean;
} = {}
): Promise<Create.Resource[]> {
const url = new URL(
`${this.domain()}/files/d/$HOME/sketches_v2${posixPath}`
);
if (options.recursive) {
url.searchParams.set('deep', 'true');
}
if (options.match) {
url.searchParams.set('name_like', options.match);
}
const headers = await this.headers();
const cachedSketch = this.sketchCache.getSketch(posixPath);
const sketchPromise = options.skipSketchCache
? (cachedSketch && this.sketch(cachedSketch.id)) || Promise.resolve(null)
: Promise.resolve(this.sketchCache.getSketch(posixPath));
return Promise.all([
sketchPromise,
this.run<Create.RawResource[]>(url, {
method: 'GET',
headers,
}),
])
.then(async ([sketch, result]) => {
if (posixPath.length && posixPath !== posix.sep) {
if (sketch && sketch.secrets && sketch.secrets.length > 0) {
result.push(this.getSketchSecretStat(sketch));
}
}
return result.filter(
(res) => !Create.do_not_sync_files.includes(res.name)
);
})
.catch((reason) => {
if (reason?.status === 404) return [] as Create.Resource[];
else throw reason;
});
}
async createDirectory(posixPath: string): Promise<void> {
const url = new URL(
`${this.domain()}/files/d/$HOME/sketches_v2${posixPath}`
);
const headers = await this.headers();
await this.run(url, {
method: 'POST',
headers,
});
}
async stat(posixPath: string): Promise<Create.Resource> {
// The root is a directory read.
if (posixPath === '/') {
throw new Error('Stating the root is not supported');
}
// The RESTful API has different endpoints for files and directories.
// The RESTful API does not provide specific error codes, only HTP 500.
// We query the parent directory and look for the file with the last segment.
const parentPosixPath = createPaths.parentPosix(posixPath);
const basename = createPaths.basename(posixPath);
let resources;
if (basename === Create.arduino_secrets_file) {
const sketch = this.sketchCache.getSketch(parentPosixPath);
resources = sketch ? [this.getSketchSecretStat(sketch)] : [];
} else {
resources = await this.readDirectory(parentPosixPath, {
match: basename,
});
}
const resource = resources.find(
({ path }) => createPaths.splitSketchPath(path)[1] === posixPath
);
if (!resource) {
throw new CreateError(`Not found: ${posixPath}.`, 404);
}
return resource;
}
private async toggleSecretsInclude(
path: string,
data: string,
mode: 'add' | 'remove'
) {
const includeString = `#include "${Create.arduino_secrets_file}"`;
const includeRegexp = new RegExp(includeString + '\\s*', 'g');
const basename = createPaths.basename(path);
if (mode === 'add') {
const doesIncludeSecrets = includeRegexp.test(data);
if (doesIncludeSecrets) {
return data;
}
const sketch = this.sketchCache.getSketch(createPaths.parentPosix(path));
if (
sketch &&
(sketch.name + '.ino' === basename ||
sketch.name + '.pde' === basename) &&
sketch.secrets &&
sketch.secrets.length > 0
) {
return includeString + '\n' + data;
}
} else if (mode === 'remove') {
return data.replace(includeRegexp, '');
}
return data;
}
async readFile(posixPath: string): Promise<string> {
const basename = createPaths.basename(posixPath);
if (basename === Create.arduino_secrets_file) {
const parentPosixPath = createPaths.parentPosix(posixPath);
//retrieve the sketch id from the cache
const cacheSketch = this.sketchCache.getSketch(parentPosixPath);
if (!cacheSketch) {
throw new Error(`Unable to find sketch ${parentPosixPath} in cache`);
}
// get a fresh copy of the sketch in order to guarantee fresh secrets
const sketch = await this.sketch(cacheSketch.id);
if (!sketch) {
throw new Error(
`Unable to get a fresh copy of the sketch ${cacheSketch.id}`
);
}
this.sketchCache.addSketch(sketch);
let file = '';
if (sketch && sketch.secrets) {
for (const item of sketch.secrets) {
file += `#define ${item.name} "${item.value}"\r\n`;
}
}
return file;
}
const url = new URL(
`${this.domain()}/files/f/$HOME/sketches_v2${posixPath}`
);
const headers = await this.headers();
const result = await this.run<{ data: string; path: string }>(url, {
method: 'GET',
headers,
});
let { data } = result;
// add includes to main arduino file
data = await this.toggleSecretsInclude(posixPath, atob(data), 'add');
return data;
}
async writeFile(
posixPath: string,
content: string | Uint8Array
): Promise<void> {
const basename = createPaths.basename(posixPath);
if (basename === Create.arduino_secrets_file) {
const parentPosixPath = createPaths.parentPosix(posixPath);
const sketch = this.sketchCache.getSketch(parentPosixPath);
if (sketch) {
const url = new URL(`${this.domain()}/sketches/${sketch.id}`);
const headers = await this.headers();
// parse the secret file
const secrets = (
typeof content === 'string' ? content : Utf8ArrayToStr(content)
)
.split(/\r?\n/)
.reduce((prev, curr) => {
// check if the line contains a secret
const secret = curr.split('SECRET_')[1] || null;
if (!secret) {
return prev;
}
const regexp = /(\S*)\s+([\S\s]*)/g;
const tokens = regexp.exec(secret) || [];
const name = tokens[1].length > 0 ? `SECRET_${tokens[1]}` : '';
let value = '';
if (tokens[2].length > 0) {
value = JSON.parse(
JSON.stringify(
tokens[2].replace(/^['"]?/g, '').replace(/['"]?$/g, '')
)
);
}
if (name.length === 0) {
return prev;
}
return [...prev, { name, value }];
}, []);
const payload = {
id: sketch.id,
libraries: sketch.libraries,
secrets: { data: secrets },
};
// replace the sketch in the cache with the one we are pushing
// TODO: we should do a get after the POST, in order to be sure the cache
// is updated the most recent metadata
this.sketchCache.addSketch(sketch);
const init = {
method: 'POST',
body: JSON.stringify(payload),
headers,
};
await this.run(url, init);
}
return;
}
// do not upload "do_not_sync" files/directoris and their descendants
const segments = posixPath.split(posix.sep) || [];
if (
segments.some((segment) => Create.do_not_sync_files.includes(segment))
) {
return;
}
const url = new URL(
`${this.domain()}/files/f/$HOME/sketches_v2${posixPath}`
);
const headers = await this.headers();
let data: string =
typeof content === 'string' ? content : Utf8ArrayToStr(content);
data = await this.toggleSecretsInclude(posixPath, data, 'remove');
const payload = { data: btoa(data) };
const init = {
method: 'POST',
body: JSON.stringify(payload),
headers,
};
await this.run(url, init);
}
async deleteFile(posixPath: string): Promise<void> {
await this.delete(posixPath, 'f');
}
async deleteDirectory(posixPath: string): Promise<void> {
await this.delete(posixPath, 'd');
}
private async delete(posixPath: string, type: ResourceType): Promise<void> {
const url = new URL(
`${this.domain()}/files/${type}/$HOME/sketches_v2${posixPath}`
);
const headers = await this.headers();
await this.run(url, {
method: 'DELETE',
headers,
});
}
async rename(fromPosixPath: string, toPosixPath: string): Promise<void> {
const url = new URL(`${this.domain('v3')}/files/mv`);
const headers = await this.headers();
const payload = {
from: `$HOME/sketches_v2${fromPosixPath}`,
to: `$HOME/sketches_v2${toPosixPath}`,
};
const init = {
method: 'POST',
body: JSON.stringify(payload),
headers,
};
await this.run(url, init, ResponseResultProvider.NOOP);
}
async editSketch({
id,
params,
}: {
id: string;
params: Record<string, unknown>;
}): Promise<Create.Sketch> {
const url = new URL(`${this.domain()}/sketches/${id}`);
const headers = await this.headers();
const result = await this.run<Create.Sketch>(url, {
method: 'POST',
body: JSON.stringify({ id, ...params }),
headers,
});
return result;
}
async copy(fromPosixPath: string, toPosixPath: string): Promise<void> {
const payload = {
from: `$HOME/sketches_v2${fromPosixPath}`,
to: `$HOME/sketches_v2${toPosixPath}`,
};
const url = new URL(`${this.domain('v3')}/files/cp`);
const headers = await this.headers();
const init = {
method: 'POST',
body: JSON.stringify(payload),
headers,
};
await this.run(url, init, ResponseResultProvider.NOOP);
}
private async run<T>(
requestInfo: RequestInfo | URL,
init: RequestInit | undefined,
resultProvider: ResponseResultProvider = ResponseResultProvider.JSON
): Promise<T> {
const response = await fetch(
requestInfo instanceof URL ? requestInfo.toString() : requestInfo,
init
);
if (!response.ok) {
let details: string | undefined = undefined;
try {
details = await response.json();
} catch (e) {
console.error('Cloud not get the error details.', e);
}
const { statusText, status } = response;
throw new CreateError(statusText, status, details);
}
const result = await resultProvider(response);
return result;
}
private async headers(): Promise<Record<string, string>> {
const token = await this.token();
return {
'content-type': 'application/json',
accept: 'application/json',
authorization: `Bearer ${token}`,
};
}
private domain(apiVersion = 'v2'): string {
const endpoint = this.arduinoPreferences['arduino.cloud.sketchSyncEnpoint'];
return `${endpoint}/${apiVersion}`;
}
private async token(): Promise<string> {
return this.authenticationService.session?.accessToken || '';
}
}
export namespace CreateApi {
export const defaultInoContent = `/*
*/
void setup() {
}
void loop() {
}
`;
}