deploy: current vibn theia state
Some checks failed
Playwright Tests / Playwright Tests (ubuntu-22.04, Node.js 22.x) (push) Has been cancelled
3PP License Check / 3PP License Check (11, 22.x, ubuntu-22.04) (push) Has been cancelled
Publish packages to NPM / Perform Publishing (push) Has been cancelled

Made-with: Cursor
This commit is contained in:
2026-02-27 12:01:08 -08:00
commit 8bb5110148
3782 changed files with 640947 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
},
rules: {
'import/no-dynamic-require': 'off'
}
};

View File

@@ -0,0 +1,26 @@
<div align='center'>
<br />
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
<h2>ECLIPSE THEIA - APPLICATION-MANAGER</h2>
<hr />
</div>
## Additional Information
- [Theia - GitHub](https://github.com/eclipse-theia/theia)
- [Theia - Website](https://theia-ide.org/)
## License
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
## Trademark
"Theia" is a trademark of the Eclipse Foundation
<https://www.eclipse.org/theia>

View File

@@ -0,0 +1,81 @@
{
"name": "@theia/application-manager",
"version": "1.68.0",
"description": "Theia application manager API.",
"publishConfig": {
"access": "public"
},
"license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0",
"repository": {
"type": "git",
"url": "https://github.com/eclipse-theia/theia.git"
},
"bugs": {
"url": "https://github.com/eclipse-theia/theia/issues"
},
"homepage": "https://github.com/eclipse-theia/theia",
"files": [
"lib",
"src"
],
"main": "lib/index.js",
"typings": "lib/index.d.ts",
"scripts": {
"build": "theiaext build",
"clean": "theiaext clean",
"compile": "theiaext compile",
"lint": "theiaext lint",
"test": "theiaext test",
"watch": "theiaext watch"
},
"dependencies": {
"@babel/core": "^7.10.0",
"@babel/plugin-transform-classes": "^7.10.0",
"@babel/plugin-transform-runtime": "^7.10.0",
"@babel/preset-env": "^7.10.0",
"@theia/application-package": "1.68.0",
"@theia/ffmpeg": "1.68.0",
"@theia/native-webpack-plugin": "1.68.0",
"@types/fs-extra": "^4.0.2",
"@types/semver": "^7.5.0",
"babel-loader": "^8.2.2",
"buffer": "^6.0.3",
"compression-webpack-plugin": "^9.0.0",
"copy-webpack-plugin": "^8.1.1",
"css-loader": "^6.2.0",
"@electron/rebuild": "^3.7.2",
"fs-extra": "^4.0.2",
"http-server": "^14.1.1",
"ignore-loader": "^0.1.2",
"less": "^3.0.3",
"mini-css-extract-plugin": "^2.6.1",
"node-loader": "^2.0.0",
"path-browserify": "^1.0.1",
"semver": "^7.5.4",
"source-map": "^0.6.1",
"source-map-loader": "^2.0.1",
"source-map-support": "^0.5.19",
"style-loader": "^2.0.0",
"tslib": "^2.6.2",
"umd-compat-loader": "^2.1.2",
"webpack": "^5.76.0",
"webpack-cli": "4.7.0",
"worker-loader": "^3.0.8",
"yargs": "^15.3.1"
},
"peerDependencies": {
"@theia/electron": "*"
},
"peerDependenciesMeta": {
"@theia/electron": {
"optional": true
}
},
"devDependencies": {
"@theia/ext-scripts": "1.68.0"
},
"nyc": {
"extends": "../../configs/nyc.json"
},
"gitHead": "21358137e41342742707f660b8e222f940a27652"
}

View File

@@ -0,0 +1,263 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as path from 'path';
import * as fs from 'fs-extra';
import * as cp from 'child_process';
import * as semver from 'semver';
import { ApplicationPackage, ApplicationPackageOptions } from '@theia/application-package';
import { WebpackGenerator, FrontendGenerator, BackendGenerator } from './generator';
import { ApplicationProcess } from './application-process';
import { GeneratorOptions } from './generator/abstract-generator';
import yargs = require('yargs');
// Declare missing exports from `@types/semver@7`
declare module 'semver' {
function minVersion(range: string): string;
}
class AbortError extends Error {
constructor(...args: Parameters<ErrorConstructor>) {
super(...args);
Object.setPrototypeOf(this, AbortError.prototype);
}
}
export class ApplicationPackageManager {
static defineGeneratorOptions<T>(cli: yargs.Argv<T>): yargs.Argv<T & {
mode: 'development' | 'production'
splitFrontend?: boolean
}> {
return cli
.option('mode', {
description: 'Generation mode to use',
choices: ['development', 'production'],
default: 'production' as const,
})
.option('split-frontend', {
description: 'Split frontend modules into separate chunks. By default enabled in the `development` mode and disabled in the `production` mode.',
type: 'boolean'
});
}
readonly pck: ApplicationPackage;
/** application process */
readonly process: ApplicationProcess;
/** manager process */
protected readonly __process: ApplicationProcess;
constructor(options: ApplicationPackageOptions) {
this.pck = new ApplicationPackage(options);
this.process = new ApplicationProcess(this.pck, options.projectPath);
this.__process = new ApplicationProcess(this.pck, path.join(__dirname, '..'));
}
protected async remove(fsPath: string): Promise<void> {
if (await fs.pathExists(fsPath)) {
await fs.remove(fsPath);
}
}
async clean(): Promise<void> {
const webpackGenerator = new WebpackGenerator(this.pck);
await Promise.all([
this.remove(this.pck.lib()),
this.remove(this.pck.srcGen()),
this.remove(webpackGenerator.genConfigPath),
this.remove(webpackGenerator.genNodeConfigPath)
]);
}
async prepare(): Promise<void> {
if (this.pck.isElectron()) {
await this.prepareElectron();
}
}
async generate(options: GeneratorOptions = {}): Promise<void> {
try {
await this.prepare();
} catch (error) {
if (error instanceof AbortError) {
console.warn(error.message);
process.exit(1);
}
throw error;
}
await Promise.all([
new WebpackGenerator(this.pck, options).generate(),
new BackendGenerator(this.pck, options).generate(),
new FrontendGenerator(this.pck, options).generate(),
]);
}
async copy(): Promise<void> {
await fs.ensureDir(this.pck.lib('frontend'));
await fs.copy(this.pck.frontend('index.html'), this.pck.lib('frontend', 'index.html'));
}
async build(args: string[] = [], options: GeneratorOptions = {}): Promise<void> {
await this.generate(options);
await this.copy();
return this.__process.run('webpack', args);
}
start(args: string[] = []): cp.ChildProcess {
if (this.pck.isElectron()) {
return this.startElectron(args);
} else if (this.pck.isBrowserOnly()) {
return this.startBrowserOnly(args);
}
return this.startBrowser(args);
}
startBrowserOnly(args: string[]): cp.ChildProcess {
const { command, mainArgs, options } = this.adjustBrowserOnlyArgs(args);
return this.__process.spawnBin(command, mainArgs, options);
}
adjustBrowserOnlyArgs(args: string[]): Readonly<{ command: string, mainArgs: string[]; options: cp.SpawnOptions }> {
let { mainArgs, options } = this.adjustArgs(args);
// first parameter: path to generated frontend
// second parameter: disable cache to support watching
mainArgs = ['lib/frontend', '-c-1', ...mainArgs];
const portIndex = mainArgs.findIndex(v => v.startsWith('--port'));
if (portIndex === -1) {
mainArgs.push('--port=3000');
}
return { command: 'http-server', mainArgs, options };
}
startElectron(args: string[]): cp.ChildProcess {
// If possible, pass the project root directory to electron rather than the script file so that Electron
// can determine the app name. This requires that the package.json has a main field.
let appPath = this.pck.projectPath;
if (!this.pck.pck.main) {
// Try the bundled electron app first
appPath = this.pck.lib('backend', 'electron-main.js');
if (!fs.existsSync(appPath)) {
// Fallback to the generated electron app in src-gen
appPath = this.pck.backend('electron-main.js');
}
console.warn(
`WARNING: ${this.pck.packagePath} does not have a "main" entry.\n` +
'Please add the following line:\n' +
' "main": "lib/backend/electron-main.js"'
);
}
const { mainArgs, options } = this.adjustArgs([appPath, ...args]);
const electronCli = require.resolve('electron/cli.js', { paths: [this.pck.projectPath] });
return this.__process.fork(electronCli, mainArgs, options);
}
startBrowser(args: string[]): cp.ChildProcess {
const { mainArgs, options } = this.adjustArgs(args);
// The backend must be a process group leader on UNIX in order to kill the tree later.
// See https://nodejs.org/api/child_process.html#child_process_options_detached
options.detached = process.platform !== 'win32';
// Try the bundled backend app first
let mainPath = this.pck.lib('backend', 'main.js');
if (!fs.existsSync(mainPath)) {
// Fallback to the generated backend file in src-gen
mainPath = this.pck.backend('main.js');
}
return this.__process.fork(mainPath, mainArgs, options);
}
/**
* Inject Theia's Electron-specific dependencies into the application's package.json.
*
* Only overwrite the Electron range if the current minimum supported version is lower than the recommended one.
*/
protected async prepareElectron(): Promise<void> {
let theiaElectron;
try {
theiaElectron = await import('@theia/electron');
} catch (error) {
if (error.code === 'ERR_MODULE_NOT_FOUND') {
throw new AbortError('Please install @theia/electron as part of your Theia Electron application');
}
throw error;
}
const expectedRange = theiaElectron.electronRange;
const appPackageJsonPath = this.pck.path('package.json');
const appPackageJson = await fs.readJSON(appPackageJsonPath) as { devDependencies?: Record<string, string> };
if (!appPackageJson.devDependencies) {
appPackageJson.devDependencies = {};
}
const currentRange: string | undefined = appPackageJson.devDependencies.electron;
if (!currentRange || semver.compare(semver.minVersion(currentRange), semver.minVersion(expectedRange)) < 0) {
// Update the range with the recommended one and write it on disk.
appPackageJson.devDependencies = this.insertAlphabetically(appPackageJson.devDependencies, 'electron', expectedRange);
await fs.writeJSON(appPackageJsonPath, appPackageJson, { spaces: 2 });
throw new AbortError('Updated dependencies, please run "install" again');
}
if (!theiaElectron.electronVersion || !semver.satisfies(theiaElectron.electronVersion, currentRange)) {
throw new AbortError('Dependencies are out of sync, please run "install" again');
}
const ffmpeg = await import('@theia/ffmpeg');
await ffmpeg.replaceFfmpeg();
await ffmpeg.checkFfmpeg();
}
protected insertAlphabetically<T extends Record<string, string>>(object: T, key: string, value: string): T {
const updated: Record<string, unknown> = {};
for (const property of Object.keys(object)) {
if (property.localeCompare(key) > 0) {
updated[key] = value;
}
updated[property] = object[property];
}
if (!(key in updated)) {
updated[key] = value;
}
return updated as T;
}
private adjustArgs(args: string[], forkOptions: cp.ForkOptions = {}): Readonly<{ mainArgs: string[]; options: cp.ForkOptions }> {
const options = {
...this.forkOptions,
forkOptions
};
const mainArgs = [...args];
const inspectIndex = mainArgs.findIndex(v => v.startsWith('--inspect'));
if (inspectIndex !== -1) {
const inspectArg = mainArgs.splice(inspectIndex, 1)[0];
options.execArgv = ['--nolazy', inspectArg];
}
return {
mainArgs,
options
};
}
private get forkOptions(): cp.ForkOptions {
return {
stdio: [0, 1, 2, 'ipc'],
env: {
...process.env,
THEIA_PARENT_PID: String(process.pid)
}
};
}
}

View File

@@ -0,0 +1,100 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as path from 'path';
import * as fs from 'fs-extra';
import * as cp from 'child_process';
import { ApplicationPackage } from '@theia/application-package';
export class ApplicationProcess {
protected readonly defaultOptions = {
cwd: this.pck.projectPath,
env: process.env
};
constructor(
protected readonly pck: ApplicationPackage,
protected readonly binProjectPath: string
) { }
spawn(command: string, args?: string[], options?: cp.SpawnOptions): cp.ChildProcess {
return cp.spawn(command, args || [], Object.assign({}, this.defaultOptions, {
...options,
shell: true
}));
}
fork(modulePath: string, args?: string[], options?: cp.ForkOptions): cp.ChildProcess {
return cp.fork(modulePath, args, Object.assign({}, this.defaultOptions, options));
}
canRun(command: string): boolean {
const binPath = this.resolveBin(this.binProjectPath, command);
return !!binPath && fs.existsSync(binPath);
}
run(command: string, args: string[], options?: cp.SpawnOptions): Promise<void> {
const commandProcess = this.spawnBin(command, args, options);
return this.promisify(command, commandProcess);
}
spawnBin(command: string, args: string[], options?: cp.SpawnOptions): cp.ChildProcess {
const binPath = this.resolveBin(this.binProjectPath, command);
if (!binPath) {
throw new Error(`Could not resolve ${command} relative to ${this.binProjectPath}`);
}
return this.spawn(binPath, args, {
...options,
shell: true
});
}
protected resolveBin(rootPath: string, command: string): string | undefined {
let commandPath = path.resolve(rootPath, 'node_modules', '.bin', command);
if (process.platform === 'win32') {
commandPath = commandPath + '.cmd';
}
if (fs.existsSync(commandPath)) {
return commandPath;
}
const parentDir = path.dirname(rootPath);
if (parentDir === rootPath) {
return undefined;
}
return this.resolveBin(parentDir, command);
}
protected promisify(command: string, p: cp.ChildProcess): Promise<void> {
return new Promise((resolve, reject) => {
p.stdout!.on('data', data => this.pck.log(data.toString()));
p.stderr!.on('data', data => this.pck.error(data.toString()));
p.on('error', reject);
p.on('close', (code, signal) => {
if (signal) {
reject(new Error(`${command} exited with an unexpected signal: ${signal}.`));
return;
}
if (code === 0) {
resolve();
} else {
reject(new Error(`${command} exited with an unexpected code: ${code}.`));
}
});
});
}
}

View File

@@ -0,0 +1,80 @@
// *****************************************************************************
// Copyright (C) 2020 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as fs from 'fs-extra';
import * as path from 'path';
// eslint-disable-next-line import/no-extraneous-dependencies
import type { RawSourceMap } from 'source-map';
import { ApplicationPackage } from '@theia/application-package/lib/application-package';
const modulePackages: { dir: string, name?: string }[] = [];
for (const extensionPackage of new ApplicationPackage({ projectPath: process.cwd() }).extensionPackages) {
modulePackages.push({
name: extensionPackage.name,
dir: path.dirname(extensionPackage.raw.installed!.packagePath)
});
}
function exposeModule(modulePackage: { dir: string, name?: string }, resourcePath: string, source: string): string {
if (!modulePackage.name) {
return source;
}
const { dir, name } = path.parse(resourcePath);
let moduleName = path.join(modulePackage.name, dir.substring(modulePackage.dir.length));
if (name !== 'index') {
moduleName = path.join(moduleName, name);
}
if (path.sep !== '/') {
moduleName = moduleName.split(path.sep).join('/');
}
return source + `\n;(globalThis['theia'] = globalThis['theia'] || {})['${moduleName}'] = this;\n`;
}
/**
* Expose bundled modules on window.theia.moduleName namespace, e.g.
* window['theia']['@theia/core/lib/common/uri'].
* Such syntax can be used by external code, for instance, for testing.
*/
// TODO: webpack@5.36.2 is missing a `LoaderContext` interface so we'll use any in the meantime
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export = function (this: any, source: string, sourceMap?: RawSourceMap): string | undefined {
if (this.cacheable) {
this.cacheable();
}
let modulePackage = modulePackages.find(({ dir }) => this.resourcePath.startsWith(dir + path.sep));
if (modulePackage) {
this.callback(undefined, exposeModule(modulePackage, this.resourcePath, source), sourceMap);
return;
}
const searchString = path.sep + 'node_modules';
const index = this.resourcePath.lastIndexOf(searchString);
if (index !== -1) {
const nodeModulesPath = this.resourcePath.substring(0, index + searchString.length);
let dir = this.resourcePath;
while ((dir = path.dirname(dir)) !== nodeModulesPath) {
try {
const { name } = fs.readJSONSync(path.join(dir, 'package.json'));
modulePackage = { name, dir };
modulePackages.push(modulePackage);
this.callback(undefined, exposeModule(modulePackage, this.resourcePath, source), sourceMap);
return;
} catch {
/** no-op */
}
}
}
this.callback(undefined, source, sourceMap);
};

View File

@@ -0,0 +1,69 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as fs from 'fs-extra';
import { ApplicationPackage } from '@theia/application-package';
export interface GeneratorOptions {
mode?: 'development' | 'production'
splitFrontend?: boolean
}
export abstract class AbstractGenerator {
constructor(
protected readonly pck: ApplicationPackage,
protected options: GeneratorOptions = {}
) { }
protected ifBrowser(value: string, defaultValue: string = ''): string {
return this.pck.ifBrowser(value, defaultValue);
}
protected ifElectron(value: string, defaultValue: string = ''): string {
return this.pck.ifElectron(value, defaultValue);
}
protected ifBrowserOnly(value: string, defaultValue: string = ''): string {
return this.pck.ifBrowserOnly(value, defaultValue);
}
protected async write(path: string, content: string): Promise<void> {
await fs.ensureFile(path);
await fs.writeFile(path, content);
}
protected ifMonaco(value: () => string, defaultValue: () => string = () => ''): string {
return this.ifPackage([
'@theia/monaco',
'@theia/monaco-editor-core'
], value, defaultValue);
}
protected ifPackage(packageName: string | string[], value: string | (() => string), defaultValue: string | (() => string) = ''): string {
const packages = Array.isArray(packageName) ? packageName : [packageName];
if (this.pck.extensionPackages.some(e => packages.includes(e.name))) {
return typeof value === 'string' ? value : value();
} else {
return typeof defaultValue === 'string' ? defaultValue : defaultValue();
}
}
protected prettyStringify(object: object): string {
return JSON.stringify(object, undefined, 4);
}
}

View File

@@ -0,0 +1,204 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { EOL } from 'os';
import { AbstractGenerator } from './abstract-generator';
export class BackendGenerator extends AbstractGenerator {
async generate(): Promise<void> {
if (this.pck.isBrowserOnly()) {
// no backend generation in case of browser-only target
return;
}
const backendModules = this.pck.targetBackendModules;
await this.write(this.pck.backend('server.js'), this.compileServer(backendModules));
await this.write(this.pck.backend('main.js'), this.compileMain(backendModules));
if (this.pck.isElectron()) {
await this.write(this.pck.backend('electron-main.js'), this.compileElectronMain(this.pck.targetElectronMainModules));
}
}
protected compileElectronMain(electronMainModules?: Map<string, string>): string {
return `// @ts-check
require('@theia/core/shared/reflect-metadata');
// Workaround for https://github.com/electron/electron/issues/9225. Chrome has an issue where
// in certain locales (e.g. PL), image metrics are wrongly computed. We explicitly set the
// LC_NUMERIC to prevent this from happening (selects the numeric formatting category of the
// C locale, http://en.cppreference.com/w/cpp/locale/LC_categories).
if (process.env.LC_ALL) {
process.env.LC_ALL = 'C';
}
process.env.LC_NUMERIC = 'C';
(async () => {
// Useful for Electron/NW.js apps as GUI apps on macOS doesn't inherit the \`$PATH\` define
// in your dotfiles (.bashrc/.bash_profile/.zshrc/etc).
// https://github.com/electron/electron/issues/550#issuecomment-162037357
// https://github.com/eclipse-theia/theia/pull/3534#issuecomment-439689082
(await require('@theia/core/electron-shared/fix-path')).default();
const { resolve } = require('path');
const theiaAppProjectPath = resolve(__dirname, '..', '..');
process.env.THEIA_APP_PROJECT_PATH = theiaAppProjectPath;
const { default: electronMainApplicationModule } = require('@theia/core/lib/electron-main/electron-main-application-module');
const { ElectronMainApplication, ElectronMainApplicationGlobals } = require('@theia/core/lib/electron-main/electron-main-application');
const { Container } = require('@theia/core/shared/inversify');
const { app } = require('electron');
const config = ${this.prettyStringify(this.pck.props.frontend.config)};
const isSingleInstance = ${this.pck.props.backend.config.singleInstance === true ? 'true' : 'false'};
if (isSingleInstance && !app.requestSingleInstanceLock(process.argv)) {
// There is another instance running, exit now. The other instance will request focus.
app.quit();
return;
}
const container = new Container();
container.load(electronMainApplicationModule);
container.bind(ElectronMainApplicationGlobals).toConstantValue({
THEIA_APP_PROJECT_PATH: theiaAppProjectPath,
THEIA_BACKEND_MAIN_PATH: resolve(__dirname, 'main.js'),
THEIA_FRONTEND_HTML_PATH: resolve(__dirname, '..', '..', 'lib', 'frontend', 'index.html'),
THEIA_SECONDARY_WINDOW_HTML_PATH: resolve(__dirname, '..', '..', 'lib', 'frontend', 'secondary-window.html')
});
function load(raw) {
return Promise.resolve(raw.default).then(module =>
container.load(module)
);
}
async function start() {
const application = container.get(ElectronMainApplication);
await application.start(config);
}
try {
${Array.from(electronMainModules?.values() ?? [], jsModulePath => `\
await load(require('${jsModulePath}'));`).join(EOL)}
await start();
} catch (reason) {
if (typeof reason !== 'number') {
console.error('Failed to start the electron application.');
if (reason) {
console.error(reason);
}
}
app.quit();
};
})();
`;
}
protected compileServer(backendModules: Map<string, string>): string {
return `// @ts-check
require('reflect-metadata');${this.ifElectron(`
// Patch electron version if missing, see https://github.com/eclipse-theia/theia/pull/7361#pullrequestreview-377065146
if (typeof process.versions.electron === 'undefined' && typeof process.env.THEIA_ELECTRON_VERSION === 'string') {
process.versions.electron = process.env.THEIA_ELECTRON_VERSION;
}`)}
// Erase the ELECTRON_RUN_AS_NODE variable from the environment, else Electron apps started using Theia will pick it up.
if ('ELECTRON_RUN_AS_NODE' in process.env) {
delete process.env.ELECTRON_RUN_AS_NODE;
}
const path = require('path');
process.env.THEIA_APP_PROJECT_PATH = path.resolve(__dirname, '..', '..')
const express = require('@theia/core/shared/express');
const { Container } = require('@theia/core/shared/inversify');
const { BackendApplication, BackendApplicationServer, CliManager } = require('@theia/core/lib/node');
const { backendApplicationModule } = require('@theia/core/lib/node/backend-application-module');
const { messagingBackendModule } = require('@theia/core/lib/node/messaging/messaging-backend-module');
const { loggerBackendModule } = require('@theia/core/lib/node/logger-backend-module');
const container = new Container();
container.load(backendApplicationModule);
container.load(messagingBackendModule);
container.load(loggerBackendModule);
function defaultServeStatic(app) {
app.use(express.static(path.resolve(__dirname, '../../lib/frontend')))
}
function load(raw) {
return Promise.resolve(raw).then(
module => container.load(module.default)
);
}
async function start(port, host, argv = process.argv) {
if (!container.isBound(BackendApplicationServer)) {
container.bind(BackendApplicationServer).toConstantValue({ configure: defaultServeStatic });
}
let result = undefined;
await container.get(CliManager).initializeCli(argv.slice(2),
() => container.get(BackendApplication).configured,
async () => {
result = container.get(BackendApplication).start(port, host);
});
if (result) {
return result;
} else {
return Promise.reject(0);
}
}
module.exports = async (port, host, argv) => {
try {
${Array.from(backendModules.values(), jsModulePath => `\
await load(require('${jsModulePath}'));`).join(EOL)}
return await start(port, host, argv);
} catch (error) {
if (typeof error !== 'number') {
console.error('Failed to start the backend application:');
console.error(error);
process.exitCode = 1;
}
throw error;
}
}
`;
}
protected compileMain(backendModules: Map<string, string>): string {
return `// @ts-check
const { BackendApplicationConfigProvider } = require('@theia/core/lib/node/backend-application-config-provider');
const main = require('@theia/core/lib/node/main');
BackendApplicationConfigProvider.set(${this.prettyStringify(this.pck.props.backend.config)});
globalThis.extensionInfo = ${this.prettyStringify(this.pck.extensionPackages.map(({ name, version }) => ({ name, version })))};
const serverModule = require('./server');
const serverAddress = main.start(serverModule());
serverAddress.then((addressInfo) => {
if (process && process.send && addressInfo) {
process.send(addressInfo);
}
});
globalThis.serverAddress = serverAddress;
`;
}
}

View File

@@ -0,0 +1,221 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/* eslint-disable @typescript-eslint/indent */
import { EOL } from 'os';
import { AbstractGenerator, GeneratorOptions } from './abstract-generator';
import { existsSync, readFileSync } from 'fs';
export class FrontendGenerator extends AbstractGenerator {
async generate(options?: GeneratorOptions): Promise<void> {
await this.write(this.pck.frontend('index.html'), this.compileIndexHtml(this.pck.targetFrontendModules));
await this.write(this.pck.frontend('index.js'), this.compileIndexJs(this.pck.targetFrontendModules, this.pck.targetFrontendPreloadModules));
await this.write(this.pck.frontend('secondary-window.html'), this.compileSecondaryWindowHtml());
await this.write(this.pck.frontend('secondary-index.js'), this.compileSecondaryIndexJs(this.pck.secondaryWindowModules));
if (this.pck.isElectron()) {
await this.write(this.pck.frontend('preload.js'), this.compilePreloadJs());
}
}
protected compileIndexPreload(frontendModules: Map<string, string>): string {
const template = this.pck.props.generator.config.preloadTemplate;
if (!template) {
return '';
}
// Support path to html file
if (existsSync(template)) {
return readFileSync(template).toString();
}
return template;
}
protected compileIndexHtml(frontendModules: Map<string, string>): string {
return `<!DOCTYPE html>
<html lang="en">
<head>${this.compileIndexHead(frontendModules)}
</head>
<body>
<div class="theia-preload">${this.compileIndexPreload(frontendModules)}</div>
<script type="text/javascript" src="./bundle.js" charset="utf-8"></script>
</body>
</html>`;
}
protected compileIndexHead(frontendModules: Map<string, string>): string {
return `
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>${this.pck.props.frontend.config.applicationName}</title>
<link rel="stylesheet" href="./vibn.css">`;
}
protected compileIndexJs(frontendModules: Map<string, string>, frontendPreloadModules: Map<string, string>): string {
return `\
// @ts-check
require('reflect-metadata');
const { Container } = require('@theia/core/shared/inversify');
const { FrontendApplicationConfigProvider } = require('@theia/core/lib/browser/frontend-application-config-provider');
FrontendApplicationConfigProvider.set(${this.prettyStringify(this.pck.props.frontend.config)});
${this.ifMonaco(() => `
self.MonacoEnvironment = {
getWorkerUrl: function (moduleId, label) {
return './editor.worker.js';
}
}`)}
function load(container, jsModule) {
return Promise.resolve(jsModule)
.then(containerModule => container.load(containerModule.default));
}
async function preload(container) {
try {
${Array.from(frontendPreloadModules.values(), jsModulePath => `\
await load(container, ${this.importOrRequire()}('${jsModulePath}'));`).join(EOL)}
const { Preloader } = require('@theia/core/lib/browser/preload/preloader');
const preloader = container.get(Preloader);
await preloader.initialize();
} catch (reason) {
console.error('Failed to run preload scripts.');
if (reason) {
console.error(reason);
}
}
}
module.exports = (async () => {
const { messagingFrontendModule } = require('@theia/core/lib/${this.pck.isBrowser() || this.pck.isBrowserOnly()
? 'browser/messaging/messaging-frontend-module'
: 'electron-browser/messaging/electron-messaging-frontend-module'}');
const container = new Container();
container.load(messagingFrontendModule);
${this.ifBrowserOnly(`const { messagingFrontendOnlyModule } = require('@theia/core/lib/browser-only/messaging/messaging-frontend-only-module');
container.load(messagingFrontendOnlyModule);`)}
await preload(container);
${this.ifMonaco(() => `
const { MonacoInit } = require('@theia/monaco/lib/browser/monaco-init');
`)};
const { FrontendApplication } = require('@theia/core/lib/browser');
const { frontendApplicationModule } = require('@theia/core/lib/browser/frontend-application-module');
const { loggerFrontendModule } = require('@theia/core/lib/browser/logger-frontend-module');
container.load(frontendApplicationModule);
${this.pck.ifBrowserOnly(`const { frontendOnlyApplicationModule } = require('@theia/core/lib/browser-only/frontend-only-application-module');
container.load(frontendOnlyApplicationModule);`)}
container.load(loggerFrontendModule);
${this.ifBrowserOnly(`const { loggerFrontendOnlyModule } = require('@theia/core/lib/browser-only/logger-frontend-only-module');
container.load(loggerFrontendOnlyModule);`)}
try {
${Array.from(frontendModules.values(), jsModulePath => `\
await load(container, ${this.importOrRequire()}('${jsModulePath}'));`).join(EOL)}
${this.ifMonaco(() => `
MonacoInit.init(container);
`)};
await start();
} catch (reason) {
console.error('Failed to start the frontend application.');
if (reason) {
console.error(reason);
}
}
function start() {
(window['theia'] = window['theia'] || {}).container = container;
return container.get(FrontendApplication).start();
}
})();
`;
}
protected importOrRequire(): string {
return this.options.mode !== 'production' ? 'import' : 'require';
}
/** HTML for secondary windows that contain an extracted widget. */
protected compileSecondaryWindowHtml(): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Theia — Secondary Window</title>
<style>
html, body {
overflow: hidden;
-ms-overflow-style: none;
}
body {
margin: 0;
}
html,
head,
body,
.secondary-widget-root,
#widget-host {
width: 100% !important;
height: 100% !important;
}
</style>
<link rel="stylesheet" href="./secondary-window.css">
</head>
<body>
<div id="widget-host"></div>
</body>
</html>`;
}
protected compileSecondaryIndexJs(secondaryWindowModules: Map<string, string>): string {
return `\
// @ts-check
require('reflect-metadata');
const { Container } = require('@theia/core/shared/inversify');
module.exports = Promise.resolve().then(() => {
const { frontendApplicationModule } = require('@theia/core/lib/browser/frontend-application-module');
const container = new Container();
container.load(frontendApplicationModule);
${Array.from(secondaryWindowModules.values(), jsModulePath => `\
container.load(require('${jsModulePath}').default);`).join(EOL)}
});
`;
}
compilePreloadJs(): string {
return `\
// @ts-check
${Array.from(this.pck.preloadModules.values(), path => `require('${path}').preload();`).join(EOL)}
`;
}
}

View File

@@ -0,0 +1,19 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export * from './webpack-generator';
export * from './frontend-generator';
export * from './backend-generator';

View File

@@ -0,0 +1,529 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as paths from 'path';
import * as fs from 'fs-extra';
import { AbstractGenerator } from './abstract-generator';
export class WebpackGenerator extends AbstractGenerator {
async generate(): Promise<void> {
await this.write(this.genConfigPath, this.compileWebpackConfig());
if (!this.pck.isBrowserOnly()) {
await this.write(this.genNodeConfigPath, this.compileNodeWebpackConfig());
}
if (await this.shouldGenerateUserWebpackConfig()) {
await this.write(this.configPath, this.compileUserWebpackConfig());
}
}
protected async shouldGenerateUserWebpackConfig(): Promise<boolean> {
if (!(await fs.pathExists(this.configPath))) {
return true;
}
const content = await fs.readFile(this.configPath, 'utf8');
return content.indexOf('gen-webpack') === -1;
}
get configPath(): string {
return this.pck.path('webpack.config.js');
}
get genConfigPath(): string {
return this.pck.path('gen-webpack.config.js');
}
get genNodeConfigPath(): string {
return this.pck.path('gen-webpack.node.config.js');
}
protected compileWebpackConfig(): string {
return `/**
* Don't touch this file. It will be regenerated by theia build.
* To customize webpack configuration change ${this.configPath}
*/
// @ts-check
const path = require('path');
const webpack = require('webpack');
const yargs = require('yargs');
const resolvePackagePath = require('resolve-package-path');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { MonacoWebpackPlugin } = require('@theia/native-webpack-plugin/lib/monaco-webpack-plugins.js');
const outputPath = path.resolve(__dirname, 'lib', 'frontend');
const { mode, staticCompression } = yargs.option('mode', {
description: "Mode to use",
choices: ["development", "production"],
default: "production"
}).option('static-compression', {
description: 'Controls whether to enable compression of static artifacts.',
type: 'boolean',
default: true
}).argv;
const development = mode === 'development';
const plugins = [
new CopyWebpackPlugin({
patterns: [
{
// copy secondary window html file to lib folder
from: path.resolve(__dirname, 'src-gen/frontend/secondary-window.html')
}${this.ifPackage('@theia/plugin-ext', `,
{
// copy webview files to lib folder
from: path.join(resolvePackagePath('@theia/plugin-ext', __dirname), '..', 'src', 'main', 'browser', 'webview', 'pre'),
to: path.resolve(__dirname, 'lib', 'webview', 'pre')
}`)}
${this.ifPackage('@theia/plugin-ext-vscode', `,
{
// copy frontend plugin host files
from: path.join(resolvePackagePath('@theia/plugin-ext-vscode', __dirname), '..', 'lib', 'node', 'context', 'plugin-vscode-init-fe.js'),
to: path.resolve(__dirname, 'lib', 'frontend', 'context', 'plugin-vscode-init-fe.js')
}`)}
]
}),
new webpack.ProvidePlugin({
// the Buffer class doesn't exist in the browser but some dependencies rely on it
Buffer: ['buffer', 'Buffer']
}),
new MonacoWebpackPlugin()
];
// it should go after copy-plugin in order to compress monaco as well
if (staticCompression) {
plugins.push(new CompressionPlugin({}));
}
module.exports = [{
mode,
plugins,
devtool: 'source-map',
entry: {
bundle: path.resolve(__dirname, 'src-gen/frontend/index.js'),
${this.ifMonaco(() => "'editor.worker': '@theia/monaco-editor-core/esm/vs/editor/editor.worker.js'")}
},
output: {
filename: '[name].js',
path: outputPath,
devtoolModuleFilenameTemplate: 'webpack:///[resource-path]?[loaders]',
globalObject: 'self'
},
target: 'web',
cache: staticCompression,
module: {
rules: [
{
test: /\\.css$/,
exclude: /materialcolors\\.css$|\\.useable\\.css$/,
use: ['style-loader', 'css-loader']
},
{
test: /materialcolors\\.css$|\\.useable\\.css$/,
use: [
{
loader: 'style-loader',
options: {
esModule: false,
injectType: 'lazySingletonStyleTag',
attributes: {
id: 'theia-theme'
}
}
},
'css-loader'
]
},
{
test: /\\.(ttf|eot|svg)(\\?v=\\d+\\.\\d+\\.\\d+)?$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 10000,
}
},
generator: {
dataUrl: {
mimetype: 'image/svg+xml'
}
}
},
{
test: /\\.(jpg|png|gif)$/,
type: 'asset/resource',
generator: {
filename: '[hash].[ext]'
}
},
{
// see https://github.com/eclipse-theia/theia/issues/556
test: /source-map-support/,
loader: 'ignore-loader'
},
{
test: /\\.d\\.ts$/,
loader: 'ignore-loader'
},
{
test: /\\.js$/,
enforce: 'pre',
loader: 'source-map-loader',
exclude: /jsonc-parser|fast-plist|onigasm/
},
{
test: /\\.woff(2)?(\\?v=[0-9]\\.[0-9]\\.[0-9])?$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 10000,
}
},
generator: {
dataUrl: {
mimetype: 'image/svg+xml'
}
}
},
{
test: /node_modules[\\\\|\/](vscode-languageserver-types|vscode-uri|jsonc-parser|vscode-languageserver-protocol)/,
loader: 'umd-compat-loader'
},
{
test: /\\.wasm$/,
type: 'asset/resource'
},
{
test: /\\.plist$/,
type: 'asset/resource'
}
]
},
resolve: {
fallback: {
'child_process': false,
'crypto': false,
'net': false,
'path': require.resolve('path-browserify'),
'process': false,
'os': false,
'timers': false
},
extensions: ['.js']
},
stats: {
warnings: true,
children: true
},
ignoreWarnings: [
// Some packages do not have source maps, that's ok
/Failed to parse source map/,
{
// Monaco uses 'require' in a non-standard way
module: /@theia\\/monaco-editor-core/,
message: /require function is used in a way in which dependencies cannot be statically extracted/
}
]
},
{
mode,
plugins: [
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// both options are optional
filename: "[name].css",
chunkFilename: "[id].css",
}),
new MonacoWebpackPlugin(),
],
devtool: 'source-map',
entry: {
"secondary-window": path.resolve(__dirname, 'src-gen/frontend/secondary-index.js'),
},
output: {
filename: '[name].js',
path: outputPath,
devtoolModuleFilenameTemplate: 'webpack:///[resource-path]?[loaders]',
globalObject: 'self'
},
target: 'web',
cache: staticCompression,
module: {
rules: [
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, "css-loader"]
},
{
test: /\.wasm$/,
type: 'asset/resource'
}
]
},
resolve: {
fallback: {
'child_process': false,
'crypto': false,
'net': false,
'path': require.resolve('path-browserify'),
'process': false,
'os': false,
'timers': false
},
extensions: ['.js']
},
stats: {
warnings: true,
children: true
},
ignoreWarnings: [
{
// Monaco uses 'require' in a non-standard way
module: /@theia\\/monaco-editor-core/,
message: /require function is used in a way in which dependencies cannot be statically extracted/
}
]
}${this.ifElectron(`, {
mode,
devtool: 'source-map',
entry: {
"preload": path.resolve(__dirname, 'src-gen/frontend/preload.js'),
},
output: {
filename: '[name].js',
path: outputPath,
devtoolModuleFilenameTemplate: 'webpack:///[resource-path]?[loaders]',
globalObject: 'self'
},
target: 'electron-preload',
cache: staticCompression,
stats: {
warnings: true,
children: true
}
}`)}];`;
}
protected compileUserWebpackConfig(): string {
return `/**
* This file can be edited to customize webpack configuration.
* To reset delete this file and rerun theia build again.
*/
// @ts-check
const configs = require('./${paths.basename(this.genConfigPath)}');
${this.ifBrowserOnly('', `const nodeConfig = require('./${paths.basename(this.genNodeConfigPath)}');`)}
/**
* Expose bundled modules on window.theia.moduleName namespace, e.g.
* window['theia']['@theia/core/lib/common/uri'].
* Such syntax can be used by external code, for instance, for testing.
configs[0].module.rules.push({
test: /\\.js$/,
loader: require.resolve('@theia/application-manager/lib/expose-loader')
}); */
${this.ifBrowserOnly('module.exports = configs;', `module.exports = [
...configs,
nodeConfig.config
];`)}
`;
}
protected compileNodeWebpackConfig(): string {
return `/**
* Don't touch this file. It will be regenerated by theia build.
* To customize webpack configuration change ${this.configPath}
*/
// @ts-check
const path = require('path');
const yargs = require('yargs');
const webpack = require('webpack');
const TerserPlugin = require('terser-webpack-plugin');
const NativeWebpackPlugin = require('@theia/native-webpack-plugin');
const { MonacoWebpackPlugin } = require('@theia/native-webpack-plugin/lib/monaco-webpack-plugins.js');
const { mode } = yargs.option('mode', {
description: "Mode to use",
choices: ["development", "production"],
default: "production"
}).argv;
const production = mode === 'production';
/** @type {import('webpack').EntryObject} */
const commonJsLibraries = {};
for (const [entryPointName, entryPointPath] of Object.entries({
${this.ifPackage('@theia/plugin-ext', "'backend-init-theia': '@theia/plugin-ext/lib/hosted/node/scanners/backend-init-theia',")}
${this.ifPackage('@theia/filesystem', "'parcel-watcher': '@theia/filesystem/lib/node/parcel-watcher',")}
${this.ifPackage('@theia/plugin-ext-vscode', "'plugin-vscode-init': '@theia/plugin-ext-vscode/lib/node/plugin-vscode-init',")}
${this.ifPackage('@theia/api-provider-sample', "'gotd-api-init': '@theia/api-provider-sample/lib/plugin/gotd-api-init',")}
${this.ifPackage('@theia/git', "'git-locator-host': '@theia/git/lib/node/git-locator/git-locator-host',")}
})) {
commonJsLibraries[entryPointName] = {
import: require.resolve(entryPointPath),
library: {
type: 'commonjs2',
},
};
}
const ignoredResources = new Set();
if (process.platform !== 'win32') {
ignoredResources.add('@vscode/windows-ca-certs');
ignoredResources.add('@vscode/windows-ca-certs/build/Release/crypt32.node');
}
const nativePlugin = new NativeWebpackPlugin({
out: 'native',
trash: ${this.ifPackage('@theia/filesystem', 'true', 'false')},
ripgrep: ${this.ifPackage(['@theia/search-in-workspace', '@theia/file-search'], 'true', 'false')},
pty: ${this.ifPackage('@theia/process', 'true', 'false')},
nativeBindings: {
drivelist: 'drivelist/build/Release/drivelist.node'
}
});
${this.ifPackage('@theia/process', () => `// Ensure that node-pty is correctly hoisted
try {
require.resolve('node-pty');
} catch {
console.error('"node-pty" dependency is not installed correctly. Ensure that it is available in the root node_modules directory.');
console.error('Exiting webpack build process.');
process.exit(1);
}`)}
/** @type {import('webpack').Configuration} */
const config = {
mode,
devtool: mode === 'development' ? 'source-map' : false,
target: 'node',
node: {
global: false,
__filename: false,
__dirname: false
},
resolve: {
extensions: ['.js', '.json', '.wasm', '.node'],
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'lib', 'backend'),
devtoolModuleFilenameTemplate: 'webpack:///[absolute-resource-path]?[loaders]',
},${this.ifElectron(`
externals: {
electron: 'require("electron")'
},`)}
entry: {
// Main entry point of the Theia application backend:
'main': require.resolve('./src-gen/backend/main'),
// Theia's IPC mechanism:
'ipc-bootstrap': require.resolve('@theia/core/lib/node/messaging/ipc-bootstrap'),
${this.ifPackage('@theia/plugin-ext', () => `// VS Code extension support:
'plugin-host': require.resolve('@theia/plugin-ext/lib/hosted/node/plugin-host'),`)}
${this.ifPackage('@theia/plugin-ext-headless', () => `// Theia Headless Plugin support:
'plugin-host-headless': require.resolve('@theia/plugin-ext-headless/lib/hosted/node/plugin-host-headless'),`)}
${this.ifPackage('@theia/process', () => `// Make sure the node-pty thread worker can be executed:
'worker/conoutSocketWorker': require.resolve('node-pty/lib/worker/conoutSocketWorker'),`)}
${this.ifElectron("'electron-main': require.resolve('./src-gen/backend/electron-main'),")}
${this.ifPackage('@theia/dev-container', () => `// VS Code Dev-Container communication:
'dev-container-server': require.resolve('@theia/dev-container/lib/dev-container-server/dev-container-server'),`)}
...commonJsLibraries
},
module: {
rules: [
// Make sure we can still find and load our native addons.
{
test: /\\.node$/,
loader: 'node-loader',
options: {
name: 'native/[name].[ext]'
}
},
{
test: /\\.d\\.ts$/,
loader: 'ignore-loader'
},
{
test: /\\.js$/,
enforce: 'pre',
loader: 'source-map-loader'
},
// jsonc-parser exposes its UMD implementation by default, which
// confuses Webpack leading to missing js in the bundles.
{
test: /node_modules[\\/](jsonc-parser)/,
loader: 'umd-compat-loader'
}
]
},
plugins: [
// Some native dependencies need special handling
nativePlugin,
// Optional node dependencies can be safely ignored
new webpack.IgnorePlugin({
checkResource: resource => ignoredResources.has(resource)
}),
new MonacoWebpackPlugin()
],
optimization: {
// Split and reuse code across the various entry points
splitChunks: {
chunks: 'all'
},
// Only minimize if we run webpack in production mode
minimize: production,
minimizer: [
new TerserPlugin({
exclude: /^(lib|builtins)\\//${this.ifPackage(['@theia/scanoss', '@theia/ai-anthropic', '@theia/ai-openai'], () => `,
terserOptions: {
keep_classnames: /AbortSignal/
}`)}
})
]
},
ignoreWarnings: [
// Some packages do not have source maps, that's ok
/Failed to parse source map/,
// require with expressions are not supported
/the request of a dependency is an expression/,
// Some packages use dynamic requires, we can safely ignore them (they are handled by the native webpack plugin)
/require function is used in a way in which dependencies cannot be statically extracted/, {
module: /yargs/
}, {
module: /node-pty/
}, {
module: /require-main-filename/
}, {
module: /ws/
}, {
module: /express/
}, {
module: /cross-spawn/
}, {
module: /@parcel\\/watcher/
}
]
};
module.exports = {
config,
nativePlugin,
ignoredResources
};
`;
}
}

View File

@@ -0,0 +1,19 @@
// *****************************************************************************
// Copyright (C) 2018 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export * from './rebuild';
export * from './application-package-manager';
export * from './application-process';

View File

@@ -0,0 +1,28 @@
// *****************************************************************************
// Copyright (C) 2018 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/* note: this bogus test file is required so that
we are able to run mocha unit tests on this
package, without having any actual unit tests in it.
This way a coverage report will be generated,
showing 0% coverage, instead of no report.
This file can be removed once we have real unit
tests in place. */
describe('application-manager package', () => {
it('should support code coverage statistics', () => true);
});

View File

@@ -0,0 +1,349 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import cp = require('child_process');
import fs = require('fs-extra');
import path = require('path');
import os = require('os');
export type RebuildTarget = 'electron' | 'browser' | 'browser-only';
const EXIT_SIGNALS: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'];
interface ExitToken {
getLastSignal(): NodeJS.Signals | undefined
onSignal(callback: (signal: NodeJS.Signals) => void): void
}
type NodeABI = string | number;
export const DEFAULT_MODULES = [
'node-pty',
'native-keymap',
'find-git-repositories',
'drivelist',
'keytar',
'ssh2',
'cpu-features'
];
export interface RebuildOptions {
/**
* What modules to rebuild.
*/
modules?: string[]
/**
* Folder where the module cache will be created/read from.
*/
cacheRoot?: string
/**
* In the event that `node-abi` doesn't recognize the current Electron version,
* you can specify the Node ABI to rebuild for.
*/
forceAbi?: NodeABI
}
/**
* @param target What to rebuild for.
* @param options
*/
export function rebuild(target: RebuildTarget, options: RebuildOptions = {}): void {
const {
modules = DEFAULT_MODULES,
cacheRoot = process.cwd(),
forceAbi,
} = options;
const cache = path.resolve(cacheRoot, '.browser_modules');
const cacheExists = folderExists(cache);
guardExit(async token => {
if (target === 'electron' && !cacheExists) {
process.exitCode = await rebuildElectronModules(cache, modules, forceAbi, token);
} else if (target === 'browser' && cacheExists) {
process.exitCode = await revertBrowserModules(cache, modules);
} else {
console.log(`native node modules are already rebuilt for ${target}`);
}
}).catch(errorOrSignal => {
if (typeof errorOrSignal === 'string' && errorOrSignal in os.constants.signals) {
process.kill(process.pid, errorOrSignal);
} else {
throw errorOrSignal;
}
});
}
function folderExists(folder: string): boolean {
if (fs.existsSync(folder)) {
if (fs.statSync(folder).isDirectory()) {
return true;
} else {
throw new Error(`"${folder}" exists but it is not a directory`);
}
}
return false;
}
/**
* Schema for `<browserModuleCache>/modules.json`.
*/
interface ModulesJson {
[moduleName: string]: ModuleBackup
}
interface ModuleBackup {
originalLocation: string
}
async function rebuildElectronModules(browserModuleCache: string, modules: string[], forceAbi: NodeABI | undefined, token: ExitToken): Promise<number> {
const modulesJsonPath = path.join(browserModuleCache, 'modules.json');
const modulesJson: ModulesJson = await fs.access(modulesJsonPath).then(
() => fs.readJson(modulesJsonPath),
() => ({})
);
let success = true;
// Backup already built browser modules.
await Promise.all(modules.map(async module => {
let modulePath;
try {
modulePath = require.resolve(`${module}/package.json`, {
paths: [process.cwd()],
});
} catch (_) {
console.debug(`Module not found: ${module}`);
return; // Skip current module.
}
const src = path.dirname(modulePath);
const dest = path.join(browserModuleCache, module);
try {
await fs.remove(dest);
await fs.copy(src, dest, { overwrite: true });
modulesJson[module] = {
originalLocation: src,
};
console.debug(`Processed "${module}"`);
} catch (error) {
console.error(`Error while doing a backup for "${module}": ${error}`);
success = false;
}
}));
if (Object.keys(modulesJson).length === 0) {
console.debug('No module to rebuild.');
return 0;
}
// Update manifest tracking the backups' original locations.
await fs.writeJson(modulesJsonPath, modulesJson, { spaces: 2 });
// If we failed to process a module then exit now.
if (!success) {
return 1;
}
const todo = modules.map(m => {
// electron-rebuild ignores the module namespace...
const slash = m.indexOf('/');
return m.startsWith('@') && slash !== -1
? m.substring(slash + 1)
: m;
});
let exitCode: number | undefined;
try {
if (process.env.THEIA_REBUILD_NO_WORKAROUND) {
exitCode = await runElectronRebuild(todo, forceAbi, token);
} else {
exitCode = await electronRebuildExtraModulesWorkaround(process.cwd(), todo, () => runElectronRebuild(todo, forceAbi, token), token);
}
} catch (error) {
console.error(error);
} finally {
// If code is undefined or different from zero we need to revert back to the browser modules.
if (exitCode !== 0) {
await revertBrowserModules(browserModuleCache, modules);
}
return exitCode ?? 1;
}
}
async function revertBrowserModules(browserModuleCache: string, modules: string[]): Promise<number> {
let exitCode = 0;
const modulesJsonPath = path.join(browserModuleCache, 'modules.json');
const modulesJson: ModulesJson = await fs.readJson(modulesJsonPath);
await Promise.all(Object.entries(modulesJson).map(async ([moduleName, entry]) => {
if (!modules.includes(moduleName)) {
return; // Skip modules that weren't requested.
}
const src = path.join(browserModuleCache, moduleName);
if (!await fs.pathExists(src)) {
delete modulesJson[moduleName];
console.error(`Missing backup for ${moduleName}!`);
exitCode = 1;
return;
}
const dest = entry.originalLocation;
try {
await fs.remove(dest);
await fs.copy(src, dest, { overwrite: false });
await fs.remove(src);
delete modulesJson[moduleName];
console.debug(`Reverted "${moduleName}"`);
} catch (error) {
console.error(`Error while reverting "${moduleName}": ${error}`);
exitCode = 1;
}
}));
if (Object.keys(modulesJson).length === 0) {
// We restored everything, so we can delete the cache.
await fs.remove(browserModuleCache);
} else {
// Some things were not restored, so we update the manifest.
await fs.writeJson(modulesJsonPath, modulesJson, { spaces: 2 });
}
return exitCode;
}
async function runElectronRebuild(modules: string[], forceAbi: NodeABI | undefined, token: ExitToken): Promise<number> {
const todo = modules.join(',');
return new Promise(async (resolve, reject) => {
let command = `npx --no-install electron-rebuild -f -w=${todo} -o=${todo}`;
if (forceAbi) {
command += ` --force-abi ${forceAbi}`;
}
const electronRebuild = cp.spawn(command, {
stdio: 'inherit',
shell: true,
});
token.onSignal(signal => electronRebuild.kill(signal));
electronRebuild.on('error', reject);
electronRebuild.on('close', (code, signal) => {
if (signal) {
reject(new Error(`electron-rebuild exited with "${signal}"`));
} else {
resolve(code!);
}
});
});
}
/**
* `electron-rebuild` is supposed to accept a list of modules to build, even when not part of the dependencies.
* But there is a bug that causes `electron-rebuild` to not correctly process this list of modules.
*
* This workaround will temporarily modify the current package.json file.
*
* PR with fix: https://github.com/electron/electron-rebuild/pull/888
*
* TODO: Remove this workaround.
*/
async function electronRebuildExtraModulesWorkaround<T>(cwd: string, extraModules: string[], run: (token: ExitToken) => Promise<T>, token: ExitToken): Promise<T> {
const packageJsonPath = path.resolve(cwd, 'package.json');
if (await fs.pathExists(packageJsonPath)) {
// package.json exists: We back it up before modifying it then revert it.
const packageJsonCopyPath = `${packageJsonPath}.copy`;
const packageJson = await fs.readJson(packageJsonPath);
await fs.copy(packageJsonPath, packageJsonCopyPath);
await throwIfSignal(token, async () => {
await fs.unlink(packageJsonCopyPath);
});
if (typeof packageJson.dependencies !== 'object') {
packageJson.dependencies = {};
}
for (const extraModule of extraModules) {
if (!packageJson.dependencies[extraModule]) {
packageJson.dependencies[extraModule] = '*';
}
}
try {
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
await throwIfSignal(token);
return await run(token);
} finally {
await fs.move(packageJsonCopyPath, packageJsonPath, { overwrite: true });
}
} else {
// package.json does not exist: We create one then remove it.
const packageJson = {
name: 'theia-rebuild-workaround',
version: '0.0.0',
dependencies: {} as Record<string, string>,
};
for (const extraModule of extraModules) {
packageJson.dependencies[extraModule] = '*';
}
try {
await fs.writeJson(packageJsonPath, packageJson);
await throwIfSignal(token);
return await run(token);
} finally {
await fs.unlink(packageJsonPath);
}
}
}
/**
* Temporarily install hooks to **try** to prevent the process from exiting while `run` is running.
*
* Note that it is still possible to kill the process and prevent cleanup logic (e.g. SIGKILL, computer forced shutdown, etc).
*/
async function guardExit<T>(run: (token: ExitToken) => Promise<T>): Promise<T> {
const token = new ExitTokenImpl();
const signalListener = (signal: NodeJS.Signals) => token._emitSignal(signal);
for (const signal of EXIT_SIGNALS) {
process.on(signal, signalListener);
}
try {
return await run(token);
} finally {
for (const signal of EXIT_SIGNALS) {
// FIXME we have a type clash here between Node, Electron and Mocha.
// Typescript is resolving here to Electron's Process interface which extends the NodeJS.EventEmitter interface
// However instead of the actual NodeJS.EventEmitter interface it resolves to an empty stub of Mocha
// Therefore it can't find the correct "off" signature and throws an error
// By casting to the NodeJS.EventEmitter ourselves, we short circuit the resolving and it succeeds
(process as NodeJS.EventEmitter).off(signal, signalListener);
}
}
}
class ExitTokenImpl implements ExitToken {
protected _listeners = new Set<(signal: NodeJS.Signals) => void>();
protected _lastSignal?: NodeJS.Signals;
onSignal(callback: (signal: NodeJS.Signals) => void): void {
this._listeners.add(callback);
}
getLastSignal(): NodeJS.Signals | undefined {
return this._lastSignal;
}
_emitSignal(signal: NodeJS.Signals): void {
this._lastSignal = signal;
for (const listener of this._listeners) {
listener(signal);
}
}
}
/**
* Throw `signal` if one was received, runs `cleanup` before doing so.
*/
async function throwIfSignal(token: ExitToken, cleanup?: () => Promise<void>): Promise<void> {
if (token.getLastSignal()) {
try {
await cleanup?.();
} finally {
// eslint-disable-next-line no-throw-literal
throw token.getLastSignal()!;
}
}
}

View File

@@ -0,0 +1,22 @@
{
"extends": "../../configs/base.tsconfig",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../application-package"
},
{
"path": "../ffmpeg"
},
{
"path": "../native-webpack-plugin"
}
]
}

View File

@@ -0,0 +1,10 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
}
};

View File

@@ -0,0 +1,26 @@
<div align='center'>
<br />
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
<h2>ECLIPSE THEIA - APPLICATION-PACKAGE</h2>
<hr />
</div>
## Additional Information
- [Theia - GitHub](https://github.com/eclipse-theia/theia)
- [Theia - Website](https://theia-ide.org/)
## License
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
## Trademark
"Theia" is a trademark of the Eclipse Foundation
<https://www.eclipse.org/theia>

View File

@@ -0,0 +1,52 @@
{
"name": "@theia/application-package",
"version": "1.68.0",
"description": "Theia application package API.",
"publishConfig": {
"access": "public"
},
"license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0",
"repository": {
"type": "git",
"url": "https://github.com/eclipse-theia/theia.git"
},
"bugs": {
"url": "https://github.com/eclipse-theia/theia/issues"
},
"homepage": "https://github.com/eclipse-theia/theia",
"files": [
"lib",
"src"
],
"main": "lib/index.js",
"typings": "lib/index.d.ts",
"scripts": {
"build": "theiaext build",
"clean": "theiaext clean",
"compile": "theiaext compile",
"lint": "theiaext lint",
"test": "theiaext test",
"watch": "theiaext watch"
},
"dependencies": {
"@theia/request": "1.68.0",
"@types/fs-extra": "^4.0.2",
"@types/semver": "^7.5.0",
"@types/write-json-file": "^2.2.1",
"deepmerge": "^4.2.2",
"fs-extra": "^4.0.2",
"is-electron": "^2.1.0",
"nano": "^10.1.3",
"resolve-package-path": "^4.0.3",
"semver": "^7.5.4",
"tslib": "^2.6.2",
"write-json-file": "^2.2.0"
},
"devDependencies": {
"@theia/ext-scripts": "1.68.0"
},
"nyc": {
"extends": "../../configs/nyc.json"
},
"gitHead": "21358137e41342742707f660b8e222f940a27652"
}

View File

@@ -0,0 +1,21 @@
// *****************************************************************************
// Copyright (C) 2021 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/**
* The default supported API version the framework supports.
* The version should be in the format `x.y.z`.
*/
export const DEFAULT_SUPPORTED_API_VERSION = '1.108.0';

View File

@@ -0,0 +1,62 @@
// *****************************************************************************
// Copyright (C) 2020 Maksim Ryzhikov and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as assert from 'assert';
import * as temp from 'temp';
import * as fs from 'fs-extra';
import * as path from 'path';
import { ApplicationPackage } from './application-package';
import { ApplicationProps } from './application-props';
import * as sinon from 'sinon';
const track = temp.track();
const sandbox = sinon.createSandbox();
describe('application-package', function (): void {
after((): void => {
sandbox.restore();
track.cleanupSync();
});
it('should print warning if user set unknown target in package.json and use browser as a default value', function (): void {
const warn = sandbox.stub(console, 'warn');
const root = createProjectWithTarget('foo');
const applicationPackage = new ApplicationPackage({ projectPath: root });
assert.deepStrictEqual(applicationPackage.target, ApplicationProps.ApplicationTarget.browser);
assert.deepStrictEqual(warn.called, true);
});
it('should set target from package.json', function (): void {
const target = 'electron';
const root = createProjectWithTarget(target);
const applicationPackage = new ApplicationPackage({ projectPath: root });
assert.deepStrictEqual(applicationPackage.target, target);
});
it('should prefer target from passed options over target from package.json', function (): void {
const pckTarget = 'electron';
const optTarget = 'browser';
const root = createProjectWithTarget(pckTarget);
const applicationPackage = new ApplicationPackage({ projectPath: root, appTarget: optTarget });
assert.deepStrictEqual(applicationPackage.target, optTarget);
});
function createProjectWithTarget(target: string): string {
const root = track.mkdirSync('foo-project');
fs.writeFileSync(path.join(root, 'package.json'), `{"theia": {"target": "${target}"}}`);
return root;
}
});

View File

@@ -0,0 +1,328 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as paths from 'path';
import { readJsonFile, writeJsonFile } from './json-file';
import { NpmRegistry, NodePackage, PublishedNodePackage, sortByKey } from './npm-registry';
import { Extension, ExtensionPackage, ExtensionPackageOptions, RawExtensionPackage } from './extension-package';
import { ExtensionPackageCollector } from './extension-package-collector';
import { ApplicationProps } from './application-props';
import deepmerge = require('deepmerge');
import resolvePackagePath = require('resolve-package-path');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ApplicationLog = (message?: any, ...optionalParams: any[]) => void;
export class ApplicationPackageOptions {
readonly projectPath: string;
readonly log?: ApplicationLog;
readonly error?: ApplicationLog;
readonly registry?: NpmRegistry;
readonly appTarget?: ApplicationProps.Target;
}
export type ApplicationModuleResolver = (parentPackagePath: string, modulePath: string) => string;
export class ApplicationPackage {
readonly projectPath: string;
readonly log: ApplicationLog;
readonly error: ApplicationLog;
constructor(
protected readonly options: ApplicationPackageOptions
) {
this.projectPath = options.projectPath;
this.log = options.log || console.log.bind(console);
this.error = options.error || console.error.bind(console);
}
protected _registry: NpmRegistry | undefined;
get registry(): NpmRegistry {
if (this._registry) {
return this._registry;
}
this._registry = this.options.registry || new NpmRegistry();
this._registry.updateProps(this.props);
return this._registry;
}
get target(): ApplicationProps.Target {
return this.props.target;
}
protected _props: ApplicationProps | undefined;
get props(): ApplicationProps {
if (this._props) {
return this._props;
}
const theia = this.pck.theia || {};
if (this.options.appTarget) {
theia.target = this.options.appTarget;
}
if (theia.target && !(Object.values(ApplicationProps.ApplicationTarget).includes(theia.target))) {
const defaultTarget = ApplicationProps.ApplicationTarget.browser;
console.warn(`Unknown application target '${theia.target}', '${defaultTarget}' to be used instead`);
theia.target = defaultTarget;
}
return this._props = deepmerge(ApplicationProps.DEFAULT, theia);
}
protected _pck: NodePackage | undefined;
get pck(): NodePackage {
if (this._pck) {
return this._pck;
}
return this._pck = readJsonFile(this.packagePath);
}
protected _frontendModules: Map<string, string> | undefined;
protected _frontendPreloadModules: Map<string, string> | undefined;
protected _frontendElectronModules: Map<string, string> | undefined;
protected _secondaryWindowModules: Map<string, string> | undefined;
protected _backendModules: Map<string, string> | undefined;
protected _backendElectronModules: Map<string, string> | undefined;
protected _electronMainModules: Map<string, string> | undefined;
protected _preloadModules: Map<string, string> | undefined;
protected _extensionPackages: ReadonlyArray<ExtensionPackage> | undefined;
/**
* Extension packages in the topological order.
*/
get extensionPackages(): ReadonlyArray<ExtensionPackage> {
if (!this._extensionPackages) {
const collector = new ExtensionPackageCollector(
(raw: PublishedNodePackage, options: ExtensionPackageOptions = {}) => this.newExtensionPackage(raw, options),
this.resolveModule
);
this._extensionPackages = collector.collect(this.packagePath, this.pck);
}
return this._extensionPackages;
}
getExtensionPackage(extension: string): ExtensionPackage | undefined {
return this.extensionPackages.find(pck => pck.name === extension);
}
async findExtensionPackage(extension: string): Promise<ExtensionPackage | undefined> {
return this.getExtensionPackage(extension) || this.resolveExtensionPackage(extension);
}
/**
* Resolve an extension name to its associated package
* @param extension the name of the extension's package as defined in "dependencies" (might be aliased)
* @returns the extension package
*/
async resolveExtensionPackage(extension: string): Promise<ExtensionPackage | undefined> {
const raw = await RawExtensionPackage.view(this.registry, extension);
return raw ? this.newExtensionPackage(raw, { alias: extension }) : undefined;
}
protected newExtensionPackage(raw: PublishedNodePackage, options: ExtensionPackageOptions = {}): ExtensionPackage {
return new ExtensionPackage(raw, this.registry, options);
}
get frontendPreloadModules(): Map<string, string> {
return this._frontendPreloadModules ??= this.computeModules('frontendPreload');
}
get frontendOnlyPreloadModules(): Map<string, string> {
if (!this._frontendPreloadModules) {
this._frontendPreloadModules = this.computeModules('frontendOnlyPreload', 'frontendPreload');
}
return this._frontendPreloadModules;
}
get frontendModules(): Map<string, string> {
return this._frontendModules ??= this.computeModules('frontend');
}
get frontendOnlyModules(): Map<string, string> {
if (!this._frontendModules) {
this._frontendModules = this.computeModules('frontendOnly', 'frontend');
}
return this._frontendModules;
}
get frontendElectronModules(): Map<string, string> {
return this._frontendElectronModules ??= this.computeModules('frontendElectron', 'frontend');
}
get secondaryWindowModules(): Map<string, string> {
return this._secondaryWindowModules ??= this.computeModules('secondaryWindow');
}
get backendModules(): Map<string, string> {
return this._backendModules ??= this.computeModules('backend');
}
get backendElectronModules(): Map<string, string> {
return this._backendElectronModules ??= this.computeModules('backendElectron', 'backend');
}
get electronMainModules(): Map<string, string> {
return this._electronMainModules ??= this.computeModules('electronMain');
}
get preloadModules(): Map<string, string> {
return this._preloadModules ??= this.computeModules('preload');
}
protected computeModules<P extends keyof Extension, S extends keyof Extension = P>(primary: P, secondary?: S): Map<string, string> {
const result = new Map<string, string>();
let moduleIndex = 1;
for (const extensionPackage of this.extensionPackages) {
const extensions = extensionPackage.theiaExtensions;
if (extensions) {
for (const extension of extensions) {
const modulePath = extension[primary] || (secondary && extension[secondary]);
if (typeof modulePath === 'string') {
const extensionPath = paths.join(extensionPackage.name, modulePath).split(paths.sep).join('/');
result.set(`${primary}_${moduleIndex}`, extensionPath);
moduleIndex = moduleIndex + 1;
}
}
}
}
return result;
}
relative(path: string): string {
return paths.relative(this.projectPath, path);
}
path(...segments: string[]): string {
return paths.resolve(this.projectPath, ...segments);
}
get packagePath(): string {
return this.path('package.json');
}
lib(...segments: string[]): string {
return this.path('lib', ...segments);
}
srcGen(...segments: string[]): string {
return this.path('src-gen', ...segments);
}
backend(...segments: string[]): string {
return this.srcGen('backend', ...segments);
}
bundledBackend(...segments: string[]): string {
return this.path('backend', 'bundle', ...segments);
}
frontend(...segments: string[]): string {
return this.srcGen('frontend', ...segments);
}
isBrowser(): boolean {
return this.target === ApplicationProps.ApplicationTarget.browser;
}
isElectron(): boolean {
return this.target === ApplicationProps.ApplicationTarget.electron;
}
isBrowserOnly(): boolean {
return this.target === ApplicationProps.ApplicationTarget.browserOnly;
}
ifBrowser<T>(value: T): T | undefined;
ifBrowser<T>(value: T, defaultValue: T): T;
ifBrowser<T>(value: T, defaultValue?: T): T | undefined {
return this.isBrowser() ? value : defaultValue;
}
ifElectron<T>(value: T): T | undefined;
ifElectron<T>(value: T, defaultValue: T): T;
ifElectron<T>(value: T, defaultValue?: T): T | undefined {
return this.isElectron() ? value : defaultValue;
}
ifBrowserOnly<T>(value: T): T | undefined;
ifBrowserOnly<T>(value: T, defaultValue: T): T;
ifBrowserOnly<T>(value: T, defaultValue?: T): T | undefined {
return this.isBrowserOnly() ? value : defaultValue;
}
get targetBackendModules(): Map<string, string> {
if (this.isBrowserOnly()) {
return new Map();
}
return this.ifBrowser(this.backendModules, this.backendElectronModules);
}
get targetFrontendModules(): Map<string, string> {
if (this.isBrowserOnly()) {
return this.frontendOnlyModules;
}
return this.ifBrowser(this.frontendModules, this.frontendElectronModules);
}
get targetFrontendPreloadModules(): Map<string, string> {
if (this.isBrowserOnly()) {
return this.frontendOnlyPreloadModules;
}
return this.frontendPreloadModules;
}
get targetElectronMainModules(): Map<string, string> {
return this.ifElectron(this.electronMainModules, new Map());
}
setDependency(name: string, version: string | undefined): boolean {
const dependencies = this.pck.dependencies || {};
const currentVersion = dependencies[name];
if (currentVersion === version) {
return false;
}
if (version) {
dependencies[name] = version;
} else {
delete dependencies[name];
}
this.pck.dependencies = sortByKey(dependencies);
return true;
}
save(): Promise<void> {
return writeJsonFile(this.packagePath, this.pck, {
detectIndent: true
});
}
protected _moduleResolver: undefined | ApplicationModuleResolver;
/**
* A node module resolver in the context of the application package.
*/
get resolveModule(): ApplicationModuleResolver {
if (!this._moduleResolver) {
this._moduleResolver = (parentPackagePath, modulePath) => {
const resolved = resolvePackagePath(modulePath, parentPackagePath);
if (!resolved) {
throw new Error(`Cannot resolve package ${modulePath} relative to ${parentPackagePath}`);
}
return resolved;
};
}
return this._moduleResolver!;
}
}

View File

@@ -0,0 +1,314 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import type { BrowserWindowConstructorOptions } from 'electron';
export import deepmerge = require('deepmerge');
export type RequiredRecursive<T> = {
[K in keyof T]-?: T[K] extends object ? RequiredRecursive<T[K]> : T[K]
};
/**
* Base configuration for the Theia application.
*/
export interface ApplicationConfig {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly [key: string]: any;
}
export type ElectronFrontendApplicationConfig = RequiredRecursive<ElectronFrontendApplicationConfig.Partial>;
export namespace ElectronFrontendApplicationConfig {
export const DEFAULT: ElectronFrontendApplicationConfig = {
windowOptions: {},
showWindowEarly: true,
splashScreenOptions: {},
uriScheme: 'theia'
};
export interface SplashScreenOptions {
/**
* Initial width of the splash screen. Defaults to 640.
*/
width?: number;
/**
* Initial height of the splash screen. Defaults to 480.
*/
height?: number;
/**
* Minimum amount of time in milliseconds to show the splash screen before main window is shown.
* Defaults to 0, i.e. the splash screen will be shown until the frontend application is ready.
*/
minDuration?: number;
/**
* Maximum amount of time in milliseconds before splash screen is removed and main window is shown.
* Defaults to 30000.
*/
maxDuration?: number;
/**
* The content to load in the splash screen.
* Will be resolved from application root.
*
* Mandatory attribute.
*/
content?: string;
}
export interface Partial {
/**
* Override or add properties to the electron `windowOptions`.
*
* Defaults to `{}`.
*/
readonly windowOptions?: BrowserWindowConstructorOptions;
/**
* Whether or not to show an empty Electron window as early as possible.
*
* Defaults to `true`.
*/
readonly showWindowEarly?: boolean;
/**
* Configuration options for splash screen.
*
* Defaults to `{}` which results in no splash screen being displayed.
*/
readonly splashScreenOptions?: SplashScreenOptions;
/**
* The custom uri scheme the application registers to in the operating system.
*/
readonly uriScheme: string;
}
}
export type DefaultTheme = string | Readonly<{ light: string, dark: string }>;
export namespace DefaultTheme {
export function defaultForOSTheme(theme: DefaultTheme): string {
if (typeof theme === 'string') {
return theme;
}
if (
typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
return theme.dark;
}
return theme.light;
}
export function defaultBackgroundColor(dark?: boolean): string {
// The default light background color is based on the `colors#editor.background` value from
// `packages/monaco/data/monaco-themes/vscode/dark_vs.json` and the dark background comes from the `light_vs.json`.
return dark ? '#1E1E1E' : '#FFFFFF';
}
}
/**
* Application configuration for the frontend. The following properties will be injected into the `index.html`.
*/
export type FrontendApplicationConfig = RequiredRecursive<FrontendApplicationConfig.Partial>;
export namespace FrontendApplicationConfig {
export const DEFAULT: FrontendApplicationConfig = {
applicationName: 'Eclipse Theia',
defaultTheme: { light: 'light', dark: 'dark' },
defaultIconTheme: 'theia-file-icons',
electron: ElectronFrontendApplicationConfig.DEFAULT,
defaultLocale: '',
validatePreferencesSchema: true,
reloadOnReconnect: false,
uriScheme: 'theia'
};
export interface Partial extends ApplicationConfig {
/**
* The default theme for the application.
*
* Defaults to `dark` if the OS's theme is dark. Otherwise `light`.
*/
readonly defaultTheme?: DefaultTheme;
/**
* The default icon theme for the application.
*
* Defaults to `none`.
*/
readonly defaultIconTheme?: string;
/**
* The name of the application.
*
* Defaults to `Eclipse Theia`.
*/
readonly applicationName?: string;
/**
* Electron specific configuration.
*
* Defaults to `ElectronFrontendApplicationConfig.DEFAULT`.
*/
readonly electron?: ElectronFrontendApplicationConfig.Partial;
/**
* The default locale for the application.
*
* Defaults to "".
*/
readonly defaultLocale?: string;
/**
* When `true`, the application will validate the JSON schema of the preferences on start
* and log warnings to the console if the schema is not valid.
*
* Defaults to `true`.
*/
readonly validatePreferencesSchema?: boolean;
/**
* When 'true', the window will reload in case the front end reconnects to a back-end,
* but the back end does not have a connection context for this front end anymore.
*/
readonly reloadOnReconnect?: boolean;
}
}
/**
* Application configuration for the backend.
*/
export type BackendApplicationConfig = RequiredRecursive<BackendApplicationConfig.Partial>;
export namespace BackendApplicationConfig {
export const DEFAULT: BackendApplicationConfig = {
singleInstance: true,
frontendConnectionTimeout: 0,
configurationFolder: '.theia'
};
export interface Partial extends ApplicationConfig {
/**
* If true and in Electron mode, only one instance of the application is allowed to run at a time.
*
* Defaults to `false`.
*/
readonly singleInstance?: boolean;
/**
* The time in ms the connection context will be preserved for reconnection after a front end disconnects.
*/
readonly frontendConnectionTimeout?: number;
/**
* Configuration folder within the home user folder
*
* Defaults to `.theia`
*/
readonly configurationFolder?: string;
}
}
/**
* Configuration for the generator.
*/
export type GeneratorConfig = RequiredRecursive<GeneratorConfig.Partial>;
export namespace GeneratorConfig {
export const DEFAULT: GeneratorConfig = {
preloadTemplate: ''
};
export interface Partial {
/**
* Template to use for extra preload content markup (file path or HTML).
*
* Defaults to `''`.
*/
readonly preloadTemplate?: string;
}
}
export interface NpmRegistryProps {
/**
* Defaults to `false`.
*/
readonly next: boolean;
/**
* Defaults to `https://registry.npmjs.org/`.
*/
readonly registry: string;
}
export namespace NpmRegistryProps {
export const DEFAULT: NpmRegistryProps = {
next: false,
registry: 'https://registry.npmjs.org/'
};
}
/**
* Representation of all backend and frontend related Theia extension and application properties.
*/
export interface ApplicationProps extends NpmRegistryProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly [key: string]: any;
/**
* Whether the extension targets the browser or electron. Defaults to `browser`.
*/
readonly target: ApplicationProps.Target;
/**
* Frontend related properties.
*/
readonly frontend: {
readonly config: FrontendApplicationConfig
};
/**
* Backend specific properties.
*/
readonly backend: {
readonly config: BackendApplicationConfig
};
/**
* Generator specific properties.
*/
readonly generator: {
readonly config: GeneratorConfig
};
}
export namespace ApplicationProps {
export type Target = `${ApplicationTarget}`;
export enum ApplicationTarget {
browser = 'browser',
electron = 'electron',
browserOnly = 'browser-only'
};
export const DEFAULT: ApplicationProps = {
...NpmRegistryProps.DEFAULT,
target: 'browser',
backend: {
config: BackendApplicationConfig.DEFAULT
},
frontend: {
config: FrontendApplicationConfig.DEFAULT
},
generator: {
config: GeneratorConfig.DEFAULT
}
};
}

View File

@@ -0,0 +1,76 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
const isElectron: () => boolean = require('is-electron');
/**
* The electron specific environment.
*/
class ElectronEnv {
/**
* Environment variable that can be accessed on the `process` to check if running in electron or not.
*/
readonly THEIA_ELECTRON_VERSION = 'THEIA_ELECTRON_VERSION';
/**
* `true` if running in electron. Otherwise, `false`.
*
* Can be called from both the `main` and the render process. Also works for forked cluster workers.
*/
is(): boolean {
return isElectron();
}
/**
* `true` if running in Electron in development mode. Otherwise, `false`.
*
* Cannot be used from the browser. From the browser, it is always `false`.
*/
isDevMode(): boolean {
return this.is()
&& typeof process !== 'undefined'
// `defaultApp` does not exist on the Node.js API, but on electron (`electron.d.ts`).
&& ((process as any).defaultApp || /node_modules[/\\]electron[/\\]/.test(process.execPath)); // eslint-disable-line @typescript-eslint/no-explicit-any
}
/**
* Creates and return with a new environment object which always contains the `ELECTRON_RUN_AS_NODE: 1` property pair.
* This should be used to `spawn` and `fork` a new Node.js process from the Node.js shipped with Electron. Otherwise, a new Electron
* process will be spawned which [has side-effects](https://github.com/eclipse-theia/theia/issues/5385).
*
* If called from the backend and the `env` argument is not defined, it falls back to `process.env` such as Node.js behaves
* with the [`SpawnOptions`](https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options).
* If `env` is defined, it will be shallow-copied.
*
* Calling this function from the frontend does not make any sense, hence throw an error.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
runAsNodeEnv(env?: any): any & { ELECTRON_RUN_AS_NODE: 1 } {
if (typeof process === 'undefined') {
throw new Error("'process' cannot be undefined.");
}
return {
...(env === undefined ? process.env : env),
ELECTRON_RUN_AS_NODE: 1
};
}
}
const electron = new ElectronEnv();
const environment: Readonly<{ electron: ElectronEnv }> = { electron };
export { environment };

View File

@@ -0,0 +1,88 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { readJsonFile } from './json-file';
import { NodePackage, PublishedNodePackage } from './npm-registry';
import { ExtensionPackage, ExtensionPackageOptions, RawExtensionPackage } from './extension-package';
export class ExtensionPackageCollector {
protected readonly sorted: ExtensionPackage[] = [];
protected readonly visited = new Set<string>();
constructor(
protected readonly extensionPackageFactory: (raw: PublishedNodePackage, options?: ExtensionPackageOptions) => ExtensionPackage,
protected readonly resolveModule: (packagepath: string, modulePath: string) => string
) { }
protected root: NodePackage;
collect(packagePath: string, pck: NodePackage): ReadonlyArray<ExtensionPackage> {
this.root = pck;
this.collectPackages(packagePath, pck);
return this.sorted;
}
protected collectPackages(packagePath: string, pck: NodePackage): void {
for (const [dependency, versionRange] of [
...Object.entries(pck.dependencies ?? {}),
...Object.entries(pck.peerDependencies ?? {})
]) {
const optional = pck.peerDependenciesMeta?.[dependency]?.optional || false;
this.collectPackage(packagePath, dependency, versionRange!, optional);
}
}
protected parent: ExtensionPackage | undefined;
protected collectPackagesWithParent(packagePath: string, pck: NodePackage, parent: ExtensionPackage): void {
const current = this.parent;
this.parent = parent;
this.collectPackages(packagePath, pck);
this.parent = current;
}
protected collectPackage(parentPackagePath: string, name: string, versionRange: string, optional: boolean): void {
if (this.visited.has(name)) {
return;
}
this.visited.add(name);
let packagePath: string | undefined;
try {
packagePath = this.resolveModule(parentPackagePath, name);
} catch (err) {
if (optional) {
console.log(`Could not resolve optional peer dependency '${name}'. Skipping...`);
} else {
console.error(err.message);
}
}
if (!packagePath) {
return;
}
const pck: NodePackage = readJsonFile(packagePath);
if (RawExtensionPackage.is(pck)) {
const parent = this.parent;
const version = pck.version;
const transitive = !(name in this.root.dependencies!);
pck.installed = { packagePath, version, parent, transitive };
pck.version = versionRange;
const extensionPackage = this.extensionPackageFactory(pck, { alias: name });
this.collectPackagesWithParent(packagePath, pck, extensionPackage);
this.sorted.push(extensionPackage);
}
}
}

View File

@@ -0,0 +1,223 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as fs from 'fs-extra';
import * as paths from 'path';
import * as semver from 'semver';
import { NpmRegistry, PublishedNodePackage, NodePackage } from './npm-registry';
export interface Extension {
frontendPreload?: string;
frontendOnlyPreload?: string;
frontend?: string;
frontendOnly?: string;
frontendElectron?: string;
secondaryWindow?: string;
backend?: string;
backendElectron?: string;
electronMain?: string;
preload?: string;
}
export interface ExtensionPackageOptions {
/**
* Alias to use in place of the original package's name.
*/
alias?: string
}
export class ExtensionPackage {
protected _name: string;
constructor(
readonly raw: PublishedNodePackage & Partial<RawExtensionPackage>,
protected readonly registry: NpmRegistry,
options: ExtensionPackageOptions = {}
) {
this._name = options.alias ?? raw.name;
}
/**
* The name of the extension's package as defined in "dependencies" (might be aliased)
*/
get name(): string {
return this._name;
}
get version(): string {
if (this.raw.installed) {
return this.raw.installed.version;
}
if (this.raw.view) {
const latestVersion = this.raw.view.latestVersion;
if (latestVersion) {
return latestVersion;
}
}
return this.raw.version;
}
get description(): string {
return this.raw.description || '';
}
get theiaExtensions(): Extension[] {
return this.raw.theiaExtensions || [];
}
get installed(): boolean {
return !!this.raw.installed;
}
get dependent(): string | undefined {
if (!this.transitive) {
return undefined;
}
let current = this.parent!;
let parent = current.parent;
while (parent !== undefined) {
current = parent;
parent = current.parent;
}
return current.name;
}
get transitive(): boolean {
return !!this.raw.installed && this.raw.installed.transitive;
}
get parent(): ExtensionPackage | undefined {
if (this.raw.installed) {
return this.raw.installed.parent;
}
return undefined;
}
protected async view(): Promise<RawExtensionPackage.ViewState> {
if (this.raw.view === undefined) {
const raw = await RawExtensionPackage.view(this.registry, this.name, this.version);
this.raw.view = raw ? raw.view : new RawExtensionPackage.ViewState(this.registry);
}
return this.raw.view!;
}
protected readme?: string;
async getReadme(): Promise<string> {
if (this.readme === undefined) {
this.readme = await this.resolveReadme();
}
return this.readme;
}
protected async resolveReadme(): Promise<string> {
const raw = await this.view();
if (raw && raw.readme) {
return raw.readme;
}
if (this.raw.installed) {
const readmePath = paths.resolve(this.raw.installed.packagePath, '..', 'README.md');
if (await fs.pathExists(readmePath)) {
return fs.readFile(readmePath, { encoding: 'utf8' });
}
return '';
}
return '';
}
getAuthor(): string {
if (this.raw.publisher) {
return this.raw.publisher.username;
}
if (typeof this.raw.author === 'string') {
return this.raw.author;
}
if (this.raw.author && this.raw.author.name) {
return this.raw.author.name;
}
if (!!this.raw.maintainers && this.raw.maintainers.length > 0) {
return this.raw.maintainers[0].username;
}
return '';
}
}
export interface RawExtensionPackage extends PublishedNodePackage {
installed?: RawExtensionPackage.InstalledState
view?: RawExtensionPackage.ViewState
theiaExtensions: Extension[];
}
export namespace RawExtensionPackage {
export interface InstalledState {
version: string;
packagePath: string;
transitive: boolean;
parent?: ExtensionPackage;
}
export class ViewState {
readme?: string;
tags?: {
[tag: string]: string
};
constructor(
protected readonly registry: NpmRegistry
) { }
get latestVersion(): string | undefined {
if (this.tags) {
if (this.registry.props.next) {
const next = this.tags['next'];
if (next !== undefined) {
return next;
}
}
const latest = this.tags['latest'];
if (this.registry.props.next || !semver.prerelease(latest)) {
return latest;
}
return undefined;
}
return undefined;
}
}
export function is(pck: NodePackage | undefined): pck is RawExtensionPackage {
return PublishedNodePackage.is(pck) && !!pck.theiaExtensions;
}
export async function view(registry: NpmRegistry, name: string, version?: string): Promise<RawExtensionPackage | undefined> {
const result = await registry.view(name).catch(() => undefined);
if (!result) {
return undefined;
}
const tags = result['dist-tags'];
const versions = [tags['latest']];
if (registry.props.next) {
versions.push(tags['next']);
}
if (version) {
versions.push(tags[version], version);
}
for (const current of versions.reverse()) {
const raw = result.versions[current];
if (is(raw)) {
const viewState = new ViewState(registry);
viewState.readme = result.readme;
viewState.tags = tags;
raw.view = viewState;
return raw;
}
}
return undefined;
}
}

View File

@@ -0,0 +1,22 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export * from './npm-registry';
export * from './extension-package';
export * from './application-package';
export * from './application-props';
export * from './environment';
export * from './api';

View File

@@ -0,0 +1,25 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as fs from 'fs';
import writeJsonFile = require('write-json-file');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function readJsonFile(path: string): any {
return JSON.parse(fs.readFileSync(path, { encoding: 'utf-8' }));
}
export { writeJsonFile, readJsonFile };

View File

@@ -0,0 +1,166 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as nano from 'nano';
import { RequestContext } from '@theia/request';
import { NodeRequestService } from '@theia/request/lib/node-request-service';
import { NpmRegistryProps } from './application-props';
export interface IChangeStream {
on(event: 'data', cb: (change: { id: string }) => void): void;
destroy(): void;
}
export interface Author {
name: string;
email: string;
}
export interface Maintainer {
username: string;
email: string;
}
export interface Dependencies {
[name: string]: string | undefined;
}
export interface PeerDependenciesMeta {
[name: string]: { optional: boolean } | undefined;
}
export interface NodePackage {
name?: string;
version?: string;
description?: string;
publisher?: Maintainer;
author?: string | Author;
maintainers?: Maintainer[];
keywords?: string[];
dependencies?: Dependencies;
peerDependencies?: Dependencies;
peerDependenciesMeta?: PeerDependenciesMeta;
[property: string]: any;
}
export interface PublishedNodePackage extends NodePackage {
name: string;
version: string;
}
export namespace PublishedNodePackage {
export function is(pck: NodePackage | undefined): pck is PublishedNodePackage {
return !!pck && !!pck.name && !!pck.version;
}
}
export interface ViewResult {
'dist-tags': {
[tag: string]: string
}
'versions': {
[version: string]: NodePackage
},
'readme': string;
[key: string]: any
}
export function sortByKey(object: { [key: string]: any }): {
[key: string]: any;
} {
return Object.keys(object).sort().reduce((sorted, key) => {
sorted[key] = object[key];
return sorted;
}, {} as { [key: string]: any });
}
export class NpmRegistryOptions {
/**
* Default: false.
*/
readonly watchChanges: boolean;
}
export class NpmRegistry {
readonly props: NpmRegistryProps = { ...NpmRegistryProps.DEFAULT };
protected readonly options: NpmRegistryOptions;
protected changes?: nano.ChangesReaderScope;
protected readonly index = new Map<string, Promise<ViewResult>>();
protected request: NodeRequestService;
constructor(options?: Partial<NpmRegistryOptions>) {
this.options = {
watchChanges: false,
...options
};
this.resetIndex();
this.request = new NodeRequestService();
}
updateProps(props?: Partial<NpmRegistryProps>): void {
const oldRegistry = this.props.registry;
Object.assign(this.props, props);
const newRegistry = this.props.registry;
if (oldRegistry !== newRegistry) {
this.resetIndex();
}
}
protected resetIndex(): void {
this.index.clear();
if (this.options.watchChanges && this.props.registry === NpmRegistryProps.DEFAULT.registry) {
if (this.changes) {
this.changes.stop();
}
// Invalidate index with NPM registry web hooks
this.changes = nano('https://replicate.npmjs.com').use('registry').changesReader;
this.changes.get({}).on('change', change => this.invalidate(change.id));
}
}
protected invalidate(name: string): void {
if (this.index.delete(name)) {
this.view(name);
}
}
view(name: string): Promise<ViewResult> {
const indexed = this.index.get(name);
if (indexed) {
return indexed;
}
const result = this.doView(name);
this.index.set(name, result);
result.catch(() => this.index.delete(name));
return result;
}
protected async doView(name: string): Promise<ViewResult> {
let url = this.props.registry;
if (name[0] === '@') {
url += '@' + encodeURIComponent(name.substring(1));
} else {
url += encodeURIComponent(name);
}
const response = await this.request.request({ url });
if (response.res.statusCode !== 200) {
throw new Error(`HTTP ${response.res.statusCode}: for ${url}`);
}
return RequestContext.asJson<ViewResult>(response);
}
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../../configs/base.tsconfig",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../request"
}
]
}

View File

@@ -0,0 +1,13 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
},
rules: {
'import/no-dynamic-require': 'off'
}
};

370
dev-packages/cli/README.md Normal file
View File

@@ -0,0 +1,370 @@
<div align='center'>
<br />
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
<h2>ECLIPSE THEIA - CLI</h2>
<hr />
</div>
## Outline
- [Outline](#outline)
- [Description](#description)
- [Getting Started](#getting-started)
- [Configure](#configure)
- [Application Properties](#application-properties)
- [Default Preferences](#default-preferences)
- [Default Theme](#default-theme)
- [Build Target](#build-target)
- [Electron Frontend Application Config](#electron-frontend-application-config)
- [Using Latest Builds](#using-latest-builds)
- [Building](#building)
- [Build](#build)
- [Watch](#watch)
- [Clean](#clean)
- [Rebuilding Native Modules](#rebuilding-native-modules)
- [Running](#running)
- [Debugging](#debugging)
- [Testing](#testing)
- [Enabling Tests](#enabling-tests)
- [Writing Tests](#writing-tests)
- [Running Tests](#running-tests)
- [Configuring Tests](#configuring-tests)
- [Inspecting Tests](#inspecting-tests)
- [Reporting Test Coverage](#reporting-test-coverage)
- [Downloading Plugins](#downloading-plugins)
- [Autogenerated Application](#autogenerated-application)
- [Additional Information](#additional-information)
- [License](#license)
- [Trademark](#trademark)
## Description
The `@theia/cli` package provides helpful scripts and commands for extension and application development.
The contributed `theia`, is a command line tool to manage Theia-based applications.
## Getting Started
Install `@theia/cli` as a dev dependency in your application.
With yarn:
```bash
yarn add @theia/cli@next --dev
```
With npm:
```bash
npm install @theia/cli@next --save-dev
```
## Configure
A Theia-based application can be configured via the `theia` property as described in the application's `package.json`.
### Application Properties
It is possible `Application Properties` for a given application.\
For example, an application can define it's `applicationName` using the following syntax:
```json
"theia": {
"frontend": {
"config": {
"applicationName": "Custom Application Name",
}
}
},
```
### Default Preferences
If required, an application can define for a given preference, the default value.
For example, an application can update the preference value for `files.enableTrash` based on it's requirements:
```json
"theia": {
"frontend": {
"config": {
"preferences": {
"files.enableTrash": false
}
}
}
},
```
### Default Theme
Default color and icon themes can be configured in `theia.frontend.config` section:
```json
"theia": {
"frontend": {
"config": {
"defaultTheme": "light",
"defaultIconTheme": "vs-seti"
}
}
},
```
### Build Target
The following targets are supported: `browser` and `electron`. By default `browser` target is used.
The target can be configured in the `package.json` via `theia/target` property, e.g:
```json
{
"theia": {
"target": "electron"
},
"dependencies": {
"@theia/electron": "latest"
}
}
```
For `electron` target applications, is it mandatory to include **Electron** runtime dependencies. The `@theia/electron` package is the easiest way to install the necessary dependencies.
### Electron Frontend Application Config
The `electron` frontend application configuration provides configuration options for the `electron` target.\
The currently supported configurations are:
- `disallowReloadKeybinding`: if set to `true`, reloading the current browser window won't be possible with the <kbd>Ctrl/Cmd</kbd> + <kbd>r</kbd> keybinding. It is `false` by default. Has no effect if not in an electron environment.
- `windowOptions`: override or add properties to the electron `windowOptions`.
```json
{
"theia": {
"target": "electron",
"frontend": {
"config": {
"electron": {
"disallowReloadKeybinding": true,
"windowOptions": {
"titleBarStyle": "hidden",
"webPreferences": {
"webSecurity": false,
"nodeIntegration": true,
"webviewTag": true
}
}
}
}
}
}
}
```
### Using Latest Builds
If you set `next` in your theia config, then Theia will prefer `next` over `latest` as the latest tag.
```json
{
"theia": {
"next": "true"
}
}
```
## Building
### Build
The following command can be used to build the application:
**Development**
theia build --mode development
**Production**
theia build
### Watch
The following command can be used to rebuild the application on each change:
theia build --watch --mode development
### Clean
The following command can be used to clean up the build result:
In order to clean up the build result:
theia clean
Arguments are passed directly to [webpack](https://webpack.js.org/). Use `--help` to learn which options are supported.
## Rebuilding Native Modules
In order to run Electron targets, one should rebuild native node modules for an electron version:
theia rebuild
To rollback native modules, change the target to `browser` and run the command again.
## Running
To run the backend server:
theia start
For the browser target a server is started on <http://localhost:3000> by default.
For the electron target a server is started on `localhost` host with the dynamically allocated port by default.
Arguments are passed directly to a server, use `--help` to learn which options are supported.
## Debugging
To debug the backend server:
theia start --inspect
Theia CLI accepts `--inspect` node flag: <https://nodejs.org/en/docs/inspector/#command-line-options>.
## Testing
### Enabling Tests
First enable `expose-loader` in `webpack.config.js`
to expose modules from bundled code to tests
by un-commenting:
```js
/**
* Expose bundled modules on window.theia.moduleName namespace, e.g.
* window['theia']['@theia/core/lib/common/uri'].
* Such syntax can be used by external code, for instance, for testing.
config.module.rules.push({
test: /\.js$/,
loader: require.resolve('@theia/application-manager/lib/expose-loader')
}); */
```
After that run `theia build` again to expose modules in generated bundle files.
### Writing Tests
See [API Integration Testing](../../doc/api-testing.md) docs.
### Running Tests
To start the backend server and run API tests against it:
theia test
After running test this command terminates. It accepts the same arguments as `start` command,
but as well additional arguments to specify test files, enable inspection or generate test coverage.
### Configuring Tests
To specify test files:
theia test . --test-spec=./test/*.spec.js --plugins=./plugins
This command starts the application with a current directory as a workspace,
load VS Code extensions from `./plugins`
and run test files matching `./test/*.spec.js` glob.
Use `theia test --help` to learn more options. Test specific options start with `--test-`.
### Inspecting Tests
To inspect tests:
theia test . --test-spec=./test/*.spec.js --test-inspect --inspect
This command starts the application server in the debug mode
as well as open the Chrome devtools to debug frontend code and test files.
One can reload/rerun code and tests by simply reloading the page.
> Important! Since tests are relying on focus, while running tests keep the page focused.
### Reporting Test Coverage
To report test coverage:
theia test . --test-spec=./test/*.spec.js --test-coverage
This command executes tests and generate test coverage files consumable by [Istanbul](https://github.com/istanbuljs/istanbuljs).
## Downloading Plugins
The `@theia/cli` package provides a utility for applications to define and download a list of plugins it requires as part of their application using the command:
theia download:plugins
This utility works by declaring in the `package.json` a location to store downloaded plugins, as well as defining each plugin the application wishes to download.
The property `theiaPluginsDir` describes the location of which to download plugins (relative to the `package.json`), for example:
```json
"theiaPluginsDir": "plugins",
```
The property `theiaPlugins` describes the list of plugins to download, for example:
```json
"theiaPlugins": {
"vscode.theme-defaults": "https://open-vsx.org/api/vscode/theme-defaults/1.62.3/file/vscode.theme-defaults-1.62.3.vsix",
"vscode-builtin-extension-pack": "https://open-vsx.org/api/eclipse-theia/builtin-extension-pack/1.50.0/file/eclipse-theia.builtin-extension-pack-1.50.0.vsix",
"vscode-editorconfig": "https://open-vsx.org/api/EditorConfig/EditorConfig/0.14.4/file/EditorConfig.EditorConfig-0.14.4.vsix",
"vscode-eslint": "https://open-vsx.org/api/dbaeumer/vscode-eslint/2.1.1/file/dbaeumer.vscode-eslint-2.1.1.vsix",
"rust-analyzer": "https://open-vsx.org/api/rust-lang/rust-analyzer/${targetPlatform}/0.4.1473/file/rust-lang.rust-analyzer-0.4.1473@${targetPlatform}.vsix"
}
```
As seen in the `rust-analyzer` entry we can use placeholders in the URLs. Supported placeholders are:
- The `${targetPlatform}` Placeholder, which resolves to a string like `win32-x64` describing the local system and architecture. This is useful for adding non-universal plugins.
Please note that in order to use `extensionPacks` properly you should use `namespace.name` as the `id` you give extensions so that when resolving the pack we do not re-download an existing plugin under a different name.
The property `theiaPluginsExcludeIds` can be used to declare the list of plugin `ids` to exclude when using extension-packs.
The `ids` referenced by the property will not be downloaded when resolving extension-packs, and can be used to omit extensions which are problematic or unwanted. The format of the property is as follows:
```json
"theiaPluginsExcludeIds": [
"vscode.cpp"
]
```
## Autogenerated Application
This package can auto-generate application code for both the backend and frontend, as well as webpack configuration files.
When targeting Electron, the `electron-main.js` script will spawn the backend process in a Node.js sub-process, where Electron's API won't be available. To prevent the generated application from forking the backend, you can pass a `--no-cluster` flag. This flag is mostly useful/used for debugging.
```sh
# when developing a Theia application with @theia/cli:
yarn theia start --no-cluster
# when starting a bundled application made using @theia/cli:
bundled-application.exe --no-cluster
```
## Additional Information
- [Theia - GitHub](https://github.com/eclipse-theia/theia)
- [Theia - Website](https://theia-ide.org/)
## License
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
## Trademark
"Theia" is a trademark of the Eclipse Foundation
<https://www.eclipse.org/theia>

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env node
// *****************************************************************************
// Copyright (C) 2024 STMicroelectronics and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
// @ts-check
const path = require('path');
const cp = require('child_process');
const patchPackage = require.resolve('patch-package');
console.log(`patch-package = ${patchPackage}`);
const patchesDir = path.join('.', 'node_modules', '@theia', 'cli', 'patches');
console.log(`patchesdir = ${patchesDir}`);
const env = Object.assign({}, process.env);
const scriptProcess = cp.exec(`node "${patchPackage}" --patch-dir "${patchesDir}"`, {
cwd: process.cwd(),
env
});
scriptProcess.stdout.pipe(process.stdout);
scriptProcess.stderr.pipe(process.stderr);

2
dev-packages/cli/bin/theia.js Executable file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env node
require('../lib/theia')

View File

@@ -0,0 +1,68 @@
{
"name": "@theia/cli",
"version": "1.68.0",
"description": "Theia CLI.",
"publishConfig": {
"access": "public"
},
"license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0",
"repository": {
"type": "git",
"url": "https://github.com/eclipse-theia/theia.git"
},
"bugs": {
"url": "https://github.com/eclipse-theia/theia/issues"
},
"homepage": "https://github.com/eclipse-theia/theia",
"files": [
"bin",
"lib",
"src",
"patches"
],
"bin": {
"theia": "./bin/theia.js",
"theia-patch": "./bin/theia-patch.js"
},
"scripts": {
"compile": "theiaext compile",
"lint": "theiaext lint",
"build": "theiaext build",
"watch": "theiaext watch",
"clean": "theiaext clean"
},
"dependencies": {
"@theia/application-manager": "1.68.0",
"@theia/application-package": "1.68.0",
"@theia/ffmpeg": "1.68.0",
"@theia/localization-manager": "1.68.0",
"@theia/ovsx-client": "1.68.0",
"@theia/request": "1.68.0",
"@types/chai": "^4.2.7",
"@types/mocha": "^10.0.0",
"@types/node-fetch": "^2.5.7",
"chai": "^4.3.10",
"chalk": "4.0.0",
"decompress": "^4.2.1",
"escape-string-regexp": "4.0.0",
"glob": "^8.0.3",
"http-server": "^14.1.1",
"limiter": "^2.1.0",
"log-update": "^4.0.0",
"mocha": "^10.1.0",
"patch-package": "^8.0.0",
"puppeteer": "23.1.0",
"puppeteer-core": "23.1.0",
"puppeteer-to-istanbul": "1.4.0",
"temp": "^0.9.1",
"tslib": "^2.6.2",
"yargs": "^15.3.1"
},
"devDependencies": {
"@types/chai": "^4.2.7",
"@types/mocha": "^10.0.0",
"@types/node-fetch": "^2.5.7",
"@types/proxy-from-env": "^1.0.1"
},
"gitHead": "21358137e41342742707f660b8e222f940a27652"
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,328 @@
// *****************************************************************************
// Copyright (C) 2022 STMicroelectronics and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as fs from 'fs';
import * as path from 'path';
import { glob } from 'glob';
import { create as logUpdater } from 'log-update';
import * as chalk from 'chalk';
const NODE_MODULES = 'node_modules';
const PACKAGE_JSON = 'package.json';
const logUpdate = logUpdater(process.stdout);
interface CheckDependenciesOptions {
workspaces: string[] | undefined,
include: string[],
exclude: string[],
skipHoisted: boolean,
skipUniqueness: boolean,
skipSingleTheiaVersion: boolean,
onlyTheiaExtensions: boolean,
suppress: boolean
}
/** NPM package */
interface Package {
/** Name of the package, e.g. `@theia/core`. */
name: string,
/** Actual resolved version of the package, e.g. `1.27.0`. */
version: string,
/** Path of the package relative to the workspace, e.g. `node_modules/@theia/core`. */
path: string,
/** Whether the package is hoisted or not, i.e., whether it is contained in the root `node_modules`. */
hoisted: boolean,
/** Workspace location in which the package was found. */
dependent: string | undefined,
/** Whether the package is a Theia extension or not */
isTheiaExtension?: boolean,
}
/** Issue found with a specific package. */
interface DependencyIssue {
/** Type of the issue. */
issueType: 'not-hoisted' | 'multiple-versions' | 'theia-version-mix',
/** Package with issue. */
package: Package,
/** Packages related to this issue. */
relatedPackages: Package[],
/** Severity */
severity: 'warning' | 'error'
}
export default function checkDependencies(options: CheckDependenciesOptions): void {
const workspaces = deriveWorkspaces(options);
logUpdate(`✅ Found ${workspaces.length} workspaces`);
console.log('🔍 Collecting dependencies...');
const dependencies = findAllDependencies(workspaces, options);
logUpdate(`✅ Found ${dependencies.length} dependencies`);
console.log('🔍 Analyzing dependencies...');
const issues = analyzeDependencies(dependencies, options);
if (issues.length <= 0) {
logUpdate('✅ No issues were found');
process.exit(0);
}
logUpdate('🟠 Found ' + issues.length + ' issues');
printIssues(issues);
printHints(issues);
process.exit(options.suppress ? 0 : 1);
}
function deriveWorkspaces(options: CheckDependenciesOptions): string[] {
const wsGlobs = options.workspaces ?? readWorkspaceGlobsFromPackageJson();
const workspaces: string[] = [];
for (const wsGlob of wsGlobs) {
workspaces.push(...glob.sync(wsGlob + '/'));
}
return workspaces;
}
function readWorkspaceGlobsFromPackageJson(): string[] {
const rootPackageJson = path.join(process.cwd(), PACKAGE_JSON);
if (!fs.existsSync(rootPackageJson)) {
console.error('Directory does not contain a package.json with defined workspaces');
console.info('Run in the root of a Theia project or specify them via --workspaces');
process.exit(1);
}
return require(rootPackageJson).workspaces ?? [];
}
function findAllDependencies(workspaces: string[], options: CheckDependenciesOptions): Package[] {
const dependencies: Package[] = [];
dependencies.push(...findDependencies('.', options));
for (const workspace of workspaces) {
dependencies.push(...findDependencies(workspace, options));
}
return dependencies;
}
function findDependencies(workspace: string, options: CheckDependenciesOptions): Package[] {
const dependent = getPackageName(path.join(process.cwd(), workspace, PACKAGE_JSON));
const nodeModulesDir = path.join(workspace, NODE_MODULES);
const matchingPackageJsons: Package[] = [];
options.include.forEach(include =>
glob.sync(`${include}/${PACKAGE_JSON}`, {
cwd: nodeModulesDir,
ignore: [
`**/${NODE_MODULES}/**`, // node_modules folders within dependencies
`[^@]*/*/**/${PACKAGE_JSON}`, // package.json that isn't at the package root (and not in an @org)
`@*/*/*/**/${PACKAGE_JSON}`, // package.json that isn't at the package root (and in an @org)
...options.exclude] // user-specified exclude patterns
}).forEach(packageJsonPath => {
const dependency = toDependency(packageJsonPath, nodeModulesDir, dependent);
if (!options.onlyTheiaExtensions || dependency.isTheiaExtension) {
matchingPackageJsons.push(dependency);
}
const childNodeModules: string = path.join(nodeModulesDir, packageJsonPath, '..');
matchingPackageJsons.push(...findDependencies(childNodeModules, options));
})
);
return matchingPackageJsons;
}
function toDependency(packageJsonPath: string, nodeModulesDir: string, dependent?: string): Package {
const fullPackageJsonPath = path.join(process.cwd(), nodeModulesDir, packageJsonPath);
const name = getPackageName(fullPackageJsonPath);
const version = getPackageVersion(fullPackageJsonPath);
return {
name: name ?? packageJsonPath.replace('/' + PACKAGE_JSON, ''),
version: version ?? 'unknown',
path: path.relative(process.cwd(), fullPackageJsonPath),
hoisted: nodeModulesDir === NODE_MODULES,
dependent: dependent,
isTheiaExtension: isTheiaExtension(fullPackageJsonPath)
};
}
function getPackageVersion(fullPackageJsonPath: string): string | undefined {
try {
return require(fullPackageJsonPath).version;
} catch (error) {
return undefined;
}
}
function getPackageName(fullPackageJsonPath: string): string | undefined {
try {
return require(fullPackageJsonPath).name;
} catch (error) {
return undefined;
}
}
function isTheiaExtension(fullPackageJsonPath: string): boolean {
try {
const theiaExtension = require(fullPackageJsonPath).theiaExtensions;
return theiaExtension ? true : false;
} catch (error) {
return false;
}
}
function analyzeDependencies(packages: Package[], options: CheckDependenciesOptions): DependencyIssue[] {
const issues: DependencyIssue[] = [];
if (!options.skipHoisted) {
issues.push(...findNotHoistedDependencies(packages, options));
}
if (!options.skipUniqueness) {
issues.push(...findDuplicateDependencies(packages, options));
}
if (!options.skipSingleTheiaVersion) {
issues.push(...findTheiaVersionMix(packages, options));
}
return issues;
}
function findNotHoistedDependencies(packages: Package[], options: CheckDependenciesOptions): DependencyIssue[] {
const issues: DependencyIssue[] = [];
const nonHoistedPackages = packages.filter(p => p.hoisted === false);
for (const nonHoistedPackage of nonHoistedPackages) {
issues.push(createNonHoistedPackageIssue(nonHoistedPackage, options));
}
return issues;
}
function createNonHoistedPackageIssue(nonHoistedPackage: Package, options: CheckDependenciesOptions): DependencyIssue {
return {
issueType: 'not-hoisted',
package: nonHoistedPackage,
relatedPackages: [getHoistedPackageByName(nonHoistedPackage.name)],
severity: options.suppress ? 'warning' : 'error'
};
}
function getHoistedPackageByName(name: string): Package {
return toDependency(path.join(name, PACKAGE_JSON), NODE_MODULES);
}
function findDuplicateDependencies(packages: Package[], options: CheckDependenciesOptions): DependencyIssue[] {
const duplicates: string[] = [];
const packagesGroupedByName = new Map<string, Package[]>();
for (const currentPackage of packages) {
const name = currentPackage.name;
if (!packagesGroupedByName.has(name)) {
packagesGroupedByName.set(name, []);
}
const currentPackages = packagesGroupedByName.get(name)!;
currentPackages.push(currentPackage);
if (currentPackages.length > 1 && duplicates.indexOf(name) === -1) {
duplicates.push(name);
}
}
duplicates.sort();
const issues: DependencyIssue[] = [];
for (const duplicate of duplicates) {
const duplicatePackages = packagesGroupedByName.get(duplicate);
if (duplicatePackages && duplicatePackages.length > 0) {
issues.push({
issueType: 'multiple-versions',
package: duplicatePackages.pop()!,
relatedPackages: duplicatePackages,
severity: options.suppress ? 'warning' : 'error'
});
}
}
return issues;
}
function findTheiaVersionMix(packages: Package[], options: CheckDependenciesOptions): DependencyIssue[] {
// @theia/monaco-editor-core is following the versions of Monaco so it can't be part of this check
const theiaPackages = packages.filter(p => p.name.startsWith('@theia/') && !p.name.startsWith('@theia/monaco-editor-core'));
let theiaVersion = undefined;
let referenceTheiaPackage = undefined;
const packagesWithOtherVersion: Package[] = [];
for (const theiaPackage of theiaPackages) {
if (!theiaVersion && theiaPackage.version) {
theiaVersion = theiaPackage.version;
referenceTheiaPackage = theiaPackage;
} else if (theiaVersion !== theiaPackage.version) {
packagesWithOtherVersion.push(theiaPackage);
}
}
if (referenceTheiaPackage && packagesWithOtherVersion.length > 0) {
return [{
issueType: 'theia-version-mix',
package: referenceTheiaPackage,
relatedPackages: packagesWithOtherVersion,
severity: 'error'
}];
}
return [];
}
function printIssues(issues: DependencyIssue[]): void {
console.log();
const indent = issues.length.toString().length;
issues.forEach((issue, index) => {
printIssue(issue, index + 1, indent);
});
}
function printIssue(issue: DependencyIssue, issueNumber: number, indent: number): void {
const remainingIndent = indent - issueNumber.toString().length;
const indentString = ' '.repeat(remainingIndent + 1);
console.log(issueTitle(issue, issueNumber, indentString));
console.log(issueDetails(issue, ' ' + ' '.repeat(indent)));
console.log();
}
function issueTitle(issue: DependencyIssue, issueNumber: number, indent: string): string {
const dependent = issue.package.dependent ? ` in ${chalk.blueBright(issue.package.dependent ?? 'unknown')}` : '';
return chalk.bgWhiteBright.bold.black(`#${issueNumber}${indent}`) + ' ' + chalk.cyanBright(issue.package.name)
+ dependent + chalk.dim(` [${issue.issueType}]`);
}
function issueDetails(issue: DependencyIssue, indent: string): string {
return indent + severity(issue) + ' ' + issueMessage(issue) + '\n'
+ indent + versionLine(issue.package) + '\n'
+ issue.relatedPackages.map(p => indent + versionLine(p)).join('\n');
}
function issueMessage(issue: DependencyIssue): string {
if (issue.issueType === 'multiple-versions') {
return `Multiple versions of dependency ${chalk.bold(issue.package.name)} found.`;
} else if (issue.issueType === 'theia-version-mix') {
return `Mix of ${chalk.bold('@theia/*')} versions found.`;
} else {
return `Dependency ${chalk.bold(issue.package.name)} is not hoisted.`;
}
}
function severity(issue: DependencyIssue): string {
return issue.severity === 'error' ? chalk.red('error') : chalk.yellow('warning');
}
function versionLine(pckg: Package): string {
return chalk.bold(pckg.version) + ' in ' + pckg.path;
}
function printHints(issues: DependencyIssue[]): void {
console.log();
if (issues.find(i => i.issueType === 'theia-version-mix')) {
console.log('⛔ A mix of Theia versions is very likely leading to a broken application.');
}
console.log(` Use ${chalk.bold('npm ls <package-name>')} to find out why those multiple versions of a package are pulled.`);
console.log(' Try to resolve those issues by finding package versions along the dependency chain that depend on compatible versions.');
console.log(` Use ${chalk.bold('overrides')} in your root package.json to force specific versions as a last resort.`);
console.log();
}

View File

@@ -0,0 +1,421 @@
// *****************************************************************************
// Copyright (C) 2020 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/* eslint-disable @typescript-eslint/no-explicit-any */
import { OVSXApiFilterImpl, OVSXClient, VSXTargetPlatform } from '@theia/ovsx-client';
import * as chalk from 'chalk';
import * as decompress from 'decompress';
import { promises as fs } from 'fs';
import * as path from 'path';
import * as temp from 'temp';
import { DEFAULT_SUPPORTED_API_VERSION } from '@theia/application-package/lib/api';
import { RequestContext, RequestService } from '@theia/request';
import { RateLimiter } from 'limiter';
import escapeStringRegexp = require('escape-string-regexp');
temp.track();
/**
* Available options when downloading.
*/
export interface DownloadPluginsOptions {
/**
* Determines if a plugin should be unpacked.
* Defaults to `false`.
*/
packed?: boolean;
/**
* Determines if failures while downloading plugins should be ignored.
* Defaults to `false`.
*/
ignoreErrors?: boolean;
/**
* The supported vscode API version.
* Used to determine extension compatibility.
*/
apiVersion?: string;
/**
* Fetch plugins in parallel
*/
parallel?: boolean;
}
interface PluginDownload {
id: string,
downloadUrl: string,
version?: string | undefined
}
export default async function downloadPlugins(
ovsxClient: OVSXClient,
rateLimiter: RateLimiter,
requestService: RequestService,
options: DownloadPluginsOptions = {}
): Promise<void> {
const {
packed = false,
ignoreErrors = false,
apiVersion = DEFAULT_SUPPORTED_API_VERSION,
parallel = true
} = options;
const apiFilter = new OVSXApiFilterImpl(ovsxClient, apiVersion);
// Collect the list of failures to be appended at the end of the script.
const failures: string[] = [];
// Resolve the `package.json` at the current working directory.
const pck = JSON.parse(await fs.readFile(path.resolve('package.json'), 'utf8'));
// Resolve the directory for which to download the plugins.
const pluginsDir = pck.theiaPluginsDir || 'plugins';
// Excluded extension ids.
const excludedIds = new Set<string>(pck.theiaPluginsExcludeIds || []);
const parallelOrSequence = async (tasks: (() => unknown)[]) => {
if (parallel) {
await Promise.all(tasks.map(task => task()));
} else {
for (const task of tasks) {
await task();
}
}
};
// Downloader wrapper
const downloadPlugin = async (plugin: PluginDownload): Promise<void> => {
await downloadPluginAsync(requestService, rateLimiter, failures, plugin.id, plugin.downloadUrl, pluginsDir, packed, excludedIds, plugin.version);
};
const downloader = async (plugins: PluginDownload[]) => {
await parallelOrSequence(plugins.map(plugin => () => downloadPlugin(plugin)));
};
await fs.mkdir(pluginsDir, { recursive: true });
if (!pck.theiaPlugins) {
console.log(chalk.red('error: missing mandatory \'theiaPlugins\' property.'));
return;
}
try {
console.warn('--- downloading plugins ---');
// Download the raw plugins defined by the `theiaPlugins` property.
// This will include both "normal" plugins as well as "extension packs".
const pluginsToDownload = Object.entries(pck.theiaPlugins)
.filter((entry: [string, unknown]): entry is [string, string] => typeof entry[1] === 'string')
.map(([id, url]) => ({ id, downloadUrl: resolveDownloadUrlPlaceholders(url) }));
await downloader(pluginsToDownload);
const handleDependencyList = async (dependencies: (string | string[])[]) => {
// De-duplicate extension ids to only download each once:
const ids = new Set<string>(dependencies.flat());
await parallelOrSequence(Array.from(ids, id => async () => {
try {
await rateLimiter.removeTokens(1);
const extension = await apiFilter.findLatestCompatibleExtension({
extensionId: id,
includeAllVersions: true,
targetPlatform
});
const version = extension?.version;
const downloadUrl = extension?.files.download;
if (downloadUrl) {
await rateLimiter.removeTokens(1);
await downloadPlugin({ id, downloadUrl, version });
} else {
failures.push(`No download url for extension pack ${id} (${version})`);
}
} catch (err) {
console.error(err);
failures.push(err.message);
}
}));
};
console.warn('--- collecting extension-packs ---');
const extensionPacks = await collectExtensionPacks(pluginsDir, excludedIds);
if (extensionPacks.size > 0) {
console.warn(`--- resolving ${extensionPacks.size} extension-packs ---`);
await handleDependencyList(Array.from(extensionPacks.values()));
}
console.warn('--- collecting extension dependencies ---');
const pluginDependencies = await collectPluginDependencies(pluginsDir, excludedIds);
if (pluginDependencies.length > 0) {
console.warn(`--- resolving ${pluginDependencies.length} extension dependencies ---`);
await handleDependencyList(pluginDependencies);
}
} finally {
temp.cleanupSync();
}
for (const failure of failures) {
console.error(failure);
}
if (!ignoreErrors && failures.length > 0) {
throw new Error('Errors downloading some plugins. To make these errors non fatal, re-run with --ignore-errors');
}
}
const targetPlatform = `${process.platform}-${process.arch}` as VSXTargetPlatform;
const placeholders: Record<string, string> = {
targetPlatform
};
function resolveDownloadUrlPlaceholders(url: string): string {
for (const [name, value] of Object.entries(placeholders)) {
url = url.replace(new RegExp(escapeStringRegexp(`\${${name}}`), 'g'), value);
}
return url;
}
/**
* Downloads a plugin, will make multiple attempts before actually failing.
* @param requestService
* @param failures reference to an array storing all failures.
* @param plugin plugin short name.
* @param pluginUrl url to download the plugin at.
* @param target where to download the plugin in.
* @param packed whether to decompress or not.
*/
async function downloadPluginAsync(
requestService: RequestService,
rateLimiter: RateLimiter,
failures: string[],
plugin: string,
pluginUrl: string,
pluginsDir: string,
packed: boolean,
excludedIds: Set<string>,
version?: string
): Promise<void> {
if (!plugin) {
return;
}
let fileExt: string;
if (pluginUrl.endsWith('tar.gz')) {
fileExt = '.tar.gz';
} else if (pluginUrl.endsWith('vsix')) {
fileExt = '.vsix';
} else if (pluginUrl.endsWith('theia')) {
fileExt = '.theia'; // theia plugins.
} else {
failures.push(chalk.red(`error: '${plugin}' has an unsupported file type: '${pluginUrl}'`));
return;
}
const targetPath = path.resolve(pluginsDir, `${plugin}${packed === true ? fileExt : ''}`);
// Skip plugins which have previously been downloaded.
if (await isDownloaded(targetPath)) {
console.warn('- ' + plugin + ': already downloaded - skipping');
return;
}
const maxAttempts = 5;
const retryDelay = 2000;
let attempts: number;
let lastError: Error | undefined;
let response: RequestContext | undefined;
for (attempts = 0; attempts < maxAttempts; attempts++) {
if (attempts > 0) {
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
lastError = undefined;
try {
await rateLimiter.removeTokens(1);
response = await requestService.request({
url: pluginUrl
});
} catch (error) {
lastError = error;
continue;
}
const status = response.res.statusCode;
const retry = status && (status === 429 || status === 439 || status >= 500);
if (!retry) {
break;
}
}
if (lastError) {
failures.push(chalk.red(`x ${plugin}: failed to download, last error:\n ${lastError}`));
return;
}
if (typeof response === 'undefined') {
failures.push(chalk.red(`x ${plugin}: failed to download (unknown reason)`));
return;
}
if (response.res.statusCode !== 200) {
failures.push(chalk.red(`x ${plugin}: failed to download with: ${response.res.statusCode}`));
return;
}
if ((fileExt === '.vsix' || fileExt === '.theia')) {
if (packed) {
// Download .vsix without decompressing.
await fs.writeFile(targetPath, response.buffer);
} else {
await decompressVsix(targetPath, response.buffer);
}
console.warn(chalk.green(`+ ${plugin}${version ? `@${version}` : ''}: downloaded successfully ${attempts > 1 ? `(after ${attempts} attempts)` : ''}`));
} else if (fileExt === '.tar.gz') {
// Write the downloaded tar.gz to a temporary file for decompression.
const tempFile = temp.path('theia-plugin-download');
await fs.writeFile(tempFile, response.buffer);
// Decompress to inspect archive contents and determine handling strategy.
const files = await decompress(tempFile);
// Check if the archive is a bundle containing only .vsix files.
const allVsix = files.length > 0 && files.every(file => file.path.endsWith('.vsix'));
if (allVsix) {
// Handle pure vsix bundle: process each vsix individually.
for (const file of files) {
const vsixName = path.basename(file.path);
const pluginId = vsixName.replace(/\.vsix$/, '');
if (excludedIds.has(pluginId)) {
console.log(chalk.yellow(`'${pluginId}' referred to by '${plugin}' (tar.gz) is excluded because of 'theiaPluginsExcludeIds'`));
continue;
}
const vsixTargetPath = packed
? path.join(pluginsDir, vsixName)
: path.join(pluginsDir, pluginId);
if (await isDownloaded(vsixTargetPath)) {
console.warn('- ' + pluginId + ': already downloaded - skipping');
continue;
}
if (packed) {
// Download .vsix without decompressing.
await fs.writeFile(vsixTargetPath, file.data);
} else {
await decompressVsix(vsixTargetPath, file.data);
}
console.warn(chalk.green(`+ ${pluginId}: downloaded successfully ${attempts > 1 ? `(after ${attempts} attempts)` : ''}`));
}
} else {
// Handle regular tar.gz: decompress directly to target directory.
await fs.mkdir(targetPath, { recursive: true });
await decompress(tempFile, targetPath);
console.warn(chalk.green(`+ ${plugin}${version ? `@${version}` : ''}: downloaded successfully ${attempts > 1 ? `(after ${attempts} attempts)` : ''}`));
}
await fs.unlink(tempFile);
}
}
/**
* Decompresses a VSIX plugin archive to a target directory.
*
* Creates the target directory if it doesn't exist, writes the buffer content
* to a temporary file, and then extracts the archive contents to the target path.
*
* @param targetPath the directory path where the VSIX contents will be extracted.
* @param buffer the VSIX file content as a binary buffer or string.
*/
async function decompressVsix(targetPath: string, buffer: Uint8Array | string): Promise<void> {
await fs.mkdir(targetPath, { recursive: true });
const tempFile = temp.path('theia-plugin-download');
await fs.writeFile(tempFile, buffer);
await decompress(tempFile, targetPath);
await fs.unlink(tempFile);
}
/**
* Determine if the resource for the given path is already downloaded.
* @param filePath the resource path.
*
* @returns `true` if the resource is already downloaded, else `false`.
*/
async function isDownloaded(filePath: string): Promise<boolean> {
return fs.stat(filePath).then(() => true, () => false);
}
/**
* Walk the plugin directory and collect available extension paths.
* @param pluginDir the plugin directory.
* @returns the list of all available extension paths.
*/
async function collectPackageJsonPaths(pluginDir: string): Promise<string[]> {
const packageJsonPathList: string[] = [];
const files = await fs.readdir(pluginDir);
// Recursively fetch the list of extension `package.json` files.
for (const file of files) {
const filePath = path.join(pluginDir, file);
if ((await fs.stat(filePath)).isDirectory()) {
packageJsonPathList.push(...await collectPackageJsonPaths(filePath));
} else if (path.basename(filePath) === 'package.json' && !path.dirname(filePath).includes('node_modules')) {
packageJsonPathList.push(filePath);
}
}
return packageJsonPathList;
}
/**
* Get the mapping of extension-pack paths and their included plugin ids.
* - If an extension-pack references an explicitly excluded `id` the `id` will be omitted.
* @param pluginDir the plugin directory.
* @param excludedIds the list of plugin ids to exclude.
* @returns the mapping of extension-pack paths and their included plugin ids.
*/
async function collectExtensionPacks(pluginDir: string, excludedIds: Set<string>): Promise<Map<string, string[]>> {
const extensionPackPaths = new Map<string, string[]>();
const packageJsonPaths = await collectPackageJsonPaths(pluginDir);
await Promise.all(packageJsonPaths.map(async packageJsonPath => {
const json = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
const extensionPack: unknown = json.extensionPack;
if (Array.isArray(extensionPack)) {
extensionPackPaths.set(packageJsonPath, extensionPack.filter(id => {
if (excludedIds.has(id)) {
console.log(chalk.yellow(`'${id}' referred to by '${json.name}' (ext pack) is excluded because of 'theiaPluginsExcludeIds'`));
return false; // remove
}
return true; // keep
}));
}
}));
return extensionPackPaths;
}
/**
* Get the mapping of paths and their included plugin ids.
* - If an extension-pack references an explicitly excluded `id` the `id` will be omitted.
* @param pluginDir the plugin directory.
* @param excludedIds the list of plugin ids to exclude.
* @returns the mapping of extension-pack paths and their included plugin ids.
*/
async function collectPluginDependencies(pluginDir: string, excludedIds: Set<string>): Promise<string[]> {
const dependencyIds: string[] = [];
const packageJsonPaths = await collectPackageJsonPaths(pluginDir);
await Promise.all(packageJsonPaths.map(async packageJsonPath => {
const json = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
const extensionDependencies: unknown = json.extensionDependencies;
if (Array.isArray(extensionDependencies)) {
for (const dependency of extensionDependencies) {
if (excludedIds.has(dependency)) {
console.log(chalk.yellow(`'${dependency}' referred to by '${json.name}' is excluded because of 'theiaPluginsExcludeIds'`));
} else {
dependencyIds.push(dependency);
}
}
}
}));
return dependencyIds;
}

View File

@@ -0,0 +1,88 @@
// *****************************************************************************
// Copyright (C) 2020 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as net from 'net';
import * as puppeteer from 'puppeteer-core';
import newTestPage, { TestFileOptions } from './test-page';
export interface TestOptions {
start: () => Promise<net.AddressInfo>
launch?: puppeteer.PuppeteerLaunchOptions
files?: Partial<TestFileOptions>
coverage?: boolean
}
export default async function runTest(options: TestOptions): Promise<void> {
const { start, launch } = options;
const exit = !(launch && launch.devtools);
const testPage = await newTestPage({
files: options.files,
matchAppUrl: () => true, // all urls are application urls
newPage: async () => {
const browser = await puppeteer.launch(launch);
// re-use empty tab
const [tab] = await browser.pages();
return tab;
},
onWillRun: async () => {
const promises = [];
if (options.coverage) {
promises.push(testPage.coverage.startJSCoverage());
promises.push(testPage.coverage.startCSSCoverage());
}
// When launching in non-headless mode (with a UI and dev-tools open), make sure
// the app has focus, to avoid failures of tests that query the UI's state.
if (launch && launch.devtools) {
promises.push(testPage.waitForSelector('#theia-app-shell.lm-Widget.theia-ApplicationShell')
.then(e => {
// eslint-disable-next-line no-null/no-null
if (e !== null) {
e.click();
}
}));
}
// Clear application's local storage to avoid reusing previous state
promises.push(testPage.evaluate(() => localStorage.clear()));
await Promise.all(promises);
},
onDidRun: async failures => {
if (options.coverage) {
console.log('collecting test coverage...');
const [jsCoverage, cssCoverage] = await Promise.all([
testPage.coverage.stopJSCoverage(),
testPage.coverage.stopCSSCoverage(),
]);
require('puppeteer-to-istanbul').write([...jsCoverage, ...cssCoverage]);
}
if (exit) {
// allow a bit of time to finish printing-out test results
await new Promise(resolve => setTimeout(resolve, 1000));
await testPage.close();
process.exit(failures > 0 ? 1 : 0);
}
}
});
const { address, port } = await start();
const url = net.isIPv6(address)
? `http://[${address}]:${port}`
: `http://${address}:${port}`;
await testPage.goto(url);
await testPage.bringToFront();
}

View File

@@ -0,0 +1,138 @@
// *****************************************************************************
// Copyright (C) 2020 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as puppeteer from 'puppeteer-core';
const collectFiles: (options: TestFileOptions) => { files: string[] } = require('mocha/lib/cli/collect-files');
export interface TestFileOptions {
ignore: string[]
extension: string[]
file: string[]
recursive: boolean
sort: boolean
spec: string[]
}
export interface TestPageOptions {
files?: Partial<TestFileOptions>
newPage: () => Promise<puppeteer.Page>
matchAppUrl?: (url: string) => boolean
onWillRun?: () => Promise<void>
onDidRun?: (failures: number) => Promise<void>
}
export default async function newTestPage(options: TestPageOptions): Promise<puppeteer.Page> {
const { newPage, matchAppUrl, onWillRun, onDidRun } = options;
const fileOptions: TestFileOptions = {
ignore: options.files && options.files.ignore || [],
extension: options.files && options.files.extension || [],
file: options.files && options.files.file || [],
spec: options.files && options.files.spec || [],
recursive: options.files && options.files.recursive || false,
sort: options.files && options.files.sort || false
};
// quick check whether test files exist
const files = collectFiles(fileOptions);
const page = await newPage();
page.on('dialog', dialog => dialog.dismiss());
page.on('pageerror', console.error);
let theiaLoaded = false;
page.exposeFunction('fireDidUnloadTheia', () => theiaLoaded = false);
const preLoad = (frame: puppeteer.Frame) => {
const frameUrl = frame.url();
if (matchAppUrl && !matchAppUrl(frameUrl)) {
return;
}
if (theiaLoaded) {
return;
}
console.log('loading chai...');
theiaLoaded = true;
page.addScriptTag({ path: require.resolve('chai/chai.js') });
page.evaluate(() =>
window.addEventListener('beforeunload', () => (window as any)['fireDidUnloadTheia']())
);
};
page.on('frameattached', preLoad);
page.on('framenavigated', preLoad);
page.on('load', async () => {
if (matchAppUrl && !matchAppUrl(page.url())) {
return;
}
console.log('loading mocha...');
// replace console.log by theia logger for mocha
await page.waitForFunction(() => !!(window as any)['theia']?.['@theia/core/lib/common/logger']?.logger, {
timeout: 30 * 1000
});
await page.addScriptTag({ path: require.resolve('mocha/mocha.js') });
await page.waitForFunction(() => !!(window as any)['chai'] && !!(window as any)['mocha'] && !!(window as any)['theia'].container, { timeout: 30 * 1000 });
console.log('loading Theia...');
await page.evaluate(() => {
const { FrontendApplicationStateService } = (window as any)['theia']['@theia/core/lib/browser/frontend-application-state'];
const { PreferenceService } = (window as any)['theia']['@theia/core/lib/common/preferences/preference-service'];
const { WorkspaceService } = (window as any)['theia']['@theia/workspace/lib/browser/workspace-service'];
const container = (window as any)['theia'].container;
const frontendApplicationState = container.get(FrontendApplicationStateService);
const preferenceService = container.get(PreferenceService);
const workspaceService = container.get(WorkspaceService);
return Promise.all([
frontendApplicationState.reachedState('ready'),
preferenceService.ready,
workspaceService.roots
]);
});
console.log('loading test files...');
await page.evaluate(() => {
// replace require to load modules from theia namespace
(window as any)['require'] = (moduleName: string) => (window as any)['theia'][moduleName];
mocha.setup({
reporter: 'spec',
ui: 'bdd',
color: true,
retries: 0,
timeout: 10000
});
});
if (onWillRun) {
await onWillRun();
}
for (const file of files.files) {
await page.addScriptTag({ path: file });
}
console.log('running test files...');
const failures = await page.evaluate(() =>
new Promise<number>(resolve => mocha.run(resolve))
);
if (onDidRun) {
await onDidRun(failures);
}
});
return page;
}

View File

@@ -0,0 +1,704 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as fs from 'fs';
import * as path from 'path';
import * as temp from 'temp';
import * as yargs from 'yargs';
import yargsFactory = require('yargs/yargs');
import { ApplicationPackageManager, rebuild } from '@theia/application-manager';
import { ApplicationProps, DEFAULT_SUPPORTED_API_VERSION } from '@theia/application-package';
import checkDependencies from './check-dependencies';
import downloadPlugins from './download-plugins';
import runTest from './run-test';
import { RateLimiter } from 'limiter';
import { LocalizationManager, extract } from '@theia/localization-manager';
import { NodeRequestService } from '@theia/request/lib/node-request-service';
import { ExtensionIdMatchesFilterFactory, OVSX_RATE_LIMIT, OVSXClient, OVSXHttpClient, OVSXRouterClient, RequestContainsFilterFactory } from '@theia/ovsx-client';
const { executablePath } = require('puppeteer');
process.on('unhandledRejection', (reason, promise) => {
throw reason;
});
process.on('uncaughtException', error => {
if (error) {
console.error('Uncaught Exception: ', error.toString());
if (error.stack) {
console.error(error.stack);
}
}
process.exit(1);
});
theiaCli();
function toStringArray(argv: (string | number)[]): string[];
function toStringArray(argv?: (string | number)[]): string[] | undefined;
function toStringArray(argv?: (string | number)[]): string[] | undefined {
return argv?.map(arg => String(arg));
}
function rebuildCommand(command: string, target: ApplicationProps.Target): yargs.CommandModule<unknown, {
modules: string[]
cacheRoot?: string
forceAbi?: number,
}> {
return {
command,
describe: `Rebuild/revert native node modules for "${target}"`,
builder: {
'cacheRoot': {
type: 'string',
describe: 'Root folder where to store the .browser_modules cache'
},
'modules': {
alias: 'm',
type: 'array', // === `--modules/-m` can be specified multiple times
describe: 'List of modules to rebuild/revert'
},
'forceAbi': {
type: 'number',
describe: 'The Node ABI version to rebuild for'
}
},
handler: ({ cacheRoot, modules, forceAbi }) => {
// Note: `modules` is actually `string[] | undefined`.
if (modules) {
// It is ergonomic to pass arguments as --modules="a,b,c,..."
// but yargs doesn't parse it this way by default.
const flattened: string[] = [];
for (const value of modules) {
if (value.includes(',')) {
flattened.push(...value.split(',').map(mod => mod.trim()));
} else {
flattened.push(value);
}
}
modules = flattened;
}
rebuild(target, { cacheRoot, modules, forceAbi });
}
};
}
function defineCommonOptions<T>(cli: yargs.Argv<T>): yargs.Argv<T & {
appTarget?: 'browser' | 'electron' | 'browser-only'
}> {
return cli
.option('app-target', {
description: 'The target application type. Overrides `theia.target` in the application\'s package.json',
choices: ['browser', 'electron', 'browser-only'] as const,
});
}
async function theiaCli(): Promise<void> {
const { version } = await fs.promises.readFile(path.join(__dirname, '../package.json'), 'utf8').then(JSON.parse);
yargs.scriptName('theia').version(version);
const projectPath = process.cwd();
// Create a sub `yargs` parser to read `app-target` without
// affecting the global `yargs` instance used by the CLI.
const { appTarget } = defineCommonOptions(yargsFactory()).help(false).parse();
const manager = new ApplicationPackageManager({ projectPath, appTarget });
const localizationManager = new LocalizationManager();
const { target } = manager.pck;
defineCommonOptions(yargs)
.command<{
theiaArgs?: (string | number)[]
}>({
command: 'start [theia-args...]',
describe: `Start the ${target} backend`,
// Disable this command's `--help` option so that it is forwarded to Theia's CLI
builder: cli => cli.help(false) as yargs.Argv,
handler: async ({ theiaArgs }) => {
manager.start(toStringArray(theiaArgs));
}
})
.command({
command: 'clean',
describe: `Clean for the ${target} target`,
handler: async () => {
await manager.clean();
}
})
.command({
command: 'copy',
describe: 'Copy various files from `src-gen` to `lib`',
handler: async () => {
await manager.copy();
}
})
.command<{
mode: 'development' | 'production',
splitFrontend?: boolean
}>({
command: 'generate',
describe: `Generate various files for the ${target} target`,
builder: cli => ApplicationPackageManager.defineGeneratorOptions(cli),
handler: async ({ mode, splitFrontend }) => {
await manager.generate({ mode, splitFrontend });
}
})
.command<{
mode: 'development' | 'production',
webpackHelp: boolean
splitFrontend?: boolean
webpackArgs?: (string | number)[]
}>({
command: 'build [webpack-args...]',
describe: `Generate and bundle the ${target} frontend using webpack`,
builder: cli => ApplicationPackageManager.defineGeneratorOptions(cli)
.option('webpack-help' as 'webpackHelp', {
boolean: true,
description: 'Display Webpack\'s help',
default: false
}),
handler: async ({ mode, splitFrontend, webpackHelp, webpackArgs = [] }) => {
await manager.build(
webpackHelp
? ['--help']
: [
// Forward the `mode` argument to Webpack too:
'--mode', mode,
...toStringArray(webpackArgs)
],
{ mode, splitFrontend }
);
}
})
.command(rebuildCommand('rebuild', target))
.command(rebuildCommand('rebuild:browser', 'browser'))
.command(rebuildCommand('rebuild:electron', 'electron'))
.command<{
suppress: boolean
}>({
command: 'check:hoisted',
describe: 'Check that all dependencies are hoisted',
builder: {
'suppress': {
alias: 's',
describe: 'Suppress exiting with failure code',
boolean: true,
default: false
}
},
handler: ({ suppress }) => {
checkDependencies({
workspaces: ['packages/*'],
include: ['**'],
exclude: ['.bin/**', '.cache/**'],
skipHoisted: false,
skipUniqueness: true,
skipSingleTheiaVersion: true,
onlyTheiaExtensions: false,
suppress
});
}
})
.command<{
suppress: boolean
}>({
command: 'check:theia-version',
describe: 'Check that all dependencies have been resolved to the same Theia version',
builder: {
'suppress': {
alias: 's',
describe: 'Suppress exiting with failure code',
boolean: true,
default: false
}
},
handler: ({ suppress }) => {
checkDependencies({
workspaces: undefined,
include: ['@theia/**'],
exclude: [],
skipHoisted: true,
skipUniqueness: false,
skipSingleTheiaVersion: false,
onlyTheiaExtensions: false,
suppress
});
}
})
.command<{
suppress: boolean
}>({
command: 'check:theia-extensions',
describe: 'Check uniqueness of Theia extension versions or whether they are hoisted',
builder: {
'suppress': {
alias: 's',
describe: 'Suppress exiting with failure code',
boolean: true,
default: false
}
},
handler: ({ suppress }) => {
checkDependencies({
workspaces: undefined,
include: ['**'],
exclude: [],
skipHoisted: true,
skipUniqueness: false,
skipSingleTheiaVersion: true,
onlyTheiaExtensions: true,
suppress
});
}
})
.command<{
workspaces: string[] | undefined,
include: string[],
exclude: string[],
skipHoisted: boolean,
skipUniqueness: boolean,
skipSingleTheiaVersion: boolean,
onlyTheiaExtensions: boolean,
suppress: boolean
}>({
command: 'check:dependencies',
describe: 'Check uniqueness of dependency versions or whether they are hoisted',
builder: {
'workspaces': {
alias: 'w',
describe: 'Glob patterns of workspaces to analyze, relative to `cwd`',
array: true,
defaultDescription: 'All glob patterns listed in the package.json\'s workspaces',
demandOption: false
},
'include': {
alias: 'i',
describe: 'Glob pattern of dependencies\' package names to be included, e.g. -i "@theia/**"',
array: true,
default: ['**']
},
'exclude': {
alias: 'e',
describe: 'Glob pattern of dependencies\' package names to be excluded',
array: true,
defaultDescription: 'None',
default: []
},
'skip-hoisted': {
alias: 'h',
describe: 'Skip checking whether dependencies are hoisted',
boolean: true,
default: false
},
'skip-uniqueness': {
alias: 'u',
describe: 'Skip checking whether all dependencies are resolved to a unique version',
boolean: true,
default: false
},
'skip-single-theia-version': {
alias: 't',
describe: 'Skip checking whether all @theia/* dependencies are resolved to a single version',
boolean: true,
default: false
},
'only-theia-extensions': {
alias: 'o',
describe: 'Only check dependencies which are Theia extensions',
boolean: true,
default: false
},
'suppress': {
alias: 's',
describe: 'Suppress exiting with failure code',
boolean: true,
default: false
}
},
handler: ({
workspaces,
include,
exclude,
skipHoisted,
skipUniqueness,
skipSingleTheiaVersion,
onlyTheiaExtensions,
suppress
}) => {
checkDependencies({
workspaces,
include,
exclude,
skipHoisted,
skipUniqueness,
skipSingleTheiaVersion,
onlyTheiaExtensions,
suppress
});
}
})
.command<{
packed: boolean
ignoreErrors: boolean
apiVersion: string
apiUrl: string
parallel: boolean
proxyUrl?: string
proxyAuthorization?: string
strictSsl: boolean
rateLimit: number
ovsxRouterConfig?: string
}>({
command: 'download:plugins',
describe: 'Download defined external plugins',
builder: {
'packed': {
alias: 'p',
describe: 'Controls whether to pack or unpack plugins',
boolean: true,
default: false,
},
'ignore-errors': {
alias: 'i',
describe: 'Ignore errors while downloading plugins',
boolean: true,
default: false,
},
'api-version': {
alias: 'v',
describe: 'Supported API version for plugins',
default: DEFAULT_SUPPORTED_API_VERSION
},
'api-url': {
alias: 'u',
describe: 'Open-VSX Registry API URL',
default: 'https://open-vsx.org/api'
},
'parallel': {
describe: 'Download in parallel',
boolean: true,
default: true
},
'rate-limit': {
describe: 'Amount of maximum open-vsx requests per second',
number: true,
default: OVSX_RATE_LIMIT
},
'proxy-url': {
describe: 'Proxy URL'
},
'proxy-authorization': {
describe: 'Proxy authorization information'
},
'strict-ssl': {
describe: 'Whether to enable strict SSL mode',
boolean: true,
default: false
},
'ovsx-router-config': {
describe: 'JSON configuration file for the OVSX router client',
type: 'string'
}
},
handler: async ({ apiUrl, proxyUrl, proxyAuthorization, strictSsl, ovsxRouterConfig, ...options }) => {
const requestService = new NodeRequestService();
await requestService.configure({
proxyUrl,
proxyAuthorization,
strictSSL: strictSsl
});
let client: OVSXClient | undefined;
const rateLimiter = new RateLimiter({ tokensPerInterval: options.rateLimit, interval: 'second' });
if (ovsxRouterConfig) {
const routerConfig = await fs.promises.readFile(ovsxRouterConfig, 'utf8').then(JSON.parse, error => {
console.error(error);
});
if (routerConfig) {
client = await OVSXRouterClient.FromConfig(
routerConfig,
OVSXHttpClient.createClientFactory(requestService, rateLimiter),
[RequestContainsFilterFactory, ExtensionIdMatchesFilterFactory]
);
}
}
if (!client) {
client = new OVSXHttpClient(apiUrl, requestService, rateLimiter);
}
try {
await downloadPlugins(client, rateLimiter, requestService, options);
} catch (error) {
console.error(error);
process.exit(1);
}
process.exit(0);
},
})
.command<{
freeApi?: boolean,
deeplKey: string,
file: string,
languages: string[],
sourceLanguage?: string
}>({
command: 'nls-localize [languages...]',
describe: 'Localize json files using the DeepL API',
builder: {
'file': {
alias: 'f',
describe: 'The source file which should be translated',
demandOption: true
},
'deepl-key': {
alias: 'k',
describe: 'DeepL key used for API access. See https://www.deepl.com/docs-api for more information',
demandOption: true
},
'free-api': {
describe: 'Indicates whether the specified DeepL API key belongs to the free API',
boolean: true,
default: false,
},
'source-language': {
alias: 's',
describe: 'The source language of the translation file'
}
},
handler: async ({ freeApi, deeplKey, file, sourceLanguage, languages = [] }) => {
const success = await localizationManager.localize({
sourceFile: file,
freeApi: freeApi ?? true,
authKey: deeplKey,
targetLanguages: languages,
sourceLanguage
});
if (!success) {
process.exit(1);
}
}
})
.command<{
root: string,
output: string,
merge: boolean,
exclude?: string,
logs?: string,
files?: string[],
quiet: boolean
}>({
command: 'nls-extract',
describe: 'Extract translation key/value pairs from source code',
builder: {
'output': {
alias: 'o',
describe: 'Output file for the extracted translations',
demandOption: true
},
'root': {
alias: 'r',
describe: 'The directory which contains the source code',
default: '.'
},
'merge': {
alias: 'm',
describe: 'Whether to merge new with existing translation values',
boolean: true,
default: false
},
'exclude': {
alias: 'e',
describe: 'Allows to exclude translation keys starting with this value'
},
'files': {
alias: 'f',
describe: 'Glob pattern matching the files to extract from (starting from --root).',
array: true
},
'logs': {
alias: 'l',
describe: 'File path to a log file'
},
'quiet': {
alias: 'q',
describe: 'Prevents errors from being logged to console',
boolean: true,
default: false
}
},
handler: async options => {
await extract(options);
}
})
.command<{
testInspect: boolean,
testExtension: string[],
testFile: string[],
testIgnore: string[],
testRecursive: boolean,
testSort: boolean,
testSpec: string[],
testCoverage: boolean
theiaArgs?: (string | number)[]
}>({
command: 'test [theia-args...]',
builder: {
'test-inspect': {
describe: 'Whether to auto-open a DevTools panel for test page.',
boolean: true,
default: false
},
'test-extension': {
describe: 'Test file extension(s) to load',
array: true,
default: ['js']
},
'test-file': {
describe: 'Specify test file(s) to be loaded prior to root suite execution',
array: true,
default: []
},
'test-ignore': {
describe: 'Ignore test file(s) or glob pattern(s)',
array: true,
default: []
},
'test-recursive': {
describe: 'Look for tests in subdirectories',
boolean: true,
default: false
},
'test-sort': {
describe: 'Sort test files',
boolean: true,
default: false
},
'test-spec': {
describe: 'One or more test files, directories, or globs to test',
array: true,
default: ['test']
},
'test-coverage': {
describe: 'Report test coverage consumable by istanbul',
boolean: true,
default: false
}
},
handler: async ({ testInspect, testExtension, testFile, testIgnore, testRecursive, testSort, testSpec, testCoverage, theiaArgs }) => {
if (!process.env.THEIA_CONFIG_DIR) {
process.env.THEIA_CONFIG_DIR = temp.track().mkdirSync('theia-test-config-dir');
}
const args = ['--no-sandbox'];
if (!testInspect) {
args.push('--headless=old');
}
await runTest({
start: () => new Promise((resolve, reject) => {
const serverProcess = manager.start(toStringArray(theiaArgs));
serverProcess.on('message', resolve);
serverProcess.on('error', reject);
serverProcess.on('close', (code, signal) => reject(`Server process exited unexpectedly: ${code ?? signal}`));
}),
launch: {
args: args,
// eslint-disable-next-line no-null/no-null
defaultViewport: null, // view port can take available space instead of 800x600 default
devtools: testInspect,
headless: testInspect ? false : 'shell',
executablePath: executablePath(),
protocolTimeout: 600000,
timeout: 60000
},
files: {
extension: testExtension,
file: testFile,
ignore: testIgnore,
recursive: testRecursive,
sort: testSort,
spec: testSpec
},
coverage: testCoverage
});
}
})
.command<{
electronVersion?: string
electronDist?: string
ffmpegPath?: string
platform?: NodeJS.Platform
}>({
command: 'ffmpeg:replace [ffmpeg-path]',
describe: '',
builder: {
'electronDist': {
description: 'Electron distribution location.',
},
'electronVersion': {
description: 'Electron version for which to pull the "clean" ffmpeg library.',
},
'ffmpegPath': {
description: 'Absolute path to the ffmpeg shared library.',
},
'platform': {
description: 'Dictates where the library is located within the Electron distribution.',
choices: ['darwin', 'linux', 'win32'] as NodeJS.Platform[],
},
},
handler: async options => {
const ffmpeg = await import('@theia/ffmpeg');
await ffmpeg.replaceFfmpeg(options);
},
})
.command<{
electronDist?: string
ffmpegPath?: string
json?: boolean
platform?: NodeJS.Platform
}>({
command: 'ffmpeg:check [ffmpeg-path]',
describe: '(electron-only) Check that ffmpeg doesn\'t contain proprietary codecs',
builder: {
'electronDist': {
description: 'Electron distribution location',
},
'ffmpegPath': {
describe: 'Absolute path to the ffmpeg shared library',
},
'json': {
description: 'Output the found codecs as JSON on stdout',
boolean: true,
},
'platform': {
description: 'Dictates where the library is located within the Electron distribution',
choices: ['darwin', 'linux', 'win32'] as NodeJS.Platform[],
},
},
handler: async options => {
const ffmpeg = await import('@theia/ffmpeg');
await ffmpeg.checkFfmpeg(options);
},
})
.parserConfiguration({
'unknown-options-as-args': true,
})
.strictCommands()
.demandCommand(1, 'Please run a command')
.fail((msg, err, cli) => {
process.exitCode = 1;
if (err) {
// One of the handlers threw an error:
console.error(err);
} else {
// Yargs detected a problem with commands and/or arguments while parsing:
cli.showHelp();
console.error(msg);
}
})
.parse();
}

View File

@@ -0,0 +1,31 @@
{
"extends": "../../configs/base.tsconfig",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../application-manager"
},
{
"path": "../application-package"
},
{
"path": "../ffmpeg"
},
{
"path": "../localization-manager"
},
{
"path": "../ovsx-client"
},
{
"path": "../request"
}
]
}

View File

@@ -0,0 +1,10 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
}
};

View File

@@ -0,0 +1,3 @@
# `ffmeg.node`
This is a [Node Native Addon](https://nodejs.org/docs/latest-v14.x/api/n-api.html) to dynamically link to Electron's `ffmpeg.dll` and fetch a list of included codecs.

View File

@@ -0,0 +1,29 @@
{
'targets': [{
'defines': ['NAPI_VERSION=2'],
'target_name': 'ffmpeg',
'sources': [
'native/ffmpeg.c',
],
'conditions': [
['OS=="linux"', {
'sources': [
'native/linux-ffmpeg.c',
],
'libraries': [
'-ldl',
]
}],
['OS=="mac"', {
'sources': [
'native/mac-ffmpeg.c',
]
}],
['OS=="win"', {
'sources': [
'native/win-ffmpeg.c',
]
}],
],
}],
}

View File

@@ -0,0 +1,146 @@
// *****************************************************************************
// Copyright (C) 2019 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/**
* https://nodejs.org/docs/latest-v10.x/api/n-api.html#n_api_n_api
*/
#include <node_api.h>
#include <string.h>
#include "ffmpeg.h"
/**
* Return the list of codecs registered in the FFMPEG library.
*/
napi_value codecs(napi_env env, napi_callback_info info)
{
// We will reuse this `status` for all napi calls.
napi_status status;
char *error = NULL;
// Get arguments.
size_t argc = 1;
napi_value argv[1];
status = napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
if (status != napi_ok || argc < 1)
{
error = "invalid arguments";
goto error;
}
// Get first argument as string.
char path[2048];
status = napi_get_value_string_utf8(env, argv[0], path, 2048, NULL);
if (status != napi_ok)
{
error = "invalid string argument";
goto error;
}
// Load ffmpeg based on the provided path.
struct FFMPEG_Library ffmpeg = NULL_FFMPEG_LIBRARY;
char *load_error = load_ffmpeg_library(&ffmpeg, path);
if (load_error != NULL)
{
error = load_error;
goto error;
}
// Create the JavaScript list that will be returned.
napi_value codecs;
status = napi_create_array(env, &codecs);
if (status != napi_ok)
{
error = "napi_create_array fail";
goto error;
}
// Iterate over the codec descriptions.
// It includes descriptions for codecs that may not be present in the library.
struct AVCodecDescriptor *descriptor = ffmpeg.avcodec_descriptor_next(NULL);
while (descriptor != NULL)
{
// Try to fetch the codec being described, returns null on missing codecs.
struct AVCodec *decoder = ffmpeg.avcodec_find_decoder(descriptor->id);
if (decoder != NULL)
{
// Create the codec object and assign the properties.
napi_value object, value;
napi_create_object(env, &object);
// id: number
napi_create_int32(env, decoder->id, &value);
napi_set_named_property(env, object, "id", value);
// name: string
napi_create_string_utf8(env, decoder->name, strlen(decoder->name), &value);
napi_set_named_property(env, object, "name", value);
// longName: string
napi_create_string_utf8(env, decoder->long_name, strlen(decoder->long_name), &value);
napi_set_named_property(env, object, "longName", value);
// Pushing into a JS array requires calling the JS method for that.
napi_value push_fn;
napi_get_named_property(env, codecs, "push", &push_fn);
napi_call_function(env, codecs, push_fn, 1, (napi_value[]){object}, NULL);
}
descriptor = ffmpeg.avcodec_descriptor_next(descriptor);
}
// Free the ffmpeg library.
char *unload_error = unload_ffmpeg_library(&ffmpeg);
if (unload_error != NULL)
{
error = unload_error;
goto error;
}
return codecs;
error:
if (error != NULL)
{
napi_throw_error(env, NULL, error);
}
return NULL;
}
/**
* https://nodejs.org/docs/latest-v10.x/api/n-api.html#n_api_module_registration
*/
napi_value initialize(napi_env env, napi_value exports)
{
napi_status status;
napi_value function_codecs;
status = napi_create_function(env, NULL, 0, codecs, NULL, &function_codecs);
if (status != napi_ok)
{
return NULL;
}
status = napi_set_named_property(env, exports, "codecs", function_codecs);
if (status != napi_ok)
{
return NULL;
}
return exports;
}
NAPI_MODULE(NODE_GYP_MODULE_NAME, initialize);

View File

@@ -0,0 +1,80 @@
#ifndef FFMPEG_H
#define FFMPEG_H
/**
* THIS FILE REDEFINES DATA AS RETURNED BY THE FFMPEG LIBRARY.
* HEADER FILES ARE NOT DISTRIBUTED IN OUR SETUP, HENCE THIS.
*/
/**
* https://github.com/FFmpeg/FFmpeg/blob/release/3.2/libavutil/avutil.h#L193-L201
*/
enum AVMediaType
{
_UNKNOWN_DATA_AVMediaType = -1,
};
/**
* https://github.com/FFmpeg/FFmpeg/blob/release/3.2/libavcodec/avcodec.h#L191-L653
*/
enum AVCodecID
{
__UNKNOWN_DATA_AVCodecID = 0,
};
/**
* https://github.com/FFmpeg/FFmpeg/blob/release/3.2/libavcodec/avcodec.h#L3611-L3721
*/
struct AVCodec
{
const char *name, *long_name;
enum AVMediaType type;
enum AVCodecID id;
};
/**
* https://github.com/FFmpeg/FFmpeg/blob/release/3.2/libavcodec/avcodec.h#L660-L688
*/
struct AVCodecDescriptor
{
enum AVCodecID id;
enum AVMediaType type;
const char *name, *long_name;
};
/**
* Wrapper around the ffmpeg library that must be loaded at runtime.
*/
struct FFMPEG_Library
{
void *handle;
/**
* https://github.com/FFmpeg/FFmpeg/blob/release/3.2/libavcodec/avcodec.h#L6228
*
* We use AVCodecDescriptor because it is the only structure that we can
* query on all platforms. Windows' ffmpeg.dll does not export a
* `av_codec_next` function, only `avcodec_descriptor_next`.
* Also it seems that this "descriptor" concept is the recommended API.
*/
struct AVCodecDescriptor *(*avcodec_descriptor_next)(const struct AVCodecDescriptor *);
/**
* https://github.com/FFmpeg/FFmpeg/blob/release/3.2/libavcodec/avcodec.h#L4646
*/
struct AVCodec *(*avcodec_find_decoder)(enum AVCodecID);
};
#define NULL_FFMPEG_LIBRARY \
(struct FFMPEG_Library) { NULL, NULL, NULL }
/**
* Loader that will inject the loaded functions into a FFMPEG_Library structure.
*/
char *load_ffmpeg_library(struct FFMPEG_Library *library, char *library_path);
/**
* Free library.
*/
char *unload_ffmpeg_library(struct FFMPEG_Library *library);
#endif // FFMPEG_H guard

View File

@@ -0,0 +1,68 @@
// *****************************************************************************
// Copyright (C) 2019 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
#ifndef LINUX_FFMPEG
#define LINUX_FFMPEG
#include <stdlib.h>
#include <dlfcn.h>
#include "ffmpeg.h"
char *load_ffmpeg_library(struct FFMPEG_Library *library, char *library_path)
{
void *handle = dlopen(library_path, RTLD_NOW);
char *error = dlerror();
if (error != NULL)
{
goto error;
}
struct AVCodecDescriptor *(*avcodec_descriptor_next)(const struct AVCodecDescriptor *) = dlsym(handle, "avcodec_descriptor_next");
error = dlerror();
if (error != NULL)
{
goto error;
}
struct AVCodec *(*avcodec_find_decoder)(enum AVCodecID) = dlsym(handle, "avcodec_find_decoder");
error = dlerror();
if (error != NULL)
{
goto error;
}
library->handle = handle;
library->avcodec_descriptor_next = avcodec_descriptor_next;
library->avcodec_find_decoder = avcodec_find_decoder;
return NULL;
error:
if (handle != NULL)
{
dlclose(handle);
}
return error;
}
char *unload_ffmpeg_library(struct FFMPEG_Library *library)
{
dlclose(library->handle);
*library = NULL_FFMPEG_LIBRARY;
return dlerror();
}
#endif // LINUX_FFMPEG guard

View File

@@ -0,0 +1,26 @@
// *****************************************************************************
// Copyright (C) 2019 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
#ifndef MAC_FFMPEG
#define MAC_FFMPEG
/**
* Mac seems to use the same libraries as Linux.
* Difference is that the compiler doesn't need to be told to use `-ldl`.
*/
#include "./linux-ffmpeg.c"
#endif // MAC_FFMPEG guard

View File

@@ -0,0 +1,77 @@
// *****************************************************************************
// Copyright (C) 2019 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
#ifndef WIN_FFMPEG
#define WIN_FFMPEG
#include <windows.h>
#include "ffmpeg.h"
static char *error_library_not_found = "shared library not found";
static char *error_function_not_found = "function not found in shared library";
static char *error_cannot_free_library = "cannot free shared library";
char *load_ffmpeg_library(struct FFMPEG_Library *library, char *library_path)
{
char *error = NULL;
HMODULE handle = LoadLibrary(library_path);
if (!handle)
{
error = error_library_not_found;
goto error;
}
struct AVCodecDescriptor *(*av_codec_next)(const struct AVCodecDescriptor *) = (struct AVCodecDescriptor * (*)(const struct AVCodecDescriptor *))
GetProcAddress(handle, "avcodec_descriptor_next");
if (!av_codec_next)
{
error = error_function_not_found;
goto error;
}
struct AVCodec *(*avcodec_find_decoder)(enum AVCodecID) = (struct AVCodec * (*)(enum AVCodecID))
GetProcAddress(handle, "avcodec_find_decoder");
if (!avcodec_find_decoder)
{
error = error_function_not_found;
goto error;
}
library->handle = handle;
library->avcodec_descriptor_next = av_codec_next;
library->avcodec_find_decoder = avcodec_find_decoder;
return NULL;
error:
if (handle)
{
FreeLibrary(handle);
}
return error;
}
char *unload_ffmpeg_library(struct FFMPEG_Library *library)
{
if (library->handle && FreeLibrary(library->handle))
{
*library = NULL_FFMPEG_LIBRARY;
return NULL;
}
return error_cannot_free_library;
}
#endif // WIN_FFMPEG guard

View File

@@ -0,0 +1,40 @@
{
"name": "@theia/ffmpeg",
"version": "1.68.0",
"description": "Theia FFMPEG reader utility.",
"publishConfig": {
"access": "public"
},
"license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0",
"repository": {
"type": "git",
"url": "https://github.com/eclipse-theia/theia.git"
},
"bugs": {
"url": "https://github.com/eclipse-theia/theia/issues"
},
"homepage": "https://github.com/eclipse-theia/theia",
"main": "lib/index.js",
"files": [
"binding.gyp",
"lib",
"native",
"src"
],
"scripts": {
"compile": "theiaext compile",
"lint": "theiaext lint",
"build": "theiaext build",
"watch": "theiaext watch",
"clean": "theiaext clean"
},
"dependencies": {
"@electron/get": "^2.0.0",
"tslib": "^2.6.2",
"unzipper": "^0.9.11"
},
"devDependencies": {
"@types/unzipper": "^0.9.2"
},
"gitHead": "21358137e41342742707f660b8e222f940a27652"
}

View File

@@ -0,0 +1,56 @@
// *****************************************************************************
// Copyright (C) 2019 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as ffmpeg from './ffmpeg';
export interface CheckFfmpegOptions extends ffmpeg.FfmpegOptions {
json?: boolean
}
export interface CheckFfmpegResult {
free: ffmpeg.Codec[],
proprietary: ffmpeg.Codec[],
}
export const KNOWN_PROPRIETARY_CODECS = new Set(['h264', 'aac']);
export async function checkFfmpeg(options: CheckFfmpegOptions = {}): Promise<void> {
const {
ffmpegPath = ffmpeg.ffmpegAbsolutePath(options),
json = false,
} = options;
const codecs = ffmpeg.getFfmpegCodecs(ffmpegPath);
const free = [];
const proprietary = [];
for (const codec of codecs) {
if (KNOWN_PROPRIETARY_CODECS.has(codec.name.toLowerCase())) {
proprietary.push(codec);
} else {
free.push(codec);
}
}
if (json) {
// Pretty format JSON on stdout.
const result: CheckFfmpegResult = { free, proprietary };
console.log(JSON.stringify(result, undefined, 2));
}
if (proprietary.length > 0) {
// Should be displayed on stderr to not pollute the JSON on stdout.
throw new Error(`${proprietary.length} proprietary codecs found\n${proprietary.map(codec => `> ${codec.name} detected (${codec.longName})`).join('\n')}`);
}
// Print to stderr to not pollute the JSON on stdout.
console.warn(`"${ffmpegPath}" does not contain proprietary codecs (${codecs.length} found).`);
}

View File

@@ -0,0 +1,114 @@
// *****************************************************************************
// Copyright (C) 2019 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import path = require('path');
export interface Codec {
id: number
name: string
longName: string
}
export interface FfmpegNativeAddon {
codecs(ffmpegPath: string): Codec[]
}
export interface FfmpegNameAndLocation {
/**
* Name with extension of the shared library.
*/
name: string
/**
* Relative location of the file from Electron's dist root.
*/
location: string
}
export interface FfmpegOptions {
electronVersion?: string
electronDist?: string
ffmpegPath?: string
platform?: NodeJS.Platform
}
/**
* @internal
*/
export function _loadFfmpegNativeAddon(): FfmpegNativeAddon {
try {
return require('../build/Release/ffmpeg.node');
} catch (error) {
if (error.code === 'MODULE_NOT_FOUND') {
return require('../build/Debug/ffmpeg.node');
} else {
throw error;
}
}
}
/**
* @returns name and relative path from Electron's root where FFMPEG is located at.
*/
export function ffmpegNameAndLocation({
platform = process.platform
}: FfmpegOptions = {}): FfmpegNameAndLocation {
switch (platform) {
case 'darwin':
return {
name: 'libffmpeg.dylib',
location: 'Electron.app/Contents/Frameworks/Electron Framework.framework/Libraries/',
};
case 'win32':
return {
name: 'ffmpeg.dll',
location: '',
};
case 'linux':
return {
name: 'libffmpeg.so',
location: '',
};
default:
throw new Error(`${platform} is not supported`);
}
}
/**
* @returns relative ffmpeg shared library path from the Electron distribution root.
*/
export function ffmpegRelativePath(options: FfmpegOptions = {}): string {
const { location, name } = ffmpegNameAndLocation(options);
return path.join(location, name);
}
/**
* @returns absolute ffmpeg shared library path.
*/
export function ffmpegAbsolutePath(options: FfmpegOptions = {}): string {
const {
electronDist = path.resolve(require.resolve('electron/package.json'), '..', 'dist')
} = options;
return path.join(electronDist, ffmpegRelativePath(options));
}
/**
* Dynamically link to `ffmpegPath` and use FFMPEG APIs to list the included `Codec`s.
* @param ffmpegPath absolute path the the FFMPEG shared library.
* @returns list of codecs for the given ffmpeg shared library.
*/
export function getFfmpegCodecs(ffmpegPath: string): Codec[] {
return _loadFfmpegNativeAddon().codecs(ffmpegPath);
}

View File

@@ -0,0 +1,28 @@
// *****************************************************************************
// Copyright (C) 2021 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import crypto = require('crypto');
import fs = require('fs-extra');
export async function hashFile(filePath: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const sha256 = crypto.createHash('sha256');
fs.createReadStream(filePath)
.on('close', () => resolve(sha256.digest()))
.on('data', data => sha256.update(data))
.on('error', reject);
});
}

View File

@@ -0,0 +1,20 @@
// *****************************************************************************
// Copyright (C) 2021 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export * from './hash';
export * from './ffmpeg';
export * from './check-ffmpeg';
export * from './replace-ffmpeg';

View File

@@ -0,0 +1,80 @@
// *****************************************************************************
// Copyright (C) 2019 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import electronGet = require('@electron/get');
import fs = require('fs-extra');
import os = require('os');
import path = require('path');
import unzipper = require('unzipper');
import * as ffmpeg from './ffmpeg';
import { hashFile } from './hash';
export async function replaceFfmpeg(options: ffmpeg.FfmpegOptions = {}): Promise<void> {
let shouldDownload = true;
let shouldReplace = true;
const {
name: ffmpegName,
location: ffmpegLocation,
} = ffmpeg.ffmpegNameAndLocation(options);
const {
electronDist = path.resolve(require.resolve('electron/package.json'), '..', 'dist'),
electronVersion = await readElectronVersion(electronDist),
ffmpegPath = path.resolve(electronDist, ffmpegLocation, ffmpegName),
} = options;
const ffmpegCachedPath = path.join(os.tmpdir(), `theia-cli/cache/electron-v${electronVersion}`, ffmpegName);
if (await fs.pathExists(ffmpegCachedPath)) {
shouldDownload = false; // If the file is already cached, do not download.
console.warn('Found cached ffmpeg library.');
const [cacheHash, distHash] = await Promise.all([
hashFile(ffmpegCachedPath),
hashFile(ffmpegPath),
]);
if (cacheHash.equals(distHash)) {
shouldReplace = false; // If files are already the same, do not replace.
console.warn('Hashes are equal, not replacing the ffmpeg library.');
}
}
if (shouldDownload) {
const ffmpegZipPath = await electronGet.downloadArtifact({
version: electronVersion,
artifactName: 'ffmpeg'
});
const ffmpegZip = await unzipper.Open.file(ffmpegZipPath);
const file = ffmpegZip.files.find(f => f.path.endsWith(ffmpegName));
if (!file) {
throw new Error(`Archive did not contain "${ffmpegName}".`);
}
// Extract file to cache.
await fs.mkdirp(path.dirname(ffmpegCachedPath));
await new Promise<void>((resolve, reject) => {
file.stream()
.pipe(fs.createWriteStream(ffmpegCachedPath))
.on('finish', resolve)
.on('error', reject);
});
console.warn(`Downloaded ffmpeg shared library { version: "${electronVersion}", dist: "${electronDist}" }.`);
}
if (shouldReplace) {
await fs.copy(ffmpegCachedPath, ffmpegPath);
console.warn(`Successfully replaced "${ffmpegPath}".`);
}
}
export async function readElectronVersion(electronDist: string): Promise<string> {
const electronVersionFilePath = path.resolve(electronDist, 'version');
const version = await fs.readFile(electronVersionFilePath, 'utf8');
return version.trim();
}

View File

@@ -0,0 +1,12 @@
{
"extends": "../../configs/base.tsconfig",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": []
}

View File

@@ -0,0 +1,13 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
},
rules: {
'import/no-dynamic-require': 'off'
}
};

View File

@@ -0,0 +1,67 @@
<div align='center'>
<br />
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
<h2>ECLIPSE THEIA - LOCALIZATION-MANAGER</h2>
<hr />
</div>
## Description
The `@theia/localization-manager` package is used easily create localizations of Theia and Theia extensions for different languages. It has two main use cases.
First, it allows to extract localization keys and default values from `nls.localize` calls within the codebase using the `nls-extract` Theia-CLI command. Take this code for example:
```ts
const hi = nls.localize('greetings/hi', 'Hello');
const bye = nls.localize('greetings/bye', 'Bye');
```
It will be converted into this JSON file (`nls.json`):
```json
{
"greetings": {
"hi": "Hello",
"bye": "Bye"
}
}
```
Afterwards, any manual or automatic translation approach can be used to translate this file into other languages. These JSON files are supposed to be picked up by `LocalizationContribution`s.
Additionally, Theia provides a simple way to translate the generated JSON files out of the box using the [DeepL API](https://www.deepl.com/docs-api). For this, a [DeepL free or pro account](https://www.deepl.com/pro) is needed. Using the `nls-localize` command of the Theia-CLI, a target file can be translated into different languages. For example, when calling the command using the previous JSON file with the `fr` (french) language, the following `nls.fr.json` file will be created in the same directory as the translation source:
```json
{
"greetings": {
"hi": "Bonjour",
"bye": "Au revoir"
}
}
```
Only JSON entries without corresponding translations are translated using DeepL. This ensures that manual changes to the translated files aren't overwritten and only new translation entries are actually sent to DeepL.
Use `theia nls-localize --help` for more information on how to use the command and supply DeepL API keys.
For more information, see the [internationalization documentation](https://theia-ide.org/docs/i18n/).
## Additional Information
- [Theia - GitHub](https://github.com/eclipse-theia/theia)
- [Theia - Website](https://theia-ide.org/)
## License
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
## Trademark
"Theia" is a trademark of the Eclipse Foundation
<https://www.eclipse.org/theia>

View File

@@ -0,0 +1,50 @@
{
"name": "@theia/localization-manager",
"version": "1.68.0",
"description": "Theia localization manager API.",
"publishConfig": {
"access": "public"
},
"license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0",
"repository": {
"type": "git",
"url": "https://github.com/eclipse-theia/theia.git"
},
"bugs": {
"url": "https://github.com/eclipse-theia/theia/issues"
},
"homepage": "https://github.com/eclipse-theia/theia",
"files": [
"lib",
"src"
],
"main": "lib/index.js",
"typings": "lib/index.d.ts",
"scripts": {
"build": "theiaext build",
"clean": "theiaext clean",
"compile": "theiaext compile",
"lint": "theiaext lint",
"test": "theiaext test",
"watch": "theiaext watch"
},
"dependencies": {
"@types/bent": "^7.0.1",
"@types/fs-extra": "^4.0.2",
"bent": "^7.1.0",
"chalk": "4.0.0",
"deepmerge": "^4.2.2",
"fs-extra": "^4.0.2",
"glob": "^7.2.0",
"limiter": "^2.1.0",
"tslib": "^2.6.2",
"typescript": "~5.9.3"
},
"devDependencies": {
"@theia/ext-scripts": "1.68.0"
},
"nyc": {
"extends": "../../configs/nyc.json"
},
"gitHead": "21358137e41342742707f660b8e222f940a27652"
}

View File

@@ -0,0 +1,27 @@
// *****************************************************************************
// Copyright (C) 2021 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export interface Localization {
[key: string]: string | Localization
}
export function sortLocalization(localization: Localization): Localization {
return Object.keys(localization).sort().reduce((result: Localization, key: string) => {
const value = localization[key];
result[key] = typeof value === 'string' ? value : sortLocalization(value);
return result;
}, {});
}

View File

@@ -0,0 +1,190 @@
// *****************************************************************************
// Copyright (C) 2021 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as bent from 'bent';
import { RateLimiter } from 'limiter';
const post = bent('POST', 'json', 200);
// 50 is the maximum amount of translations per request
const deeplLimit = 50;
const rateLimiter = new RateLimiter({
tokensPerInterval: 10,
interval: 'second',
fireImmediately: true
});
export async function deepl(
parameters: DeeplParameters
): Promise<DeeplResponse> {
coerceLanguage(parameters);
const sub_domain = parameters.free_api ? 'api-free' : 'api';
const textChunks: string[][] = [];
const textArray = [...parameters.text];
while (textArray.length > 0) {
textChunks.push(textArray.splice(0, deeplLimit));
}
const responses: DeeplResponse[] = await Promise.all(textChunks.map(async chunk => {
const parameterCopy: DeeplParameters = { ...parameters, text: chunk };
const url = `https://${sub_domain}.deepl.com/v2/translate`;
const buffer = Buffer.from(toFormData(parameterCopy));
return postWithRetry(url, parameters.auth_key, buffer, 1);
}));
const mergedResponse: DeeplResponse = { translations: [] };
for (const response of responses) {
mergedResponse.translations.push(...response.translations);
}
for (const translation of mergedResponse.translations) {
translation.text = coerceTranslation(translation.text);
}
return mergedResponse;
}
async function postWithRetry(url: string, key: string, buffer: Buffer, attempt: number): Promise<DeeplResponse> {
try {
await rateLimiter.removeTokens(Math.min(attempt, 10));
const response = await post(url, buffer, {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Theia-Localization-Manager',
'Authorization': 'DeepL-Auth-Key ' + key
});
return response;
} catch (e) {
if ('message' in e && typeof e.message === 'string' && e.message.includes('Too Many Requests')) {
return postWithRetry(url, key, buffer, attempt + 1);
}
throw e;
}
}
/**
* Coerces the target language into a form expected by Deepl.
*
* Currently only replaces `ZH-CN` with `ZH`
*/
function coerceLanguage(parameters: DeeplParameters): void {
if (parameters.target_lang === 'ZH-CN') {
parameters.target_lang = 'ZH-HANS';
} else if (parameters.target_lang === 'ZH-TW') {
parameters.target_lang = 'ZH-HANT';
}
}
/**
* Coerces translated text into a form expected by VSCode/Theia.
*
* Replaces certain full-width characters with their ascii counter-part.
*/
function coerceTranslation(text: string): string {
return text
.replace(/\uff08/g, '(')
.replace(/\uff09/g, ')')
.replace(/\uff0c/g, ',')
.replace(/\uff1a/g, ':')
.replace(/\uff1b/g, ';')
.replace(/\uff1f/g, '?');
}
function toFormData(parameters: DeeplParameters): string {
const str: string[] = [];
for (const [key, value] of Object.entries(parameters)) {
if (typeof value === 'string') {
str.push(encodeURIComponent(key) + '=' + encodeURIComponent(value.toString()));
} else if (Array.isArray(value)) {
for (const item of value) {
str.push(encodeURIComponent(key) + '=' + encodeURIComponent(item.toString()));
}
}
}
return str.join('&');
}
export type DeeplLanguage =
| 'BG'
| 'CS'
| 'DA'
| 'DE'
| 'EL'
| 'EN-GB'
| 'EN-US'
| 'EN'
| 'ES'
| 'ET'
| 'FI'
| 'FR'
| 'HU'
| 'ID'
| 'IT'
| 'JA'
| 'KO'
| 'LT'
| 'LV'
| 'NB'
| 'NL'
| 'PL'
| 'PT-PT'
| 'PT-BR'
| 'PT'
| 'RO'
| 'RU'
| 'SK'
| 'SL'
| 'SV'
| 'TR'
| 'UK'
| 'ZH-CN'
| 'ZH-TW'
| 'ZH-HANS'
| 'ZH-HANT'
| 'ZH';
export const supportedLanguages = [
'BG', 'CS', 'DA', 'DE', 'EL', 'EN-GB', 'EN-US', 'EN', 'ES', 'ET', 'FI', 'FR', 'HU', 'ID', 'IT',
'JA', 'KO', 'LT', 'LV', 'NL', 'PL', 'PT-PT', 'PT-BR', 'PT', 'RO', 'RU', 'SK', 'SL', 'SV', 'TR', 'UK', 'ZH-CN', 'ZH-TW'
];
// From https://code.visualstudio.com/docs/getstarted/locales#_available-locales
export const defaultLanguages = [
'ZH-CN', 'ZH-TW', 'FR', 'DE', 'IT', 'ES', 'JA', 'KO', 'RU', 'PT-BR', 'TR', 'PL', 'CS', 'HU'
] as const;
export function isSupportedLanguage(language: string): language is DeeplLanguage {
return supportedLanguages.includes(language.toUpperCase());
}
export interface DeeplParameters {
free_api: Boolean
auth_key: string
text: string[]
source_lang?: DeeplLanguage
target_lang: DeeplLanguage
split_sentences?: '0' | '1' | 'nonewlines'
preserve_formatting?: '0' | '1'
formality?: 'default' | 'more' | 'less'
tag_handling?: string[]
non_splitting_tags?: string[]
outline_detection?: string
splitting_tags?: string[]
ignore_tags?: string[]
}
export interface DeeplResponse {
translations: DeeplTranslation[]
}
export interface DeeplTranslation {
detected_source_language: string
text: string
}

View File

@@ -0,0 +1,19 @@
// *****************************************************************************
// Copyright (C) 2021 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export * from './common';
export * from './localization-extractor';
export * from './localization-manager';

View File

@@ -0,0 +1,151 @@
// *****************************************************************************
// Copyright (C) 2021 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as assert from 'assert';
import { extractFromFile, ExtractionOptions } from './localization-extractor';
const TEST_FILE = 'test.ts';
const quiet: ExtractionOptions = { quiet: true };
describe('correctly extracts from file content', () => {
it('should extract from simple nls.localize() call', async () => {
const content = 'nls.localize("key", "value")';
assert.deepStrictEqual(await extractFromFile(TEST_FILE, content), {
'key': 'value'
});
});
it('should extract from nested nls.localize() call', async () => {
const content = 'nls.localize("nested/key", "value")';
assert.deepStrictEqual(await extractFromFile(TEST_FILE, content), {
'nested': {
'key': 'value'
}
});
});
it('should extract IDs from Command.toLocalizedCommand() call', async () => {
const content = `
Command.toLocalizedCommand({
id: 'command-id1',
label: 'command-label1'
});
Command.toLocalizedCommand({
id: 'command-id2',
label: 'command-label2'
}, 'command-key');
`;
assert.deepStrictEqual(await extractFromFile(TEST_FILE, content), {
'command-id1': 'command-label1',
'command-key': 'command-label2'
});
});
it('should extract category from Command.toLocalizedCommand() call', async () => {
const content = `
Command.toLocalizedCommand({
id: 'id',
label: 'label',
category: 'category'
}, undefined, 'category-key');`;
assert.deepStrictEqual(await extractFromFile(TEST_FILE, content), {
'id': 'label',
'category-key': 'category'
});
});
it('should merge different nls.localize() calls', async () => {
const content = `
nls.localize('nested/key1', 'value1');
nls.localize('nested/key2', 'value2');
`;
assert.deepStrictEqual(await extractFromFile(TEST_FILE, content), {
'nested': {
'key1': 'value1',
'key2': 'value2'
}
});
});
it('should be able to resolve local references', async () => {
const content = `
const a = 'key';
nls.localize(a, 'value');
`;
assert.deepStrictEqual(await extractFromFile(TEST_FILE, content), {
'key': 'value'
});
});
it('should return an error when resolving is not successful', async () => {
const content = "nls.localize(a, 'value')";
const errors: string[] = [];
assert.deepStrictEqual(await extractFromFile(TEST_FILE, content, errors, quiet), {});
assert.deepStrictEqual(errors, [
"test.ts(1,14): Could not resolve reference to 'a'"
]);
});
it('should return an error when resolving from an expression', async () => {
const content = "nls.localize(test.value, 'value');";
const errors: string[] = [];
assert.deepStrictEqual(await extractFromFile(TEST_FILE, content, errors, quiet), {});
assert.deepStrictEqual(errors, [
"test.ts(1,14): 'test.value' is not a string constant"
]);
});
it('should show error when trying to merge an object and a string', async () => {
const content = `
nls.localize('key', 'value');
nls.localize('key/nested', 'value');
`.trim();
const errors: string[] = [];
assert.deepStrictEqual(await extractFromFile(TEST_FILE, content, errors, quiet), {
'key': 'value'
});
assert.deepStrictEqual(errors, [
"test.ts(2,35): String entry already exists at 'key'"
]);
});
it('should show error when trying to merge a string into an object', async () => {
const content = `
nls.localize('key/nested', 'value');
nls.localize('key', 'value');
`.trim();
const errors: string[] = [];
assert.deepStrictEqual(await extractFromFile(TEST_FILE, content, errors, quiet), {
'key': {
'nested': 'value'
}
});
assert.deepStrictEqual(errors, [
"test.ts(2,28): Multiple translation keys already exist at 'key'"
]);
});
it('should show error for template literals', async () => {
const content = 'nls.localize("key", `template literal value`)';
const errors: string[] = [];
assert.deepStrictEqual(await extractFromFile(TEST_FILE, content, errors, quiet), {});
assert.deepStrictEqual(errors, [
"test.ts(1,20): Template literals are not supported for localization. Please use the additional arguments of the 'nls.localize' function to format strings"
]);
});
});

View File

@@ -0,0 +1,431 @@
// *****************************************************************************
// Copyright (C) 2021 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as fs from 'fs-extra';
import * as ts from 'typescript';
import * as os from 'os';
import * as path from 'path';
import { glob } from 'glob';
import { promisify } from 'util';
import deepmerge = require('deepmerge');
import { Localization, sortLocalization } from './common';
const globPromise = promisify(glob);
export interface ExtractionOptions {
root?: string
output?: string
exclude?: string
logs?: string
/** List of globs matching the files to extract from. */
files?: string[]
merge?: boolean
quiet?: boolean
}
class SingleFileServiceHost implements ts.LanguageServiceHost {
private file: ts.IScriptSnapshot;
private lib: ts.IScriptSnapshot;
constructor(private options: ts.CompilerOptions, private filename: string, contents: string) {
this.file = ts.ScriptSnapshot.fromString(contents);
this.lib = ts.ScriptSnapshot.fromString('');
}
getCompilationSettings = () => this.options;
getScriptFileNames = () => [this.filename];
getScriptVersion = () => '1';
getScriptSnapshot = (name: string) => name === this.filename ? this.file : this.lib;
getCurrentDirectory = () => '';
getDefaultLibFileName = () => 'lib.d.ts';
readFile(file: string, encoding?: string | undefined): string | undefined {
if (file === this.filename) {
return this.file.getText(0, this.file.getLength());
}
}
fileExists(file: string): boolean {
return this.filename === file;
}
}
class TypeScriptError extends Error {
constructor(message: string, node: ts.Node) {
super(buildErrorMessage(message, node));
}
}
function buildErrorMessage(message: string, node: ts.Node): string {
const source = node.getSourceFile();
const sourcePath = source.fileName;
const pos = source.getLineAndCharacterOfPosition(node.pos);
return `${sourcePath}(${pos.line + 1},${pos.character + 1}): ${message}`;
}
const tsOptions: ts.CompilerOptions = {
allowJs: true
};
export async function extract(options: ExtractionOptions): Promise<void> {
const cwd = path.resolve(process.env.INIT_CWD || process.cwd(), options.root ?? '');
const files: string[] = [];
await Promise.all((options.files ?? ['**/src/**/*.{ts,tsx}']).map(
async pattern => files.push(...await globPromise(pattern, { cwd }))
));
let localization: Localization = {};
const errors: string[] = [];
for (const file of files) {
const filePath = path.resolve(cwd, file);
const fileName = path.relative(cwd, file).split(path.sep).join('/');
const content = await fs.readFile(filePath, 'utf8');
const fileLocalization = await extractFromFile(fileName, content, errors, options);
localization = deepmerge(localization, fileLocalization);
}
if (errors.length > 0 && options.logs) {
await fs.writeFile(options.logs, errors.join(os.EOL));
}
const out = path.resolve(process.env.INIT_CWD || process.cwd(), options.output ?? '');
if (options.merge && await fs.pathExists(out)) {
const existing = await fs.readJson(out);
localization = deepmerge(existing, localization);
}
localization = sortLocalization(localization);
await fs.mkdirs(path.dirname(out));
await fs.writeJson(out, localization, {
spaces: 2
});
}
export async function extractFromFile(file: string, content: string, errors?: string[], options?: ExtractionOptions): Promise<Localization> {
const serviceHost = new SingleFileServiceHost(tsOptions, file, content);
const service = ts.createLanguageService(serviceHost);
const sourceFile = service.getProgram()!.getSourceFile(file)!;
const localization: Localization = {};
const localizationCalls = collect(sourceFile, node => isLocalizeCall(node));
for (const call of localizationCalls) {
try {
const extracted = extractFromLocalizeCall(call, options);
if (extracted) {
insert(localization, extracted);
}
} catch (err) {
const tsError = err as Error;
errors?.push(tsError.message);
if (!options?.quiet) {
console.log(tsError.message);
}
}
}
const localizedCommands = collect(sourceFile, node => isCommandLocalizeUtility(node));
for (const command of localizedCommands) {
try {
const extracted = extractFromLocalizedCommandCall(command, errors, options);
const label = extracted.label;
const category = extracted.category;
if (!isExcluded(options, label[0])) {
insert(localization, label);
}
if (category && !isExcluded(options, category[0])) {
insert(localization, category);
}
} catch (err) {
const tsError = err as Error;
errors?.push(tsError.message);
if (!options?.quiet) {
console.log(tsError.message);
}
}
}
return localization;
}
function isExcluded(options: ExtractionOptions | undefined, key: string): boolean {
return !!options?.exclude && key.startsWith(options.exclude);
}
function insert(localization: Localization, values: [string, string, ts.Node]): void {
const key = values[0];
const value = values[1];
const node = values[2];
const parts = key.split('/');
parts.forEach((part, i) => {
let entry = localization[part];
if (i === parts.length - 1) {
if (typeof entry === 'object') {
throw new TypeScriptError(`Multiple translation keys already exist at '${key}'`, node);
}
localization[part] = value;
} else {
if (typeof entry === 'string') {
throw new TypeScriptError(`String entry already exists at '${parts.splice(0, i + 1).join('/')}'`, node);
}
if (!entry) {
entry = {};
}
localization[part] = entry;
localization = entry;
}
});
}
function collect(n: ts.Node, fn: (node: ts.Node) => boolean): ts.Node[] {
const result: ts.Node[] = [];
function loop(node: ts.Node): void {
const stepResult = fn(node);
if (stepResult) {
result.push(node);
} else {
ts.forEachChild(node, loop);
}
}
loop(n);
return result;
}
function isLocalizeCall(node: ts.Node): boolean {
if (!ts.isCallExpression(node)) {
return false;
}
return node.expression.getText() === 'nls.localize';
}
function extractFromLocalizeCall(node: ts.Node, options?: ExtractionOptions): [string, string, ts.Node] | undefined {
if (!ts.isCallExpression(node)) {
throw new TypeScriptError('Invalid node type', node);
}
const args = node.arguments;
if (args.length < 2) {
throw new TypeScriptError('Localize call needs at least 2 arguments', node);
}
const key = extractString(args[0]);
const value = extractString(args[1]);
if (isExcluded(options, key)) {
return undefined;
}
return [key, value, args[1]];
}
function extractFromLocalizedCommandCall(node: ts.Node, errors?: string[], options?: ExtractionOptions): {
label: [string, string, ts.Node],
category?: [string, string, ts.Node]
} {
if (!ts.isCallExpression(node)) {
throw new TypeScriptError('Invalid node type', node);
}
const args = node.arguments;
if (args.length < 1) {
throw new TypeScriptError('Command localization call needs at least one argument', node);
}
const commandObj = args[0];
if (!ts.isObjectLiteralExpression(commandObj)) {
throw new TypeScriptError('First argument of "toLocalizedCommand" needs to be an object literal', node);
}
const properties = commandObj.properties;
const propertyMap = new Map<string, string>();
const relevantProps = ['id', 'label', 'category'];
let labelNode: ts.Node = node;
for (const property of properties) {
if (!property.name) {
continue;
}
if (!ts.isPropertyAssignment(property)) {
throw new TypeScriptError('Only property assignments in "toLocalizedCommand" are allowed', property);
}
if (!ts.isIdentifier(property.name)) {
throw new TypeScriptError('Only identifiers are allowed as property names in "toLocalizedCommand"', property);
}
const name = property.name.text;
if (!relevantProps.includes(property.name.text)) {
continue;
}
if (property.name.text === 'label') {
labelNode = property.initializer;
}
try {
const value = extractString(property.initializer);
propertyMap.set(name, value);
} catch (err) {
const tsError = err as Error;
errors?.push(tsError.message);
if (!options?.quiet) {
console.log(tsError.message);
}
}
}
let labelKey = propertyMap.get('id');
let categoryKey: string | undefined = undefined;
let categoryNode: ts.Node | undefined;
// We have an explicit label translation key
if (args.length > 1) {
try {
const labelOverrideKey = extractStringOrUndefined(args[1]);
if (labelOverrideKey) {
labelKey = labelOverrideKey;
labelNode = args[1];
}
} catch (err) {
const tsError = err as Error;
errors?.push(tsError.message);
if (!options?.quiet) {
console.log(tsError.message);
}
}
}
// We have an explicit category translation key
if (args.length > 2) {
try {
categoryKey = extractStringOrUndefined(args[2]);
categoryNode = args[2];
} catch (err) {
const tsError = err as Error;
errors?.push(tsError.message);
if (!options?.quiet) {
console.log(tsError.message);
}
}
}
if (!labelKey) {
throw new TypeScriptError('No label key found', node);
}
if (!propertyMap.get('label')) {
throw new TypeScriptError('No default label found', node);
}
let categoryLocalization: [string, string, ts.Node] | undefined = undefined;
const categoryLabel = propertyMap.get('category');
if (categoryKey && categoryLabel && categoryNode) {
categoryLocalization = [categoryKey, categoryLabel, categoryNode];
}
return {
label: [labelKey, propertyMap.get('label')!, labelNode],
category: categoryLocalization
};
}
function extractStringOrUndefined(node: ts.Expression): string | undefined {
if (node.getText() === 'undefined') {
return undefined;
}
return extractString(node);
}
function extractString(node: ts.Expression): string {
if (ts.isIdentifier(node)) {
const reference = followReference(node);
if (!reference) {
throw new TypeScriptError(`Could not resolve reference to '${node.text}'`, node);
}
node = reference;
}
if (ts.isTemplateLiteral(node)) {
throw new TypeScriptError(
"Template literals are not supported for localization. Please use the additional arguments of the 'nls.localize' function to format strings",
node
);
}
if (!ts.isStringLiteralLike(node)) {
throw new TypeScriptError(`'${node.getText()}' is not a string constant`, node);
}
return unescapeString(node.text);
}
function followReference(node: ts.Identifier): ts.Expression | undefined {
const scope = collectScope(node);
const next = scope.get(node.text);
if (next && ts.isIdentifier(next)) {
return followReference(next);
}
return next;
}
function collectScope(node: ts.Node, map: Map<string, ts.Expression> = new Map()): Map<string, ts.Expression> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const locals = (node as any)['locals'] as Map<string, ts.Symbol>;
if (locals) {
for (const [key, value] of locals.entries()) {
if (!map.has(key)) {
const declaration = value.valueDeclaration;
if (declaration && ts.isVariableDeclaration(declaration) && declaration.initializer) {
map.set(key, declaration.initializer);
}
}
}
}
if (node.parent) {
collectScope(node.parent, map);
}
return map;
}
function isCommandLocalizeUtility(node: ts.Node): boolean {
if (!ts.isCallExpression(node)) {
return false;
}
return node.expression.getText() === 'Command.toLocalizedCommand';
}
const unescapeMap: Record<string, string> = {
'\'': '\'',
'"': '"',
'\\': '\\',
'n': '\n',
'r': '\r',
't': '\t',
'b': '\b',
'f': '\f'
};
function unescapeString(str: string): string {
const result: string[] = [];
for (let i = 0; i < str.length; i++) {
const ch = str.charAt(i);
if (ch === '\\') {
if (i + 1 < str.length) {
const replace = unescapeMap[str.charAt(i + 1)];
if (replace !== undefined) {
result.push(replace);
i++;
continue;
}
}
}
result.push(ch);
}
return result.join('');
}

View File

@@ -0,0 +1,91 @@
// *****************************************************************************
// Copyright (C) 2021 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as assert from 'assert';
import { DeeplParameters, DeeplResponse } from './deepl-api';
import { LocalizationManager, LocalizationOptions } from './localization-manager';
describe('localization-manager#translateLanguage', () => {
async function mockLocalization(parameters: DeeplParameters): Promise<DeeplResponse> {
return {
translations: parameters.text.map(value => ({
detected_source_language: '',
text: `[${value}]`
}))
};
}
const manager = new LocalizationManager(mockLocalization);
const defaultOptions: LocalizationOptions = {
authKey: '',
freeApi: false,
sourceFile: '',
targetLanguages: ['EN']
};
it('should translate a single value', async () => {
const input = {
key: 'value'
};
const target = {};
await manager.translateLanguage(input, target, 'EN', defaultOptions);
assert.deepStrictEqual(target, {
key: '[value]'
});
});
it('should translate nested values', async () => {
const input = {
a: {
b: 'b'
},
c: 'c'
};
const target = {};
await manager.translateLanguage(input, target, 'EN', defaultOptions);
assert.deepStrictEqual(target, {
a: {
b: '[b]'
},
c: '[c]'
});
});
it('should not override existing targets', async () => {
const input = {
a: 'a'
};
const target = {
a: 'b'
};
await manager.translateLanguage(input, target, 'EN', defaultOptions);
assert.deepStrictEqual(target, {
a: 'b'
});
});
it('should keep placeholders intact', async () => {
const input = {
key: '{1} {0}'
};
const target = {};
await manager.translateLanguage(input, target, 'EN', defaultOptions);
assert.deepStrictEqual(target, {
key: '[{1} {0}]'
});
});
});

View File

@@ -0,0 +1,168 @@
// *****************************************************************************
// Copyright (C) 2021 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as chalk from 'chalk';
import * as fs from 'fs-extra';
import * as path from 'path';
import { Localization, sortLocalization } from './common';
import { deepl, DeeplLanguage, DeeplParameters, defaultLanguages, isSupportedLanguage } from './deepl-api';
export interface LocalizationOptions {
freeApi: Boolean
authKey: string
sourceFile: string
sourceLanguage?: string
targetLanguages: string[]
}
export type LocalizationFunction = (parameters: DeeplParameters) => Promise<string[]>;
export class LocalizationManager {
constructor(private localizationFn = deepl) { }
async localize(options: LocalizationOptions): Promise<boolean> {
let source: Localization = {};
const cwd = process.env.INIT_CWD || process.cwd();
const sourceFile = path.resolve(cwd, options.sourceFile);
try {
source = await fs.readJson(sourceFile);
} catch {
console.log(chalk.red(`Could not read file "${options.sourceFile}"`));
process.exit(1);
}
const languages: string[] = [];
for (const targetLanguage of options.targetLanguages) {
if (!isSupportedLanguage(targetLanguage)) {
console.log(chalk.yellow(`Language "${targetLanguage}" is not supported for automatic localization`));
} else {
languages.push(targetLanguage);
}
}
if (languages.length === 0) {
// No supported languages were found, default to all supported languages
console.log('No languages were specified, defaulting to all supported languages for VS Code');
languages.push(...defaultLanguages);
}
const existingTranslations: Map<string, Localization> = new Map();
for (const targetLanguage of languages) {
try {
const targetPath = this.translationFileName(sourceFile, targetLanguage);
existingTranslations.set(targetLanguage, await fs.readJson(targetPath));
} catch {
existingTranslations.set(targetLanguage, {});
}
}
const results = await Promise.all(languages.map(language => this.translateLanguage(source, existingTranslations.get(language)!, language, options)));
let result = results.reduce((acc, val) => acc && val, true);
for (const targetLanguage of languages) {
const targetPath = this.translationFileName(sourceFile, targetLanguage);
try {
const translation = existingTranslations.get(targetLanguage)!;
await fs.writeJson(targetPath, sortLocalization(translation), { spaces: 2 });
} catch {
console.error(chalk.red(`Error writing translated file to '${targetPath}'`));
result = false;
}
}
return result;
}
protected translationFileName(original: string, language: string): string {
const directory = path.dirname(original);
const fileName = path.basename(original, '.json');
return path.join(directory, `${fileName}.${language.toLowerCase()}.json`);
}
async translateLanguage(source: Localization, target: Localization, targetLanguage: string, options: LocalizationOptions): Promise<boolean> {
const map = this.buildLocalizationMap(source, target);
if (map.text.length > 0) {
try {
const translationResponse = await this.localizationFn({
auth_key: options.authKey,
free_api: options.freeApi,
target_lang: targetLanguage.toUpperCase() as DeeplLanguage,
source_lang: options.sourceLanguage?.toUpperCase() as DeeplLanguage,
text: map.text.map(e => this.addIgnoreTags(e)),
tag_handling: ['xml'],
ignore_tags: ['x']
});
translationResponse.translations.forEach(({ text }, i) => {
map.localize(i, this.removeIgnoreTags(text));
});
console.log(chalk.green(`Successfully translated ${map.text.length} value${map.text.length > 1 ? 's' : ''} for language "${targetLanguage}"`));
return true;
} catch (e) {
console.log(chalk.red(`Could not translate into language "${targetLanguage}"`), e);
return false;
}
} else {
console.log(`No translation necessary for language "${targetLanguage}"`);
return true;
}
}
protected addIgnoreTags(text: string): string {
return text.replace(/(\{\d*\})/g, '<x>$1</x>');
}
protected removeIgnoreTags(text: string): string {
return text.replace(/<x>(\{\d+\})<\/x>/g, '$1');
}
protected buildLocalizationMap(source: Localization, target: Localization): LocalizationMap {
const functionMap = new Map<number, (value: string) => void>();
const text: string[] = [];
const process = (s: Localization, t: Localization) => {
// Delete all extra keys in the target translation first
for (const key of Object.keys(t)) {
if (!(key in s)) {
delete t[key];
}
}
for (const [key, value] of Object.entries(s)) {
if (!(key in t)) {
if (typeof value === 'string') {
functionMap.set(text.length, translation => t[key] = translation);
text.push(value);
} else {
const newLocalization: Localization = {};
t[key] = newLocalization;
process(value, newLocalization);
}
} else if (typeof value === 'object') {
if (typeof t[key] === 'string') {
t[key] = {};
}
process(value, t[key] as Localization);
}
}
};
process(source, target);
return {
text,
localize: (index, value) => functionMap.get(index)!(value)
};
}
}
export interface LocalizationMap {
text: string[]
localize: (index: number, value: string) => void
}

View File

@@ -0,0 +1,12 @@
{
"extends": "../../configs/base.tsconfig",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": []
}

View File

@@ -0,0 +1,10 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
}
};

View File

@@ -0,0 +1,30 @@
<div align='center'>
<br />
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
<h2>ECLIPSE THEIA - NATIVE-WEBPACK-PLUGIN</h2>
<hr />
</div>
## Description
The `@theia/native-webpack-plugin` package contains a webpack plugin that is used to handle native dependencies for bundling Theia based application backends.
## Additional Information
- [Theia - GitHub](https://github.com/eclipse-theia/theia)
- [Theia - Website](https://theia-ide.org/)
## License
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
## Trademark
"Theia" is a trademark of the Eclipse Foundation
<https://www.eclipse.org/theia>

View File

@@ -0,0 +1,37 @@
{
"name": "@theia/native-webpack-plugin",
"version": "1.68.0",
"description": "Webpack Plugin for native dependencies of Theia.",
"publishConfig": {
"access": "public"
},
"license": "EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0",
"repository": {
"type": "git",
"url": "https://github.com/eclipse-theia/theia.git"
},
"bugs": {
"url": "https://github.com/eclipse-theia/theia/issues"
},
"homepage": "https://github.com/eclipse-theia/theia",
"files": [
"lib",
"src"
],
"main": "lib/index.js",
"typings": "lib/index.d.ts",
"scripts": {
"build": "theiaext build",
"clean": "theiaext clean",
"compile": "theiaext compile",
"lint": "theiaext lint",
"test": "theiaext test",
"watch": "theiaext watch"
},
"dependencies": {
"detect-libc": "^2.0.2",
"tslib": "^2.6.2",
"webpack": "^5.76.0"
},
"gitHead": "21358137e41342742707f660b8e222f940a27652"
}

View File

@@ -0,0 +1,18 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { NativeWebpackPlugin } from './native-webpack-plugin';
export = NativeWebpackPlugin;

View File

@@ -0,0 +1,26 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as webpack from 'webpack';
export class MonacoWebpackPlugin {
apply(compiler: webpack.Compiler): void {
compiler.hooks.contextModuleFactory.tap('MonacoBuildPlugin', cmf => {
cmf.hooks.contextModuleFiles.tap('MonacoBuildPlugin', files => files.filter(file => !file.endsWith('.d.ts')));
});
}
}

View File

@@ -0,0 +1,214 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import type { Compiler } from 'webpack';
const REQUIRE_RIPGREP = '@vscode/ripgrep';
const REQUIRE_BINDINGS = 'bindings';
const REQUIRE_PARCEL_WATCHER = './build/Release/watcher.node';
const REQUIRE_NODE_PTY_CONPTY = '../build/Release/conpty.node';
export interface NativeWebpackPluginOptions {
out: string;
trash: boolean;
ripgrep: boolean;
pty: boolean;
replacements?: Record<string, string>;
nativeBindings?: Record<string, string>;
}
export class NativeWebpackPlugin {
private bindings = new Map<string, string>();
private options: NativeWebpackPluginOptions;
constructor(options: NativeWebpackPluginOptions) {
this.options = options;
for (const [name, value] of Object.entries(options.nativeBindings ?? {})) {
this.nativeBinding(name, value);
}
}
nativeBinding(dependency: string, nodePath: string): void {
this.bindings.set(dependency, nodePath);
}
apply(compiler: Compiler): void {
let replacements: Record<string, (issuer: string) => Promise<string>> = {};
let nodePtyIssuer: string | undefined;
let trashHelperIssuer: string | undefined;
let ripgrepIssuer: string | undefined;
compiler.hooks.initialize.tap(NativeWebpackPlugin.name, async () => {
const directory = path.resolve(compiler.outputPath, 'native-webpack-plugin');
await fs.promises.mkdir(directory, { recursive: true });
const bindingsFile = (issuer: string) => buildFile(directory, 'bindings.js', bindingsReplacement(issuer, Array.from(this.bindings.entries())));
const ripgrepFile = () => buildFile(directory, 'ripgrep.js', ripgrepReplacement(this.options.out));
replacements = {
...(this.options.replacements ?? {}),
[REQUIRE_RIPGREP]: ripgrepFile,
[REQUIRE_BINDINGS]: bindingsFile,
[REQUIRE_PARCEL_WATCHER]: issuer => Promise.resolve(findNativeWatcherFile(issuer))
};
if (process.platform !== 'win32') {
// The expected conpty.node file is not available on non-windows platforms during build.
// We need to provide a stub that will be replaced by the real file at runtime.
replacements[REQUIRE_NODE_PTY_CONPTY] = () => buildFile(directory, 'conpty.js', conhostWindowsReplacement());
}
});
compiler.hooks.normalModuleFactory.tap(
NativeWebpackPlugin.name,
nmf => {
nmf.hooks.beforeResolve.tapPromise(NativeWebpackPlugin.name, async result => {
if (result.request === REQUIRE_RIPGREP) {
ripgrepIssuer = result.contextInfo.issuer;
} else if (result.request === 'node-pty') {
nodePtyIssuer = result.contextInfo.issuer;
} else if (result.request === 'trash') {
trashHelperIssuer = result.contextInfo.issuer;
}
for (const [file, replacement] of Object.entries(replacements)) {
if (result.request === file) {
result.request = await replacement(result.contextInfo.issuer);
}
}
});
}
);
compiler.hooks.afterEmit.tapPromise(NativeWebpackPlugin.name, async () => {
if (this.options.trash && trashHelperIssuer) {
await this.copyTrashHelper(trashHelperIssuer, compiler);
}
if (this.options.ripgrep && ripgrepIssuer) {
await this.copyRipgrep(ripgrepIssuer, compiler);
}
if (this.options.pty && nodePtyIssuer) {
await this.copyNodePtySpawnHelper(nodePtyIssuer, compiler);
}
});
}
protected async copyRipgrep(issuer: string, compiler: Compiler): Promise<void> {
const suffix = process.platform === 'win32' ? '.exe' : '';
const sourceFile = require.resolve(`@vscode/ripgrep/bin/rg${suffix}`, { paths: [issuer] });
const targetFile = path.join(compiler.outputPath, this.options.out, `rg${suffix}`);
await this.copyExecutable(sourceFile, targetFile);
}
protected async copyNodePtySpawnHelper(issuer: string, compiler: Compiler): Promise<void> {
const targetDirectory = path.resolve(compiler.outputPath, '..', 'build', 'Release');
if (process.platform === 'win32') {
const agentFile = require.resolve('node-pty/build/Release/winpty-agent.exe', { paths: [issuer] });
const targetAgentFile = path.join(targetDirectory, 'winpty-agent.exe');
await this.copyExecutable(agentFile, targetAgentFile);
const dllFile = require.resolve('node-pty/build/Release/winpty.dll', { paths: [issuer] });
const targetDllFile = path.join(targetDirectory, 'winpty.dll');
await this.copyExecutable(dllFile, targetDllFile);
} else if (process.platform === 'darwin') {
const sourceFile = require.resolve('node-pty/build/Release/spawn-helper', { paths: [issuer] });
const targetFile = path.join(targetDirectory, 'spawn-helper');
await this.copyExecutable(sourceFile, targetFile);
}
}
protected async copyTrashHelper(issuer: string, compiler: Compiler): Promise<void> {
let sourceFile: string | undefined;
let targetFile: string | undefined;
if (process.platform === 'win32') {
sourceFile = require.resolve('trash/lib/windows-trash.exe', { paths: [issuer] });
targetFile = path.join(compiler.outputPath, 'windows-trash.exe');
} else if (process.platform === 'darwin') {
sourceFile = require.resolve('trash/lib/macos-trash', { paths: [issuer] });
targetFile = path.join(compiler.outputPath, 'macos-trash');
}
if (sourceFile && targetFile) {
await this.copyExecutable(sourceFile, targetFile);
}
}
protected async copyExecutable(source: string, target: string): Promise<void> {
const targetDirectory = path.dirname(target);
await fs.promises.mkdir(targetDirectory, { recursive: true });
await fs.promises.copyFile(source, target);
await fs.promises.chmod(target, 0o777);
}
}
function findNativeWatcherFile(issuer: string): string {
let name = `@parcel/watcher-${process.platform}-${process.arch}`;
if (process.platform === 'linux') {
const { MUSL, family } = require('detect-libc');
if (family === MUSL) {
name += '-musl';
} else {
name += '-glibc';
}
}
return require.resolve(name, {
paths: [issuer]
});
}
async function buildFile(root: string, name: string, content: string): Promise<string> {
const tmpFile = path.join(root, name);
let write = true;
try {
const existing = await fs.promises.readFile(tmpFile, 'utf8');
if (existing === content) {
// prevent writing the same content again
// this would trigger the watch mode repeatedly
write = false;
}
} catch {
// ignore
}
if (write) {
await fs.promises.writeFile(tmpFile, content);
}
return tmpFile;
}
const ripgrepReplacement = (nativePath: string = '.'): string => `
const path = require('path');
exports.rgPath = path.join(__dirname, \`./${nativePath}/rg\${process.platform === 'win32' ? '.exe' : ''}\`);
`;
const bindingsReplacement = (issuer: string, entries: [string, string][]): string => {
const cases: string[] = [];
for (const [module, node] of entries) {
const modulePath = require.resolve(node, {
paths: [issuer]
});
cases.push(`${' '.repeat(8)}case '${module}': return require('${modulePath.replace(/\\/g, '/')}');`);
}
return `
module.exports = function (jsModule) {
switch (jsModule) {
${cases.join(os.EOL)}
}
throw new Error(\`unhandled module: "\${jsModule}"\`);
}`.trim();
};
const conhostWindowsReplacement = (nativePath: string = '.'): string => `
module.exports = __non_webpack_require__('${nativePath}/native/conpty.node');
`;

View File

@@ -0,0 +1,28 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/* note: this bogus test file is required so that
we are able to run mocha unit tests on this
package, without having any actual unit tests in it.
This way a coverage report will be generated,
showing 0% coverage, instead of no report.
This file can be removed once we have real unit
tests in place. */
describe('request package', () => {
it('should support code coverage statistics', () => true);
});

View File

@@ -0,0 +1,12 @@
{
"extends": "../../configs/base.tsconfig",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": []
}

View File

@@ -0,0 +1,10 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
}
};

View File

@@ -0,0 +1,62 @@
<div align='center'>
<br />
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
<h2>ECLIPSE THEIA - OVSX CLIENT</h2>
<hr />
</div>
## Description
The `@theia/ovsx-client` package is used to interact with `open-vsx` through its REST APIs.
The package allows clients to fetch extensions and their metadata, search the registry, and
includes the necessary logic to determine compatibility based on a provided supported API version.
Note that this client only supports a subset of the whole OpenVSX API, only what's relevant to
clients like Theia applications.
### `OVSXRouterClient`
This class is an `OVSXClient` that can delegate requests to sub-clients based on some configuration (`OVSXRouterConfig`).
```jsonc
{
"registries": {
// `[Alias]: URL` pairs to avoid copy pasting URLs down the config
},
"use": [
// List of aliases/URLs to use when no filtering was applied.
],
"rules": [
{
"ifRequestContains": "regex matched against various fields in requests",
"ifExtensionIdMatches": "regex matched against the extension id (without version)",
"use": [/*
List of registries to forward the request to when all the
conditions are matched.
`null` or `[]` means to not forward the request anywhere.
*/]
}
]
}
```
## Additional Information
- [Theia - GitHub](https://github.com/eclipse-theia/theia)
- [Theia - Website](https://theia-ide.org/)
## License
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
## Trademark
"Theia" is a trademark of the Eclipse Foundation
<https://www.eclipse.org/theia>

View File

@@ -0,0 +1,38 @@
{
"name": "@theia/ovsx-client",
"version": "1.68.0",
"description": "Theia Open-VSX Client",
"publishConfig": {
"access": "public"
},
"license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0",
"repository": {
"type": "git",
"url": "https://github.com/eclipse-theia/theia.git"
},
"bugs": {
"url": "https://github.com/eclipse-theia/theia/issues"
},
"homepage": "https://github.com/eclipse-theia/theia",
"files": [
"lib",
"src"
],
"main": "lib/index.js",
"typings": "lib/index.d.ts",
"scripts": {
"build": "theiaext build",
"clean": "theiaext clean",
"compile": "theiaext compile",
"lint": "theiaext lint",
"test": "theiaext test",
"watch": "theiaext watch"
},
"dependencies": {
"@theia/request": "1.68.0",
"limiter": "^2.1.0",
"semver": "^7.5.4",
"tslib": "^2.6.2"
},
"gitHead": "21358137e41342742707f660b8e222f940a27652"
}

View File

@@ -0,0 +1,22 @@
// *****************************************************************************
// Copyright (C) 2021 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export { OVSXApiFilter, OVSXApiFilterImpl, OVSXApiFilterProvider } from './ovsx-api-filter';
export { OVSXHttpClient, OVSX_RATE_LIMIT } from './ovsx-http-client';
export { OVSXMockClient } from './test/ovsx-mock-client';
export { OVSXRouterClient, OVSXRouterConfig, OVSXRouterFilterFactory as FilterFactory } from './ovsx-router-client';
export * from './ovsx-router-filters';
export * from './ovsx-types';

View File

@@ -0,0 +1,140 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as semver from 'semver';
import { OVSXClient, VSXAllVersions, VSXBuiltinNamespaces, VSXExtensionRaw, VSXQueryOptions, VSXSearchEntry } from './ovsx-types';
export const OVSXApiFilterProvider = Symbol('OVSXApiFilterProvider');
export type OVSXApiFilterProvider = () => Promise<OVSXApiFilter>;
export const OVSXApiFilter = Symbol('OVSXApiFilter');
/**
* Filter various data types based on a pre-defined supported VS Code API version.
*/
export interface OVSXApiFilter {
supportedApiVersion: string;
findLatestCompatibleExtension(query: VSXQueryOptions): Promise<VSXExtensionRaw | undefined>;
/**
* Get the latest compatible extension version:
* - A builtin extension is fetched based on the extension version which matches the API.
* - An extension satisfies compatibility if its `engines.vscode` version is supported.
*
* @param extensionId the extension id.
* @returns the data for the latest compatible extension version if available, else `undefined`.
*/
getLatestCompatibleExtension(extensions: VSXExtensionRaw[]): VSXExtensionRaw | undefined;
getLatestCompatibleVersion(searchEntry: VSXSearchEntry): VSXAllVersions | undefined;
}
export class OVSXApiFilterImpl implements OVSXApiFilter {
constructor(
public client: OVSXClient,
public supportedApiVersion: string
) { }
async findLatestCompatibleExtension(query: VSXQueryOptions): Promise<VSXExtensionRaw | undefined> {
const targetPlatform = query.targetPlatform;
if (!targetPlatform) {
return this.queryLatestCompatibleExtension(query);
}
const latestWithTargetPlatform = await this.queryLatestCompatibleExtension(query);
let latestUniversal: VSXExtensionRaw | undefined;
if (targetPlatform !== 'universal' && targetPlatform !== 'web') {
// Additionally query the universal version, as there might be a newer one available
latestUniversal = await this.queryLatestCompatibleExtension({ ...query, targetPlatform: 'universal' });
}
if (latestWithTargetPlatform && latestUniversal) {
// Prefer the version with the target platform if it's greater or equal to the universal version
return this.versionGreaterThanOrEqualTo(latestWithTargetPlatform.version, latestUniversal.version) ? latestWithTargetPlatform : latestUniversal;
}
return latestWithTargetPlatform ?? latestUniversal;
}
protected async queryLatestCompatibleExtension(query: VSXQueryOptions): Promise<VSXExtensionRaw | undefined> {
let offset = 0;
let size = 5;
let loop = true;
while (loop) {
const queryOptions: VSXQueryOptions = {
...query,
offset,
size // there is a great chance that the newest version will work
};
const results = await this.client.query(queryOptions);
const compatibleExtension = this.getLatestCompatibleExtension(results.extensions);
if (compatibleExtension) {
return compatibleExtension;
}
// Adjust offset by the amount of returned extensions
offset += results.extensions.length;
// Continue querying if there are more extensions available
loop = results.totalSize > offset;
// Adjust the size to fetch more extensions next time
size = Math.min(size * 2, 100);
}
return undefined;
}
getLatestCompatibleExtension(extensions: VSXExtensionRaw[]): VSXExtensionRaw | undefined {
if (extensions.length === 0) {
return;
} else if (this.isBuiltinNamespace(extensions[0].namespace.toLowerCase())) {
return extensions.find(extension => this.versionGreaterThanOrEqualTo(this.supportedApiVersion, extension.version));
} else {
return extensions.find(extension => this.supportedVscodeApiSatisfies(extension.engines?.vscode ?? '*'));
}
}
getLatestCompatibleVersion(searchEntry: VSXSearchEntry): VSXAllVersions | undefined {
function getLatestCompatibleVersion(predicate: (allVersions: VSXAllVersions) => boolean): VSXAllVersions | undefined {
if (searchEntry.allVersions) {
return searchEntry.allVersions.find(predicate);
}
// If the allVersions field is missing then try to use the
// searchEntry as VSXAllVersions and check if it's compatible:
if (predicate(searchEntry)) {
return searchEntry;
}
}
if (this.isBuiltinNamespace(searchEntry.namespace)) {
return getLatestCompatibleVersion(allVersions => this.versionGreaterThanOrEqualTo(this.supportedApiVersion, allVersions.version));
} else {
return getLatestCompatibleVersion(allVersions => this.supportedVscodeApiSatisfies(allVersions.engines?.vscode ?? '*'));
}
}
protected isBuiltinNamespace(namespace: string): boolean {
return VSXBuiltinNamespaces.is(namespace);
}
/**
* @returns `a >= b`
*/
protected versionGreaterThanOrEqualTo(a: string, b: string): boolean {
const versionA = semver.clean(a);
const versionB = semver.clean(b);
if (!versionA || !versionB) {
return false;
}
return semver.gte(versionA, versionB);
}
protected supportedVscodeApiSatisfies(vscodeApiRange: string): boolean {
return semver.satisfies(this.supportedApiVersion, vscodeApiRange);
}
}

View File

@@ -0,0 +1,89 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { OVSXClient, VSXQueryOptions, VSXQueryResult, VSXSearchOptions, VSXSearchResult } from './ovsx-types';
import { RequestContext, RequestService } from '@theia/request';
import { RateLimiter } from 'limiter';
export const OVSX_RATE_LIMIT = 15;
export class OVSXHttpClient implements OVSXClient {
/**
* @param requestService
* @returns factory that will cache clients based on the requested input URL.
*/
static createClientFactory(requestService: RequestService, rateLimiter?: RateLimiter): (url: string) => OVSXClient {
// eslint-disable-next-line no-null/no-null
const cachedClients: Record<string, OVSXClient> = Object.create(null);
return url => cachedClients[url] ??= new this(url, requestService, rateLimiter);
}
constructor(
protected vsxRegistryUrl: string,
protected requestService: RequestService,
protected rateLimiter = new RateLimiter({ tokensPerInterval: OVSX_RATE_LIMIT, interval: 'second' })
) { }
search(searchOptions?: VSXSearchOptions): Promise<VSXSearchResult> {
return this.requestJson(this.buildUrl('api/-/search', searchOptions));
}
query(queryOptions?: VSXQueryOptions): Promise<VSXQueryResult> {
return this.requestJson(this.buildUrl('api/v2/-/query', queryOptions));
}
protected async requestJson<R>(url: string): Promise<R> {
const attempts = 5;
for (let i = 0; i < attempts; i++) {
// Use 1, 2, 4, 8, 16 tokens for each attempt
const tokenCount = Math.pow(2, i);
await this.rateLimiter.removeTokens(tokenCount);
const context = await this.requestService.request({
url,
headers: { 'Accept': 'application/json' }
});
if (context.res.statusCode === 429) {
console.warn('OVSX rate limit exceeded. Consider reducing the rate limit.');
// If there are still more attempts left, retry the request with a higher token count
if (i < attempts - 1) {
continue;
}
}
return RequestContext.asJson<R>(context);
}
throw new Error('Failed to fetch data from OVSX.');
}
protected buildUrl(url: string, query?: object): string {
return new URL(`${url}${this.buildQueryString(query)}`, this.vsxRegistryUrl).toString();
}
protected buildQueryString(searchQuery?: object): string {
if (!searchQuery) {
return '';
}
let queryString = '';
for (const [key, value] of Object.entries(searchQuery)) {
if (typeof value === 'string') {
queryString += `&${key}=${encodeURIComponent(value)}`;
} else if (typeof value === 'boolean' || typeof value === 'number') {
queryString += `&${key}=${value}`;
}
}
return queryString && '?' + queryString.slice(1);
}
}

View File

@@ -0,0 +1,126 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/* eslint-disable no-null/no-null */
import { OVSXRouterClient } from './ovsx-router-client';
import { testClientProvider, registries, filterFactories } from './test/ovsx-router-client.spec-data';
import { ExtensionLike } from './ovsx-types';
import assert = require('assert');
describe('OVSXRouterClient', async () => {
const router = await OVSXRouterClient.FromConfig(
{
registries,
use: ['internal', 'public', 'third'],
rules: [{
ifRequestContains: /\btestFullStop\b/.source,
use: null,
},
{
ifRequestContains: /\bsecret\b/.source,
use: 'internal'
},
{
ifExtensionIdMatches: /^some\./.source,
use: 'internal'
}]
},
testClientProvider,
filterFactories,
);
it('test query agglomeration', async () => {
const result = await router.query({ namespaceName: 'other' });
assert.deepStrictEqual(result.extensions.map(ExtensionLike.id), [
// note the order: plugins from "internal" first then from "public"
'other.d',
'other.e'
]);
});
it('test query request filtering', async () => {
const result = await router.query({ namespaceName: 'secret' });
assert.deepStrictEqual(result.extensions.map(ExtensionLike.id), [
// 'secret.w' from 'public' shouldn't be returned
'secret.x',
'secret.y',
'secret.z'
]);
});
it('test query result filtering', async () => {
const result = await router.query({ namespaceName: 'some' });
assert.deepStrictEqual(result.extensions.map(ExtensionLike.idWithVersion), [
// no entry for the `some` namespace should be returned from the `public` registry
'some.a@1.0.0'
]);
});
it('test query full stop', async () => {
const result = await router.query({ extensionId: 'testFullStop.c' });
assert.deepStrictEqual(result.extensions.length, 0);
});
it('test search agglomeration', async () => {
const result = await router.search({ query: 'other.' });
assert.deepStrictEqual(result.extensions.map(ExtensionLike.id), [
// note the order: plugins from "internal" first then from "public"
'other.d',
'other.e'
]);
});
it('test search request filtering', async () => {
const result = await router.search({ query: 'secret.' });
assert.deepStrictEqual(result.extensions.map(ExtensionLike.id), [
// 'secret.w' from 'public' shouldn't be returned
'secret.x',
'secret.y',
'secret.z'
]);
});
it('test search result filtering', async () => {
const result = await router.search({ query: 'some.' });
assert.deepStrictEqual(result.extensions.map(ExtensionLike.idWithVersion), [
// no entry for the `some` namespace should be returned from the `public` registry
'some.a@1.0.0'
]);
});
it('test search full stop', async () => {
const result = await router.search({ query: 'testFullStop.c' });
assert.deepStrictEqual(result.extensions.length, 0);
});
it('test config with unknown conditions', async () => {
const clientPromise = OVSXRouterClient.FromConfig(
{
use: 'not relevant',
rules: [{
ifRequestContains: /.*/.source,
unknownCondition: /should cause an error to be thrown/.source,
use: ['internal', 'public']
}]
},
testClientProvider,
filterFactories
);
assert.rejects(clientPromise, /^Error: unknown conditions:/);
});
});

View File

@@ -0,0 +1,253 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ExtensionLike, OVSXClient, OVSXClientProvider, VSXExtensionRaw, VSXQueryOptions, VSXQueryResult, VSXSearchEntry, VSXSearchOptions, VSXSearchResult } from './ovsx-types';
import type { MaybePromise } from './types';
export interface OVSXRouterFilter {
filterSearchOptions?(searchOptions?: VSXSearchOptions): MaybePromise<unknown>;
filterQueryOptions?(queryOptions?: VSXQueryOptions): MaybePromise<unknown>;
filterExtension?(extension: ExtensionLike): MaybePromise<unknown>;
}
/**
* @param conditions key/value mapping of condition statements that rules may process
* @param remainingKeys keys left to be processed, remove items from it when you handled them
*/
export type OVSXRouterFilterFactory = (conditions: Readonly<Record<string, unknown>>, remainingKeys: Set<string>) => MaybePromise<OVSXRouterFilter | undefined>;
/**
* Helper function to create factories that handle a single condition key.
*/
export function createFilterFactory(conditionKey: string, factory: (conditionValue: unknown) => OVSXRouterFilter | undefined): OVSXRouterFilterFactory {
return (conditions, remainingKeys) => {
if (conditionKey in conditions) {
const filter = factory(conditions[conditionKey]);
if (filter) {
remainingKeys.delete(conditionKey);
return filter;
}
}
};
}
export interface OVSXRouterConfig {
/**
* Registry aliases that will be used for routing.
*/
registries?: {
[alias: string]: string
}
/**
* The registry/ies to use by default.
*/
use: string | string[]
/**
* Filters for the different phases of interfacing with a registry.
*/
rules?: OVSXRouterRule[]
}
export interface OVSXRouterRule {
[condition: string]: unknown
use?: string | string[] | null
}
/**
* @internal
*/
export interface OVSXRouterParsedRule {
filters: OVSXRouterFilter[]
use: string[]
}
/**
* Route and agglomerate queries according to {@link routerConfig}.
* {@link ruleFactories} is the actual logic used to evaluate the config.
* Each rule implementation will be ran sequentially over each configured rule.
*/
export class OVSXRouterClient implements OVSXClient {
static async FromConfig(routerConfig: OVSXRouterConfig, clientProvider: OVSXClientProvider, filterFactories: OVSXRouterFilterFactory[]): Promise<OVSXRouterClient> {
const rules = routerConfig.rules ? await this.ParseRules(routerConfig.rules, filterFactories, routerConfig.registries) : [];
return new this(
this.ParseUse(routerConfig.use, routerConfig.registries),
clientProvider,
rules
);
}
protected static async ParseRules(rules: OVSXRouterRule[], filterFactories: OVSXRouterFilterFactory[], aliases?: Record<string, string>): Promise<OVSXRouterParsedRule[]> {
return Promise.all(rules.map(async ({ use, ...conditions }) => {
const remainingKeys = new Set(Object.keys(conditions));
const filters = removeNullValues(await Promise.all(filterFactories.map(filterFactory => filterFactory(conditions, remainingKeys))));
if (remainingKeys.size > 0) {
throw new Error(`unknown conditions: ${Array.from(remainingKeys).join(', ')}`);
}
return {
filters,
use: this.ParseUse(use, aliases)
};
}));
}
protected static ParseUse(use: string | string[] | null | undefined, aliases?: Record<string, string>): string[] {
if (typeof use === 'string') {
return [alias(use)];
} else if (Array.isArray(use)) {
return use.map(alias);
} else {
return [];
}
function alias(aliasOrUri: string): string {
return aliases?.[aliasOrUri] ?? aliasOrUri;
}
}
constructor(
protected readonly useDefault: string[],
protected readonly clientProvider: OVSXClientProvider,
protected readonly rules: OVSXRouterParsedRule[],
) { }
async search(searchOptions?: VSXSearchOptions): Promise<VSXSearchResult> {
return this.runRules(
filter => filter.filterSearchOptions?.(searchOptions),
rule => rule.use.length > 0
? this.mergedSearch(rule.use, searchOptions)
: this.emptySearchResult(searchOptions),
() => this.mergedSearch(this.useDefault, searchOptions)
);
}
async query(queryOptions: VSXQueryOptions = {}): Promise<VSXQueryResult> {
return this.runRules(
filter => filter.filterQueryOptions?.(queryOptions),
rule => rule.use.length > 0
? this.mergedQuery(rule.use, queryOptions)
: this.emptyQueryResult(queryOptions),
() => this.mergedQuery(this.useDefault, queryOptions)
);
}
protected emptySearchResult(searchOptions?: VSXSearchOptions): VSXSearchResult {
return {
extensions: [],
offset: searchOptions?.offset ?? 0
};
}
protected emptyQueryResult(queryOptions?: VSXQueryOptions): VSXQueryResult {
return {
offset: 0,
totalSize: 0,
extensions: []
};
}
protected async mergedQuery(registries: string[], queryOptions?: VSXQueryOptions): Promise<VSXQueryResult> {
return this.mergeQueryResults(await createMapping(registries, async registry => (await this.clientProvider(registry)).query(queryOptions)));
}
protected async mergedSearch(registries: string[], searchOptions?: VSXSearchOptions): Promise<VSXSearchResult> {
return this.mergeSearchResults(await createMapping(registries, async registry => (await this.clientProvider(registry)).search(searchOptions)));
}
protected async mergeSearchResults(results: Map<string, VSXSearchResult>): Promise<VSXSearchResult> {
const filtering = [] as Promise<VSXSearchEntry[]>[];
results.forEach((result, sourceUri) => {
filtering.push(Promise
.all(result.extensions.map(extension => this.filterExtension(sourceUri, extension)))
.then(removeNullValues)
);
});
return {
extensions: interleave(await Promise.all(filtering)),
offset: Math.min(...Array.from(results.values(), result => result.offset))
};
}
protected async mergeQueryResults(results: Map<string, VSXQueryResult>): Promise<VSXQueryResult> {
const filtering = [] as Promise<VSXExtensionRaw | undefined>[];
results.forEach((result, sourceUri) => {
result.extensions.forEach(extension => filtering.push(this.filterExtension(sourceUri, extension)));
});
const extensions = removeNullValues(await Promise.all(filtering));
return {
offset: 0,
totalSize: extensions.length,
extensions
};
}
protected async filterExtension<T extends ExtensionLike>(sourceUri: string, extension: T): Promise<T | undefined> {
return this.runRules(
filter => filter.filterExtension?.(extension),
rule => rule.use.includes(sourceUri) ? extension : undefined,
() => extension
);
}
protected runRules<T>(runFilter: (filter: OVSXRouterFilter) => unknown, onRuleMatched: (rule: OVSXRouterParsedRule) => T): Promise<T | undefined>;
protected runRules<T, U>(runFilter: (filter: OVSXRouterFilter) => unknown, onRuleMatched: (rule: OVSXRouterParsedRule) => T, onNoRuleMatched: () => U): Promise<T | U>;
protected async runRules<T, U>(
runFilter: (filter: OVSXRouterFilter) => unknown,
onRuleMatched: (rule: OVSXRouterParsedRule) => T,
onNoRuleMatched?: () => U
): Promise<T | U | undefined> {
for (const rule of this.rules) {
const results = removeNullValues(await Promise.all(rule.filters.map(filter => runFilter(filter))));
if (results.length > 0 && results.every(value => value)) {
return onRuleMatched(rule);
}
}
return onNoRuleMatched?.();
}
}
function nonNullable<T>(value: T | null | undefined): value is T {
// eslint-disable-next-line no-null/no-null
return typeof value !== 'undefined' && value !== null;
}
function removeNullValues<T>(values: (T | null | undefined)[]): T[] {
return values.filter(nonNullable);
}
/**
* Create a map where the keys are each element from {@link values} and the
* values are the result of a mapping function applied on the key.
*/
async function createMapping<T, U>(values: T[], map: (value: T, index: number) => MaybePromise<U>, thisArg?: unknown): Promise<Map<T, U>> {
return new Map(await Promise.all(values.map(async (value, index) => [value, await map.call(thisArg, value, index)] as [T, U])));
}
/**
* @example
* interleave([[1, 2, 3], [4, 5], [6, 7, 8]]) === [1, 4, 6, 2, 5, 7, 3, 8]
*/
function interleave<T>(arrays: T[][]): T[] {
const interleaved: T[] = [];
const length = Math.max(...arrays.map(array => array.length));
for (let i = 0; i < length; i++) {
for (const array of arrays) {
if (i < array.length) {
interleaved.push(array[i]);
}
}
}
return interleaved;
}

View File

@@ -0,0 +1,26 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export abstract class AbstractRegExpFilter {
constructor(
protected regExp: RegExp
) { }
protected test(value: unknown): boolean {
return typeof value === 'string' && this.regExp.test(value);
}
}

View File

@@ -0,0 +1,32 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { createFilterFactory, OVSXRouterFilter } from '../ovsx-router-client';
import { ExtensionLike } from '../ovsx-types';
import { AbstractRegExpFilter } from './abstract-reg-exp-filter';
export const ExtensionIdMatchesFilterFactory = createFilterFactory('ifExtensionIdMatches', ifExtensionIdMatches => {
if (typeof ifExtensionIdMatches !== 'string') {
throw new TypeError(`expected a string, got: ${typeof ifExtensionIdMatches}`);
}
return new ExtensionIdMatchesFilter(new RegExp(ifExtensionIdMatches, 'i'));
});
export class ExtensionIdMatchesFilter extends AbstractRegExpFilter implements OVSXRouterFilter {
filterExtension(extension: ExtensionLike): boolean {
return this.test(ExtensionLike.id(extension));
}
}

View File

@@ -0,0 +1,18 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export { ExtensionIdMatchesFilterFactory } from './extension-id-matches-filter';
export { RequestContainsFilterFactory } from './request-contains-filter';

View File

@@ -0,0 +1,35 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { createFilterFactory, OVSXRouterFilter } from '../ovsx-router-client';
import { VSXQueryOptions, VSXSearchOptions } from '../ovsx-types';
import { AbstractRegExpFilter } from './abstract-reg-exp-filter';
export const RequestContainsFilterFactory = createFilterFactory('ifRequestContains', ifRequestContains => {
if (typeof ifRequestContains !== 'string') {
throw new TypeError(`expected a string, got: ${typeof ifRequestContains}`);
}
return new RequestContainsFilter(new RegExp(ifRequestContains, 'i'));
});
export class RequestContainsFilter extends AbstractRegExpFilter implements OVSXRouterFilter {
filterSearchOptions(searchOptions?: VSXSearchOptions): boolean {
return !searchOptions || this.test(searchOptions.query) || this.test(searchOptions.category);
}
filterQueryOptions(queryOptions?: VSXQueryOptions): boolean {
return !queryOptions || Object.values(queryOptions).some(this.test, this);
}
}

View File

@@ -0,0 +1,309 @@
// *****************************************************************************
// Copyright (C) 2020 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { MaybePromise } from './types';
export interface ExtensionLike {
name: string;
namespace: string;
version?: string;
}
export namespace ExtensionLike {
export function id<T extends ExtensionLike>(extension: T): `${string}.${string}` {
return `${extension.namespace}.${extension.name}`;
}
export function idWithVersion<T extends ExtensionLike>(extension: T): `${string}.${string}@${string}` {
if (!extension.version) {
throw new Error(`no valid "version" value provided for "${id(extension)}"`);
}
return `${id(extension)}@${extension.version}`;
}
// eslint-disable-next-line @typescript-eslint/no-shadow
export function fromId(id: string): ExtensionLike {
const [left, version] = id.split('@', 2);
const [namespace, name] = left.split('.', 2);
return {
name,
namespace,
version
};
}
}
export interface OVSXClient {
/**
* GET https://openvsx.org/api/-/search
*/
search(searchOptions?: VSXSearchOptions): Promise<VSXSearchResult>;
/**
* GET https://openvsx.org/api/v2/-/query
*
* Fetch one or all versions of an extension.
*/
query(queryOptions?: VSXQueryOptions): Promise<VSXQueryResult>;
}
/** @deprecated since 1.31.0 use {@link VSXSearchOptions} instead */
export type VSXSearchParam = VSXSearchOptions;
/**
* The possible options when performing a search.
*
* For available options, and default values consult the `swagger`: https://open-vsx.org/swagger-ui/index.html.
*
* Should be aligned with https://github.com/eclipse/openvsx/blob/b5694a712e07d266801394916bac30609e16d77b/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java#L246-L266
*/
export interface VSXSearchOptions {
/**
* The query text for searching.
*/
query?: string;
/**
* The extension category.
*/
category?: string;
/**
* The maximum number of entries to return.
*/
size?: number;
/**
* The number of entries to skip (usually a multiple of the page size).
*/
offset?: number;
/**
* The sort order.
*/
sortOrder?: 'asc' | 'desc';
/**
* The sort key.
*/
sortBy?: 'averageRating' | 'downloadCount' | 'relevance' | 'timestamp';
/**
* By default an OpenVSX registry will return the last known version of
* extensions. Setting this field to `true` will have the registry specify
* the {@link VSXExtensionRaw.allVersions} field which references all known
* versions for each returned extension.
*
* @default false
*/
includeAllVersions?: boolean;
}
/**
* Should be aligned with https://github.com/eclipse/openvsx/blob/e8f64fe145fc05d2de1469735d50a7a90e400bc4/server/src/main/java/org/eclipse/openvsx/json/SearchResultJson.java
*/
export interface VSXSearchResult {
offset: number;
extensions: VSXSearchEntry[];
}
/**
* The possible options when performing a search.
*
* For available options, and default values consult the `swagger`: https://open-vsx.org/swagger-ui/index.html.
*
* Should be aligned with https://github.com/eclipse/openvsx/blob/b5694a712e07d266801394916bac30609e16d77b/server/src/main/java/org/eclipse/openvsx/json/QueryParamJson.java#L18-L46
*/
export interface VSXQueryOptions {
namespaceName?: string;
extensionName?: string;
extensionVersion?: string;
extensionId?: string;
extensionUuid?: string;
namespaceUuid?: string;
includeAllVersions?: boolean | 'links';
targetPlatform?: VSXTargetPlatform;
size?: number;
offset?: number;
}
export type VSXTargetPlatform =
'universal' | 'web' |
'win32-x64' | 'win32-ia32' | 'win32-arm64' |
'darwin-x64' | 'darwin-arm64' |
'linux-x64' | 'linux-arm64' | 'linux-armhf' |
'alpine-x64' | 'alpine-arm64' | (string & {});
export interface VSXQueryResult {
success?: string;
warning?: string;
error?: string;
offset: number;
totalSize: number;
extensions: VSXExtensionRaw[];
}
/**
* This type describes the data as found in {@link VSXSearchEntry.allVersions}.
*
* Note that this type only represents one version of a given plugin, despite the name.
*/
export interface VSXAllVersions {
url: string;
version: string;
engines?: {
[version: string]: string;
};
}
/**
* Should be aligned with https://github.com/eclipse/openvsx/blob/master/server/src/main/java/org/eclipse/openvsx/json/SearchEntryJson.java
*/
export interface VSXSearchEntry {
url: string;
files: {
download: string;
manifest?: string;
readme?: string;
license?: string;
icon?: string;
};
name: string;
namespace: string;
version: string;
timestamp: string;
averageRating?: number;
downloadCount: number;
displayName?: string;
description?: string;
/**
* May be undefined when {@link VSXSearchOptions.includeAllVersions} is
* `false` or `undefined`.
*/
allVersions?: VSXAllVersions[];
}
export type VSXExtensionNamespaceAccess = 'public' | 'restricted';
/**
* Should be aligned with https://github.com/eclipse/openvsx/blob/master/server/src/main/java/org/eclipse/openvsx/json/UserJson.java
*/
export interface VSXUser {
loginName: string;
homepage?: string;
}
export interface VSXExtensionRawFiles {
download: string;
readme?: string;
license?: string;
icon?: string;
}
/**
* Should be aligned with https://github.com/eclipse/openvsx/blob/master/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java
*/
export interface VSXExtensionRaw {
error?: string;
namespaceUrl: string;
reviewsUrl: string;
name: string;
namespace: string;
targetPlatform?: VSXTargetPlatform;
publishedBy: VSXUser;
preRelease: boolean;
namespaceAccess: VSXExtensionNamespaceAccess;
files: VSXExtensionRawFiles;
allVersions: {
[version: string]: string;
};
allVersionsUrl?: string;
averageRating?: number;
downloadCount: number;
reviewCount: number;
version: string;
timestamp: string;
preview?: boolean;
verified?: boolean;
displayName?: string;
namespaceDisplayName: string;
description?: string;
categories?: string[];
extensionKind?: string[];
tags?: string[];
license?: string;
homepage?: string;
repository?: string;
sponsorLink?: string;
bugs?: string;
markdown?: string;
galleryColor?: string;
galleryTheme?: string;
localizedLanguages?: string[];
qna?: string;
badges?: VSXBadge[];
dependencies?: VSXExtensionReference[];
bundledExtensions?: VSXExtensionReference[];
allTargetPlatformVersions?: VSXTargetPlatforms[];
url?: string;
engines?: {
[engine: string]: string;
};
}
export interface VSXBadge {
url?: string;
href?: string;
description?: string;
}
export interface VSXExtensionReference {
url: string;
namespace: string;
extension: string;
}
export interface VSXTargetPlatforms {
version: string;
targetPlatforms: VSXTargetPlatform[];
}
export interface VSXResponseError extends Error {
statusCode: number;
}
export namespace VSXResponseError {
export function is(error: unknown): error is VSXResponseError {
return !!error && typeof error === 'object' && typeof (error as VSXResponseError).statusCode === 'number';
}
}
/**
* Builtin namespaces maintained by the framework.
*/
export namespace VSXBuiltinNamespaces {
/**
* Namespace for individual vscode builtin extensions.
*/
export const VSCODE = 'vscode';
/**
* Namespace for vscode builtin extension packs.
* - corresponds to: https://github.com/eclipse-theia/vscode-builtin-extensions/blob/af9cfeb2ea23e1668a8340c1c2fb5afd56be07d7/src/create-extension-pack.js#L45
*/
export const THEIA = 'eclipse-theia';
/**
* Determines if the extension namespace is a builtin maintained by the framework.
* @param namespace the extension namespace to verify.
*/
export function is(namespace: string): boolean {
return namespace === VSCODE
|| namespace === THEIA;
}
}
export type OVSXClientProvider = (uri: string) => MaybePromise<OVSXClient>;

View File

@@ -0,0 +1,187 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ExtensionLike, OVSXClient, VSXExtensionRaw, VSXQueryOptions, VSXQueryResult, VSXSearchOptions, VSXSearchResult } from '../ovsx-types';
/**
* Querying will only find exact matches.
* Searching will try to find the query string in various fields.
*/
export class OVSXMockClient implements OVSXClient {
constructor(
public extensions: VSXExtensionRaw[] = []
) { }
setExtensions(extensions: VSXExtensionRaw[]): this {
this.extensions = extensions;
return this;
}
/**
* @param baseUrl required to construct the URLs required by {@link VSXExtensionRaw}.
* @param ids list of ids to generate {@link VSXExtensionRaw} from.
*/
setExtensionsFromIds(baseUrl: string, ids: string[]): this {
const now = Date.now();
const url = new OVSXMockClient.UrlBuilder(baseUrl);
this.extensions = ids.map((extension, i) => {
const [id, version = '0.0.1'] = extension.split('@', 2);
const [namespace, name] = id.split('.', 2);
return {
allVersions: {
[version]: url.extensionUrl(namespace, name, `/${version}`)
},
displayName: name,
downloadCount: 0,
files: {
download: url.extensionFileUrl(namespace, name, version, `/${id}-${version}.vsix`)
},
name,
namespace,
namespaceAccess: 'public',
namespaceUrl: url.namespaceUrl(namespace),
publishedBy: {
loginName: 'mock'
},
reviewCount: 0,
reviewsUrl: url.extensionReviewsUrl(namespace, name),
timestamp: new Date(now - ids.length + i + 1).toISOString(),
version,
description: `Mock VS Code Extension for ${id}`,
namespaceDisplayName: name,
preRelease: false
};
});
return this;
}
async query(queryOptions?: VSXQueryOptions): Promise<VSXQueryResult> {
const extensions = this.extensions
.filter(extension => typeof queryOptions === 'object' && (
this.compare(queryOptions.extensionId, this.id(extension)) &&
this.compare(queryOptions.extensionName, extension.name) &&
this.compare(queryOptions.extensionVersion, extension.version) &&
this.compare(queryOptions.namespaceName, extension.namespace)
));
return {
offset: 0,
totalSize: extensions.length,
extensions
};
}
async search(searchOptions?: VSXSearchOptions): Promise<VSXSearchResult> {
const query = searchOptions?.query;
const offset = searchOptions?.offset ?? 0;
const size = searchOptions?.size ?? 18;
const end = offset + size;
return {
offset,
extensions: this.extensions
.filter(extension => typeof query !== 'string' || (
this.includes(query, this.id(extension)) ||
this.includes(query, extension.description) ||
this.includes(query, extension.displayName)
))
.sort((a, b) => this.sort(a, b, searchOptions))
.filter((extension, i) => i >= offset && i < end)
.map(extension => ({
downloadCount: extension.downloadCount,
files: extension.files,
name: extension.name,
namespace: extension.namespace,
timestamp: extension.timestamp,
url: `${extension.namespaceUrl}/${extension.name}`,
version: extension.version,
}))
};
}
protected id(extension: ExtensionLike): string {
return `${extension.namespace}.${extension.name}`;
}
/**
* Case sensitive.
*/
protected compare(expected?: string, value?: string): boolean {
return expected === undefined || value === undefined || expected === value;
}
/**
* Case insensitive.
*/
protected includes(needle: string, value?: string): boolean {
return value === undefined || value.toLowerCase().includes(needle.toLowerCase());
}
protected sort(a: VSXExtensionRaw, b: VSXExtensionRaw, searchOptions?: VSXSearchOptions): number {
let order: number = 0;
const sortBy = searchOptions?.sortBy ?? 'relevance';
const sortOrder = searchOptions?.sortOrder ?? 'desc';
if (sortBy === 'averageRating') {
order = (a.averageRating ?? -1) - (b.averageRating ?? -1);
} else if (sortBy === 'downloadCount') {
order = a.downloadCount - b.downloadCount;
} else if (sortBy === 'relevance') {
order = 0;
} else if (sortBy === 'timestamp') {
order = new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
}
if (sortOrder === 'asc') {
order *= -1;
}
return order;
}
}
export namespace OVSXMockClient {
/**
* URLs should respect the official OpenVSX API:
* https://open-vsx.org/swagger-ui/index.html
*/
export class UrlBuilder {
constructor(
protected baseUrl: string
) { }
url(path: string): string {
return this.baseUrl + path;
}
apiUrl(path: string): string {
return this.url(`/api${path}`);
}
namespaceUrl(namespace: string, path = ''): string {
return this.apiUrl(`/${namespace}${path}`);
}
extensionUrl(namespace: string, name: string, path = ''): string {
return this.apiUrl(`/${namespace}/${name}${path}`);
}
extensionReviewsUrl(namespace: string, name: string): string {
return this.apiUrl(`/${namespace}/${name}/reviews`);
}
extensionFileUrl(namespace: string, name: string, version: string, path = ''): string {
return this.apiUrl(`/${namespace}/${name}/${version}/file${path}`);
}
}
}

View File

@@ -0,0 +1,68 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/* eslint-disable no-null/no-null */
import { OVSXMockClient } from './ovsx-mock-client';
import { ExtensionIdMatchesFilterFactory, RequestContainsFilterFactory } from '../ovsx-router-filters';
import { OVSXClient } from '../ovsx-types';
export const registries = {
internal: 'https://internal.testdomain/',
public: 'https://public.testdomain/',
third: 'https://third.testdomain/'
};
export const clients: Record<string, OVSXMockClient> = {
[registries.internal]: new OVSXMockClient().setExtensionsFromIds(registries.internal, [
'some.a@1.0.0',
'other.d',
'secret.x',
'secret.y',
'secret.z',
...Array(50)
.fill(undefined)
.map((element, i) => `internal.autogen${i}`)
]),
[registries.public]: new OVSXMockClient().setExtensionsFromIds(registries.public, [
'some.a@2.0.0',
'some.b',
'other.e',
'testFullStop.c',
'secret.w',
...Array(50)
.fill(undefined)
.map((element, i) => `public.autogen${i}`)
]),
[registries.third]: new OVSXMockClient().setExtensionsFromIds(registries.third, [
...Array(200)
.fill(undefined)
.map((element, i) => `third.autogen${i}`)
])
};
export const filterFactories = [
RequestContainsFilterFactory,
ExtensionIdMatchesFilterFactory
];
export function testClientProvider(uri: string): OVSXClient {
const client = clients[uri];
if (!client) {
throw new Error(`unknown client for URI=${uri}`);
}
return client;
};

View File

@@ -0,0 +1,17 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export type MaybePromise<T> = T | PromiseLike<T>;

View File

@@ -0,0 +1,16 @@
{
"extends": "../../configs/base.tsconfig",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../request"
}
]
}

View File

@@ -0,0 +1,10 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
}
};

View File

@@ -0,0 +1,62 @@
<div align='center'>
<br />
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
<h2>ECLIPSE THEIA - ESLINT PLUGIN</h2>
<hr />
</div>
## Description
The `@theia/eslint-plugin` contributes rules useful for Eclipse Theia development.
The plugin helps identify problems during development through static analysis including code quality, potential issues and code smells.
## Rules
### `annotation-check`
Inversify >=6.1 requires to annotate all constructor parameters of injectable classes as otherwise runtime errors are thrown.
The rule checks that all constructor parameters of injectable classes are annotated with `@inject`, `@unmanaged` or `@multiInject`.
### `localization-check`
The rule prevents the following localization related issues:
- incorrect usage of the `nls.localizeByDefault` function by using an incorrect default value.
- unnecessary call to `nls.localize` which could be replaced by `nls.localizeByDefault`.
### `no-src-import`
The rule prevents imports using `/src/` rather than `/lib/` as it causes build failures.
The rule helps developers more easily identify the cause of build errors caused by the incorrect import.
#### `runtime-import-check`
The rule prevents imports from folders meant for incompatible runtimes.
The check enforces the [code organization guidelines](https://github.com/eclipse-theia/theia/wiki/Code-Organization) of the framework and guards against invalid imports which may cause unforeseen issues downstream.
#### `shared-dependencies`
The rule prevents the following:
- prevents the implicit use of a shared dependency from `@theia/core`.
- prevents extensions from depending on a shared dependency without re-using it from `@theia/core`.
## Additional Information
- [Theia - GitHub](https://github.com/eclipse-theia/theia)
- [Theia - Website](https://theia-ide.org/)
## License
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
## Trademark
"Theia" is a trademark of the Eclipse Foundation
<https://www.eclipse.org/theia>

View File

@@ -0,0 +1,25 @@
// @ts-check
// *****************************************************************************
// Copyright (C) 2021 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/** @type {{[ruleId: string]: import('eslint').Rule.RuleModule}} */
exports.rules = {
"annotation-check": require('./rules/annotation-check'),
"localization-check": require('./rules/localization-check'),
"no-src-import": require('./rules/no-src-import'),
"runtime-import-check": require('./rules/runtime-import-check'),
"shared-dependencies": require('./rules/shared-dependencies')
};

View File

@@ -0,0 +1,16 @@
{
"private": true,
"name": "@theia/eslint-plugin",
"version": "1.68.0",
"description": "Custom ESLint rules for developing Theia extensions and applications",
"main": "index.js",
"scripts": {
"afterInstall": "npm run compile",
"compile": "theiaext compile"
},
"dependencies": {
"@theia/ext-scripts": "1.68.0",
"@theia/re-exports": "1.68.0",
"js-levenshtein": "^1.1.6"
}
}

View File

@@ -0,0 +1,103 @@
// @ts-check
// *****************************************************************************
// Copyright (C) 2024 EclipseSource and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/**
* @typedef {import('@typescript-eslint/utils').TSESTree.ClassDeclaration} ClassDeclaration
* @typedef {import('@typescript-eslint/utils').TSESTree.ClassElement} ClassElement
* @typedef {import('@typescript-eslint/utils').TSESTree.Decorator} Decorator
* @typedef {import('@typescript-eslint/utils').TSESTree.MethodDefinition} MethodDefinition
* @typedef {import('@typescript-eslint/utils').TSESTree.Parameter} Parameter
* @typedef {import('estree').Node} Node
* @typedef {import('eslint').Rule.RuleModule} RuleModule
*/
/**
* Type guard to check if a ClassElement is a MethodDefinition.
* @param {ClassElement} element
* @returns {element is MethodDefinition}
*/
function isMethodDefinition(element) {
return element.type === 'MethodDefinition';
}
/** @type {RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Ensure @injectable classes have annotated constructor parameters',
},
messages: {
missingAnnotation: 'Constructor parameters in an @injectable class must be annotated with @inject, @unmanaged or @multiInject',
},
},
create(context) {
return {
/**
* @param {ClassDeclaration} node
*/
ClassDeclaration(node) {
// Check if the class has a decorator named `injectable`
const hasInjectableDecorator = node.decorators?.some(
(/** @type {Decorator} */ decorator) =>
decorator.expression.type === 'CallExpression' &&
decorator.expression.callee.type === 'Identifier' &&
decorator.expression.callee.name === 'injectable'
);
if (hasInjectableDecorator) {
// Find the constructor method within the class body
const constructor = node.body.body.find(
member =>
isMethodDefinition(member) &&
member.kind === 'constructor'
);
if (
constructor &&
// We need to re-apply 'isMethodDefinition' here because the type guard is not properly preserved
isMethodDefinition(constructor) &&
constructor.value &&
constructor.value.params.length > 0
) {
constructor.value.params.forEach(
/** @type {Parameter} */ param => {
// Check if each constructor parameter has a decorator
const hasAnnotation = param.decorators?.some(
(/** @type {Decorator} */ decorator) =>
decorator.expression.type === 'CallExpression' &&
decorator.expression.callee.type === 'Identifier' &&
(decorator.expression.callee.name === 'inject' ||
decorator.expression.callee.name === 'unmanaged' ||
decorator.expression.callee.name === 'multiInject')
);
if (!hasAnnotation) {
context.report({
node: /** @type Node */ (param),
messageId: 'missingAnnotation',
});
}
}
);
}
}
},
};
},
};

View File

@@ -0,0 +1,149 @@
// @ts-check
// *****************************************************************************
// Copyright (C) 2021 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
const levenshtein = require('js-levenshtein');
// eslint-disable-next-line import/no-extraneous-dependencies
const metadata = require('@theia/core/src/common/i18n/nls.metadata.json');
const messages = new Set(Object.values(metadata.messages)
.reduceRight((prev, curr) => prev.concat(curr), [])
.map(e => e.replace(/&&/g, '')));
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description: 'prevent incorrect use of \'nls.localize\'.',
},
},
create(context) {
return {
CallExpression(node) {
const callee = node.callee;
if (callee.type === 'Super') {
return;
}
const localizeResults = evaluateLocalize(node);
for (const { value, byDefault, node: localizeNode } of localizeResults) {
if (value !== undefined && localizeNode) {
if (byDefault && !messages.has(value)) {
let lowestDistance = Number.MAX_VALUE;
let lowestMessage = '';
for (const message of messages) {
const distance = levenshtein(value, message);
if (distance < lowestDistance) {
lowestDistance = distance;
lowestMessage = message;
}
}
if (lowestMessage) {
const replacementValue = `'${lowestMessage.replace(/'/g, "\\'").replace(/\n/g, '\\n')}'`;
context.report({
node: localizeNode,
message: `'${value}' is not a valid default value. Did you mean ${replacementValue}?`,
fix: function (fixer) {
return fixer.replaceText(localizeNode, replacementValue);
}
});
} else {
context.report({
node: localizeNode,
message: `'${value}' is not a valid default value.`
});
}
} else if (!byDefault && messages.has(value)) {
context.report({
node,
message: `'${value}' can be translated using the 'nls.localizeByDefault' function.`,
fix: function (fixer) {
const code = context.getSourceCode();
const args = node.arguments.slice(1);
const argsCode = args.map(e => code.getText(e)).join(', ');
const updatedCall = `nls.localizeByDefault(${argsCode})`;
return fixer.replaceText(node, updatedCall);
}
});
}
}
}
}
};
/**
* Evaluates a call expression and returns localization info.
* @param {import('estree').CallExpression} node
* @returns {Array<{value?: string, byDefault: boolean, node?: import('estree').Node}>}
*/
function evaluateLocalize(/** @type {import('estree').CallExpression} */ node) {
const callee = node.callee;
if ('object' in callee && 'name' in callee.object && 'property' in callee && 'name' in callee.property && callee.object.name === 'nls') {
if (callee.property.name === 'localize') {
const defaultTextNode = node.arguments[1]; // The default text node is the second argument for `nls.localize`
if (defaultTextNode && defaultTextNode.type === 'Literal' && typeof defaultTextNode.value === 'string') {
return [{
node: defaultTextNode,
value: defaultTextNode.value,
byDefault: false
}];
}
} else if (callee.property.name === 'localizeByDefault') {
const defaultTextNode = node.arguments[0]; // The default text node is the first argument for `nls.localizeByDefault`
if (defaultTextNode && defaultTextNode.type === 'Literal' && typeof defaultTextNode.value === 'string') {
return [{
node: defaultTextNode,
value: defaultTextNode.value,
byDefault: true
}];
}
}
}
// Check for Command.toDefaultLocalizedCommand
if ('object' in callee && 'name' in callee.object && 'property' in callee && 'name' in callee.property
&& callee.object.name === 'Command' && callee.property.name === 'toDefaultLocalizedCommand') {
const commandArg = node.arguments[0];
if (commandArg && commandArg.type === 'ObjectExpression') {
return extractDefaultLocalizedProperties(commandArg);
}
}
return [];
}
/**
* Extracts label and category properties from a Command object that will be passed to localizeByDefault.
* @param {import('estree').ObjectExpression} objectNode
* @returns {Array<{value?: string, byDefault: boolean, node?: import('estree').Node}>}
*/
function extractDefaultLocalizedProperties(objectNode) {
const results = [];
for (const property of objectNode.properties) {
if (property.type === 'Property' && property.key.type === 'Identifier') {
const keyName = property.key.name;
if ((keyName === 'label' || keyName === 'category') && property.value.type === 'Literal' && typeof property.value.value === 'string') {
results.push({
node: property.value,
value: property.value.value,
byDefault: true
});
}
}
}
return results;
}
}
};

View File

@@ -0,0 +1,54 @@
// @ts-check
// *****************************************************************************
// Copyright (C) 2021 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
const path = require('path');
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description: 'prevent imports from \'src\'.',
},
},
create(context) {
return {
ImportDeclaration(node) {
checkModuleImport(node.source);
},
TSExternalModuleReference(node) {
checkModuleImport(node.expression);
},
};
function checkModuleImport(node) {
const module = /** @type {string} */(node.value);
const extension = path.parse(module).ext;
const re = /^@theia\/\S+\/src\//;
if (re.test(module) && extension === '') {
context.report({
node,
message: `'${module}' should not be imported with '/src/'`,
fix: function (fixer) {
const updatedModule = `'${module.replace('/src/', '/lib/')}'`;
return fixer.replaceText(node, updatedModule);
}
});
}
}
}
};

View File

@@ -0,0 +1,119 @@
// @ts-check
// *****************************************************************************
// Copyright (C) 2021 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/* eslint-disable max-len */
const path = require('path');
/**
* Runtime-specific folders according to our coding guidelines.
*/
const folders = {
common: 'common',
browser: 'browser',
node: 'node',
electronCommon: 'electron-common',
electronBrowser: 'electron-browser',
electronNode: 'electron-node',
electronMain: 'electron-main',
};
/**
* @typedef {object} ImportRule
* @property {string[]} allowed
* @property {string[]} restricted
*/
/**
* @param {string} src
* @param {string[]} allowedFolders
* @returns {[string, ImportRule]}
*/
function allow(src, allowedFolders) {
const allowed = [src, ...allowedFolders];
const restricted = Object.values(folders).filter(folder => !allowed.includes(folder));
return [src, { allowed, restricted }];
}
/**
* Mapping of folders to the list of allowed/restricted folders to import from.
* @type {[string, ImportRule][]}
*/
const importRuleMapping = [
allow(folders.common, []),
allow(folders.browser, [folders.common]),
allow(folders.node, [folders.common]),
allow(folders.electronCommon, [folders.common]),
allow(folders.electronBrowser, [folders.electronCommon, folders.browser, folders.common]),
allow(folders.electronNode, [folders.electronCommon, folders.node, folders.common]),
allow(folders.electronMain, [folders.electronCommon, folders.node, folders.common]),
];
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description: 'prevent imports from folders meant for incompatible runtimes.',
url: 'https://github.com/eclipse-theia/theia/tree/master/doc/code-organization.md'
},
},
create(context) {
let relativeFilePath = path.relative(context.getCwd(), context.getFilename());
// Normalize the path so we only deal with forward slashes.
if (process.platform === 'win32') {
relativeFilePath = relativeFilePath.replace(/\\/g, '/');
}
// Search for a folder following our naming conventions, keep the left-most match.
// e.g. `src/electron-node/browser/node/...` should match `electron-node`
let lowestIndex = Infinity;
/** @type {ImportRule | undefined} */
let matchedImportRule;
/** @type {string | undefined} */
let matchedFolder;
for (const [folder, importRule] of importRuleMapping) {
const index = relativeFilePath.indexOf(`/${folder}/`);
if (index !== -1 && index < lowestIndex) {
matchedImportRule = importRule;
matchedFolder = folder;
lowestIndex = index;
}
}
// File doesn't follow our naming convention so we'll bail now.
if (matchedFolder === undefined) {
return {};
}
return {
ImportDeclaration(node) {
checkModuleImport(node.source);
},
TSExternalModuleReference(node) {
checkModuleImport(node.expression);
},
};
function checkModuleImport(node) {
const module = /** @type {string} */(node.value);
if (matchedImportRule.restricted.some(restricted => module.includes(`/${restricted}/`) || module.endsWith(`/${restricted}`))) {
context.report({
node,
message: `'${module}' cannot be imported in '${matchedFolder}', only '${matchedImportRule.allowed.join(', ')}' ${matchedImportRule.allowed.length === 1 ? 'is' : 'are'} allowed.`
});
}
}
},
};

Some files were not shown because too many files have changed in this diff Show More