build: allow use of BUILDFLAG directives from within JS code (#20328)
This commit is contained in:
parent
f9c04449f4
commit
392ea320cf
16 changed files with 132 additions and 74 deletions
|
@ -27,7 +27,11 @@
|
||||||
"sourceType": "module"
|
"sourceType": "module"
|
||||||
},
|
},
|
||||||
"globals": {
|
"globals": {
|
||||||
"standardScheme": "readonly"
|
"standardScheme": "readonly",
|
||||||
|
"BUILDFLAG": "readonly",
|
||||||
|
"ENABLE_DESKTOP_CAPTURER": "readonly",
|
||||||
|
"ENABLE_REMOTE_MODULE": "readonly",
|
||||||
|
"ENABLE_VIEWS_API": "readonly"
|
||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
|
|
|
@ -20,6 +20,54 @@ class AccessDependenciesPlugin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defines = {
|
||||||
|
BUILDFLAG: ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildFlagsPrefix = '--buildflags='
|
||||||
|
const buildFlagArg = process.argv.find(arg => arg.startsWith(buildFlagsPrefix));
|
||||||
|
|
||||||
|
if (buildFlagArg) {
|
||||||
|
const buildFlagPath = buildFlagArg.substr(buildFlagsPrefix.length)
|
||||||
|
|
||||||
|
const flagFile = fs.readFileSync(buildFlagPath, 'utf8')
|
||||||
|
for (const line of flagFile.split(/(\r\n|\r|\n)/g)) {
|
||||||
|
const flagMatch = line.match(/#define BUILDFLAG_INTERNAL_(.+?)\(\) \(([01])\)/)
|
||||||
|
if (flagMatch) {
|
||||||
|
const flagName = flagMatch[1];
|
||||||
|
const flagValue = flagMatch[2];
|
||||||
|
defines[flagName] = JSON.stringify(Boolean(parseInt(flagValue, 10)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ignoredModules = []
|
||||||
|
|
||||||
|
if (defines['ENABLE_DESKTOP_CAPTURER'] === 'false') {
|
||||||
|
ignoredModules.push(
|
||||||
|
'@electron/internal/browser/desktop-capturer',
|
||||||
|
'@electron/internal/renderer/api/desktop-capturer'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defines['ENABLE_REMOTE_MODULE'] === 'false') {
|
||||||
|
ignoredModules.push(
|
||||||
|
'@electron/internal/browser/remote/server',
|
||||||
|
'@electron/internal/renderer/api/remote'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defines['ENABLE_VIEWS_API'] === 'false') {
|
||||||
|
ignoredModules.push(
|
||||||
|
'@electron/internal/browser/api/views/image-view.js'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const alias = {}
|
||||||
|
for (const ignoredModule of ignoredModules) {
|
||||||
|
alias[ignoredModule] = path.resolve(electronRoot, 'lib/common/dummy.js')
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = ({
|
module.exports = ({
|
||||||
alwaysHasNode,
|
alwaysHasNode,
|
||||||
loadElectronFromAlternateTarget,
|
loadElectronFromAlternateTarget,
|
||||||
|
@ -41,6 +89,7 @@ module.exports = ({
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
...alias,
|
||||||
'@electron/internal': path.resolve(electronRoot, 'lib'),
|
'@electron/internal': path.resolve(electronRoot, 'lib'),
|
||||||
'electron': path.resolve(electronRoot, 'lib', loadElectronFromAlternateTarget || target, 'api', 'exports', 'electron.ts'),
|
'electron': path.resolve(electronRoot, 'lib', loadElectronFromAlternateTarget || target, 'api', 'exports', 'electron.ts'),
|
||||||
// Force timers to resolve to our dependency that doens't use window.postMessage
|
// Force timers to resolve to our dependency that doens't use window.postMessage
|
||||||
|
@ -78,6 +127,7 @@ module.exports = ({
|
||||||
new webpack.ProvidePlugin({
|
new webpack.ProvidePlugin({
|
||||||
Promise: ['@electron/internal/common/webpack-globals-provider', 'Promise'],
|
Promise: ['@electron/internal/common/webpack-globals-provider', 'Promise'],
|
||||||
}),
|
}),
|
||||||
|
new webpack.DefinePlugin(defines),
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,9 @@ template("webpack_build") {
|
||||||
args = [
|
args = [
|
||||||
rebase_path(invoker.config_file),
|
rebase_path(invoker.config_file),
|
||||||
rebase_path(invoker.out_file),
|
rebase_path(invoker.out_file),
|
||||||
|
"--buildflags=" + rebase_path("$target_gen_dir/buildflags/buildflags.h"),
|
||||||
]
|
]
|
||||||
|
deps += [ "buildflags" ]
|
||||||
|
|
||||||
outputs = [ invoker.out_file ]
|
outputs = [ invoker.out_file ]
|
||||||
}
|
}
|
||||||
|
|
|
@ -135,7 +135,7 @@ auto_filenames = {
|
||||||
]
|
]
|
||||||
|
|
||||||
sandbox_bundle_deps = [
|
sandbox_bundle_deps = [
|
||||||
"lib/browser/api/module-keys.js",
|
"lib/browser/api/module-names.ts",
|
||||||
"lib/common/api/clipboard.js",
|
"lib/common/api/clipboard.js",
|
||||||
"lib/common/api/deprecate.ts",
|
"lib/common/api/deprecate.ts",
|
||||||
"lib/common/api/module-list.ts",
|
"lib/common/api/module-list.ts",
|
||||||
|
@ -258,7 +258,7 @@ auto_filenames = {
|
||||||
]
|
]
|
||||||
|
|
||||||
renderer_bundle_deps = [
|
renderer_bundle_deps = [
|
||||||
"lib/browser/api/module-keys.js",
|
"lib/browser/api/module-names.ts",
|
||||||
"lib/common/api/clipboard.js",
|
"lib/common/api/clipboard.js",
|
||||||
"lib/common/api/deprecate.ts",
|
"lib/common/api/deprecate.ts",
|
||||||
"lib/common/api/module-list.ts",
|
"lib/common/api/module-list.ts",
|
||||||
|
@ -300,7 +300,7 @@ auto_filenames = {
|
||||||
]
|
]
|
||||||
|
|
||||||
worker_bundle_deps = [
|
worker_bundle_deps = [
|
||||||
"lib/browser/api/module-keys.js",
|
"lib/browser/api/module-names.ts",
|
||||||
"lib/common/api/clipboard.js",
|
"lib/common/api/clipboard.js",
|
||||||
"lib/common/api/deprecate.ts",
|
"lib/common/api/deprecate.ts",
|
||||||
"lib/common/api/module-list.ts",
|
"lib/common/api/module-list.ts",
|
||||||
|
|
|
@ -1,47 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// TODO: Figure out a way to not duplicate this information between here and module-list
|
|
||||||
// It is currently duplicated as module-list "require"s all the browser API file and the
|
|
||||||
// remote module in the renderer process depends on that file. As a result webpack
|
|
||||||
// includes all the browser API files in the renderer process as well and we want to avoid that
|
|
||||||
|
|
||||||
const features = process.electronBinding('features');
|
|
||||||
|
|
||||||
// Browser side modules, please sort alphabetically.
|
|
||||||
module.exports = [
|
|
||||||
{ name: 'app' },
|
|
||||||
{ name: 'autoUpdater' },
|
|
||||||
{ name: 'BrowserView' },
|
|
||||||
{ name: 'BrowserWindow' },
|
|
||||||
{ name: 'contentTracing' },
|
|
||||||
{ name: 'crashReporter' },
|
|
||||||
{ name: 'dialog' },
|
|
||||||
{ name: 'globalShortcut' },
|
|
||||||
{ name: 'ipcMain' },
|
|
||||||
{ name: 'inAppPurchase' },
|
|
||||||
{ name: 'Menu' },
|
|
||||||
{ name: 'MenuItem' },
|
|
||||||
{ name: 'nativeTheme' },
|
|
||||||
{ name: 'net' },
|
|
||||||
{ name: 'netLog' },
|
|
||||||
{ name: 'MessageChannelMain' },
|
|
||||||
{ name: 'Notification' },
|
|
||||||
{ name: 'powerMonitor' },
|
|
||||||
{ name: 'powerSaveBlocker' },
|
|
||||||
{ name: 'protocol' },
|
|
||||||
{ name: 'screen' },
|
|
||||||
{ name: 'session' },
|
|
||||||
{ name: 'systemPreferences' },
|
|
||||||
{ name: 'TopLevelWindow' },
|
|
||||||
{ name: 'TouchBar' },
|
|
||||||
{ name: 'Tray' },
|
|
||||||
{ name: 'View' },
|
|
||||||
{ name: 'webContents' },
|
|
||||||
{ name: 'WebContentsView' }
|
|
||||||
];
|
|
||||||
|
|
||||||
if (features.isViewApiEnabled()) {
|
|
||||||
module.exports.push(
|
|
||||||
{ name: 'ImageView' }
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,7 +1,5 @@
|
||||||
// TODO: Updating this file also required updating the module-keys file
|
// TODO: Updating this file also required updating the module-keys file
|
||||||
|
|
||||||
const features = process.electronBinding('features');
|
|
||||||
|
|
||||||
// Browser side modules, please sort alphabetically.
|
// Browser side modules, please sort alphabetically.
|
||||||
export const browserModuleList: ElectronInternal.ModuleEntry[] = [
|
export const browserModuleList: ElectronInternal.ModuleEntry[] = [
|
||||||
{ name: 'app', loader: () => require('./app') },
|
{ name: 'app', loader: () => require('./app') },
|
||||||
|
@ -35,7 +33,7 @@ export const browserModuleList: ElectronInternal.ModuleEntry[] = [
|
||||||
{ name: 'WebContentsView', loader: () => require('./web-contents-view') }
|
{ name: 'WebContentsView', loader: () => require('./web-contents-view') }
|
||||||
];
|
];
|
||||||
|
|
||||||
if (features.isViewApiEnabled()) {
|
if (BUILDFLAG(ENABLE_VIEWS_API)) {
|
||||||
browserModuleList.push(
|
browserModuleList.push(
|
||||||
{ name: 'ImageView', loader: () => require('./views/image-view') }
|
{ name: 'ImageView', loader: () => require('./views/image-view') }
|
||||||
);
|
);
|
||||||
|
|
43
lib/browser/api/module-names.ts
Normal file
43
lib/browser/api/module-names.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
// TODO: Figure out a way to not duplicate this information between here and module-list
|
||||||
|
// It is currently duplicated as module-list "require"s all the browser API file and the
|
||||||
|
// remote module in the renderer process depends on that file. As a result webpack
|
||||||
|
// includes all the browser API files in the renderer process as well and we want to avoid that
|
||||||
|
|
||||||
|
// Browser side modules, please sort alphabetically.
|
||||||
|
export const browserModuleNames = [
|
||||||
|
'app',
|
||||||
|
'autoUpdater',
|
||||||
|
'BrowserView',
|
||||||
|
'BrowserWindow',
|
||||||
|
'contentTracing',
|
||||||
|
'crashReporter',
|
||||||
|
'dialog',
|
||||||
|
'globalShortcut',
|
||||||
|
'ipcMain',
|
||||||
|
'inAppPurchase',
|
||||||
|
'Menu',
|
||||||
|
'MenuItem',
|
||||||
|
'nativeTheme',
|
||||||
|
'net',
|
||||||
|
'netLog',
|
||||||
|
'MessageChannelMain',
|
||||||
|
'Notification',
|
||||||
|
'powerMonitor',
|
||||||
|
'powerSaveBlocker',
|
||||||
|
'protocol',
|
||||||
|
'screen',
|
||||||
|
'session',
|
||||||
|
'systemPreferences',
|
||||||
|
'TopLevelWindow',
|
||||||
|
'TouchBar',
|
||||||
|
'Tray',
|
||||||
|
'View',
|
||||||
|
'webContents',
|
||||||
|
'WebContentsView'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (BUILDFLAG(ENABLE_VIEWS_API)) {
|
||||||
|
browserModuleNames.push(
|
||||||
|
'ImageView'
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,6 +1,5 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const features = process.electronBinding('features');
|
|
||||||
const { EventEmitter } = require('events');
|
const { EventEmitter } = require('events');
|
||||||
const electron = require('electron');
|
const electron = require('electron');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
@ -341,7 +340,7 @@ WebContents.prototype.printToPDF = function (options) {
|
||||||
printSettings.scaleFactor = Math.ceil(printSettings.scaleFactor) % 100;
|
printSettings.scaleFactor = Math.ceil(printSettings.scaleFactor) % 100;
|
||||||
// PrinterType enum from //printing/print_job_constants.h
|
// PrinterType enum from //printing/print_job_constants.h
|
||||||
printSettings.printerType = 2;
|
printSettings.printerType = 2;
|
||||||
if (features.isPrintingEnabled()) {
|
if (this._printToPDF) {
|
||||||
return this._printToPDF(printSettings);
|
return this._printToPDF(printSettings);
|
||||||
} else {
|
} else {
|
||||||
const error = new Error('Printing feature is disabled');
|
const error = new Error('Printing feature is disabled');
|
||||||
|
@ -375,7 +374,7 @@ WebContents.prototype.print = function (options = {}, callback) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (features.isPrintingEnabled()) {
|
if (this._print) {
|
||||||
if (callback) {
|
if (callback) {
|
||||||
this._print(options, callback);
|
this._print(options, callback);
|
||||||
} else {
|
} else {
|
||||||
|
@ -387,7 +386,7 @@ WebContents.prototype.print = function (options = {}, callback) {
|
||||||
};
|
};
|
||||||
|
|
||||||
WebContents.prototype.getPrinters = function () {
|
WebContents.prototype.getPrinters = function () {
|
||||||
if (features.isPrintingEnabled()) {
|
if (this._getPrinters) {
|
||||||
return this._getPrinters();
|
return this._getPrinters();
|
||||||
} else {
|
} else {
|
||||||
console.error('Error: Printing feature is disabled.');
|
console.error('Error: Printing feature is disabled.');
|
||||||
|
|
|
@ -157,12 +157,10 @@ app._setDefaultAppPaths(packagePath);
|
||||||
// Load the chrome devtools support.
|
// Load the chrome devtools support.
|
||||||
require('@electron/internal/browser/devtools');
|
require('@electron/internal/browser/devtools');
|
||||||
|
|
||||||
const features = process.electronBinding('features');
|
|
||||||
|
|
||||||
// Load the chrome extension support.
|
// Load the chrome extension support.
|
||||||
require('@electron/internal/browser/chrome-extension-shim');
|
require('@electron/internal/browser/chrome-extension-shim');
|
||||||
|
|
||||||
if (features.isRemoteModuleEnabled()) {
|
if (BUILDFLAG(ENABLE_REMOTE_MODULE)) {
|
||||||
require('@electron/internal/browser/remote/server');
|
require('@electron/internal/browser/remote/server');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,6 @@ const fs = require('fs');
|
||||||
|
|
||||||
const eventBinding = process.electronBinding('event');
|
const eventBinding = process.electronBinding('event');
|
||||||
const clipboard = process.electronBinding('clipboard');
|
const clipboard = process.electronBinding('clipboard');
|
||||||
const features = process.electronBinding('features');
|
|
||||||
|
|
||||||
const { ipcMainInternal } = require('@electron/internal/browser/ipc-main-internal');
|
const { ipcMainInternal } = require('@electron/internal/browser/ipc-main-internal');
|
||||||
const ipcMainUtils = require('@electron/internal/browser/ipc-main-internal-utils');
|
const ipcMainUtils = require('@electron/internal/browser/ipc-main-internal-utils');
|
||||||
|
@ -60,7 +59,7 @@ ipcMainUtils.handleSync('ELECTRON_BROWSER_CLIPBOARD_SYNC', function (event, meth
|
||||||
return typeUtils.serialize(electron.clipboard[method](...typeUtils.deserialize(args)));
|
return typeUtils.serialize(electron.clipboard[method](...typeUtils.deserialize(args)));
|
||||||
});
|
});
|
||||||
|
|
||||||
if (features.isDesktopCapturerEnabled()) {
|
if (BUILDFLAG(ENABLE_DESKTOP_CAPTURER)) {
|
||||||
const desktopCapturer = require('@electron/internal/browser/desktop-capturer');
|
const desktopCapturer = require('@electron/internal/browser/desktop-capturer');
|
||||||
|
|
||||||
ipcMainInternal.handle('ELECTRON_BROWSER_DESKTOP_CAPTURER_GET_SOURCES', function (event, options, stack) {
|
ipcMainInternal.handle('ELECTRON_BROWSER_DESKTOP_CAPTURER_GET_SOURCES', function (event, options, stack) {
|
||||||
|
@ -76,7 +75,7 @@ if (features.isDesktopCapturerEnabled()) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRemoteModuleEnabled = features.isRemoteModuleEnabled()
|
const isRemoteModuleEnabled = BUILDFLAG(ENABLE_REMOTE_MODULE)
|
||||||
? require('@electron/internal/browser/remote/server').isRemoteModuleEnabled
|
? require('@electron/internal/browser/remote/server').isRemoteModuleEnabled
|
||||||
: () => false;
|
: () => false;
|
||||||
|
|
||||||
|
|
0
lib/common/dummy.js
Normal file
0
lib/common/dummy.js
Normal file
|
@ -1,4 +1,3 @@
|
||||||
const features = process.electronBinding('features');
|
|
||||||
const v8Util = process.electronBinding('v8_util');
|
const v8Util = process.electronBinding('v8_util');
|
||||||
|
|
||||||
const enableRemoteModule = v8Util.getHiddenValue<boolean>(global, 'enableRemoteModule');
|
const enableRemoteModule = v8Util.getHiddenValue<boolean>(global, 'enableRemoteModule');
|
||||||
|
@ -11,10 +10,16 @@ export const rendererModuleList: ElectronInternal.ModuleEntry[] = [
|
||||||
{ name: 'webFrame', loader: () => require('./web-frame') }
|
{ name: 'webFrame', loader: () => require('./web-frame') }
|
||||||
];
|
];
|
||||||
|
|
||||||
if (features.isDesktopCapturerEnabled()) {
|
if (BUILDFLAG(ENABLE_DESKTOP_CAPTURER)) {
|
||||||
rendererModuleList.push({ name: 'desktopCapturer', loader: () => require('./desktop-capturer') });
|
rendererModuleList.push({
|
||||||
|
name: 'desktopCapturer',
|
||||||
|
loader: () => require('@electron/internal/renderer/api/desktop-capturer')
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (features.isRemoteModuleEnabled() && enableRemoteModule) {
|
if (BUILDFLAG(ENABLE_REMOTE_MODULE) && enableRemoteModule) {
|
||||||
rendererModuleList.push({ name: 'remote', loader: () => require('./remote') });
|
rendererModuleList.push({
|
||||||
|
name: 'remote',
|
||||||
|
loader: () => require('@electron/internal/renderer/api/remote')
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -354,7 +354,9 @@ const addBuiltinProperty = (name) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const { commonModuleList } = require('@electron/internal/common/api/module-list');
|
const { commonModuleList } = require('@electron/internal/common/api/module-list');
|
||||||
const browserModules = commonModuleList.concat(require('@electron/internal/browser/api/module-keys'));
|
const { browserModuleNames } = require('@electron/internal/browser/api/module-names');
|
||||||
|
|
||||||
|
const browserModules = commonModuleList.concat(browserModuleNames.map(name => ({ name })));
|
||||||
|
|
||||||
// And add a helper receiver for each one.
|
// And add a helper receiver for each one.
|
||||||
browserModules
|
browserModules
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
const features = process.electronBinding('features');
|
|
||||||
|
|
||||||
export const moduleList: ElectronInternal.ModuleEntry[] = [
|
export const moduleList: ElectronInternal.ModuleEntry[] = [
|
||||||
{
|
{
|
||||||
name: 'contextBridge',
|
name: 'contextBridge',
|
||||||
|
@ -29,14 +27,14 @@ export const moduleList: ElectronInternal.ModuleEntry[] = [
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
if (features.isDesktopCapturerEnabled()) {
|
if (BUILDFLAG(ENABLE_DESKTOP_CAPTURER)) {
|
||||||
moduleList.push({
|
moduleList.push({
|
||||||
name: 'desktopCapturer',
|
name: 'desktopCapturer',
|
||||||
loader: () => require('@electron/internal/renderer/api/desktop-capturer')
|
loader: () => require('@electron/internal/renderer/api/desktop-capturer')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (features.isRemoteModuleEnabled() && process.isRemoteModuleEnabled) {
|
if (BUILDFLAG(ENABLE_REMOTE_MODULE) && process.isRemoteModuleEnabled) {
|
||||||
moduleList.push({
|
moduleList.push({
|
||||||
name: 'remote',
|
name: 'remote',
|
||||||
loader: () => require('@electron/internal/renderer/api/remote')
|
loader: () => require('@electron/internal/renderer/api/remote')
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
#include "electron/buildflags/buildflags.h"
|
#include "electron/buildflags/buildflags.h"
|
||||||
#include "shell/common/gin_converters/content_converter.h"
|
#include "shell/common/gin_converters/content_converter.h"
|
||||||
#include "shell/common/gin_converters/gurl_converter.h"
|
#include "shell/common/gin_converters/gurl_converter.h"
|
||||||
|
#include "shell/common/gin_converters/std_converter.h"
|
||||||
#include "shell/common/gin_helper/dictionary.h"
|
#include "shell/common/gin_helper/dictionary.h"
|
||||||
#include "shell/common/node_includes.h"
|
#include "shell/common/node_includes.h"
|
||||||
#include "url/origin.h"
|
#include "url/origin.h"
|
||||||
|
|
6
typings/internal-ambient.d.ts
vendored
6
typings/internal-ambient.d.ts
vendored
|
@ -1,5 +1,11 @@
|
||||||
declare var internalBinding: any;
|
declare var internalBinding: any;
|
||||||
|
|
||||||
|
declare const BUILDFLAG: (flag: boolean) => boolean;
|
||||||
|
|
||||||
|
declare const ENABLE_DESKTOP_CAPTURER: boolean;
|
||||||
|
declare const ENABLE_REMOTE_MODULE: boolean;
|
||||||
|
declare const ENABLE_VIEWS_API: boolean;
|
||||||
|
|
||||||
declare namespace NodeJS {
|
declare namespace NodeJS {
|
||||||
interface FeaturesBinding {
|
interface FeaturesBinding {
|
||||||
isBuiltinSpellCheckerEnabled(): boolean;
|
isBuiltinSpellCheckerEnabled(): boolean;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue