js: Initial commit for JavaScript Native Messaging API
-- Note this code misses all the legal boilerplate; please add this as soon as possible and provide a DCO so we can merge it into master. I also removed the dist/ directory because that was not source code.
This commit is contained in:
parent
5eb261d602
commit
eef3a509fa
@ -13,4 +13,4 @@ cl Common Lisp
|
|||||||
cpp C++
|
cpp C++
|
||||||
qt Qt-Framework API
|
qt Qt-Framework API
|
||||||
python Python 2 and 3 (module name: gpg)
|
python Python 2 and 3 (module name: gpg)
|
||||||
javascript Native messaging client for the gpgme-json server.
|
js Native messaging client for the gpgme-json server.
|
||||||
|
30
lang/js/CHECKLIST
Normal file
30
lang/js/CHECKLIST
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
NativeConnection:
|
||||||
|
|
||||||
|
[X] nativeConnection: successfully sending an encrypt request,
|
||||||
|
receiving an answer
|
||||||
|
[X] nativeConnection successfull on Chromium, chrome and firefox
|
||||||
|
[ ] nativeConnection successfull on Windows, macOS, Linux
|
||||||
|
[ ] nativeConnection with delayed, multipart (> 1MB) answer
|
||||||
|
|
||||||
|
replicating Openpgpjs API:
|
||||||
|
|
||||||
|
[*] Message handling (encrypt, verify, sign)
|
||||||
|
[ ] Key handling (import/export, modifying, status queries)
|
||||||
|
[ ] Configuration handling
|
||||||
|
[ ] check for completeness
|
||||||
|
[ ] handling of differences to openpgpjs
|
||||||
|
|
||||||
|
Communication with other implementations
|
||||||
|
|
||||||
|
[ ] option to export SECRET Key into localstore used by e.g. mailvelope
|
||||||
|
|
||||||
|
Management:
|
||||||
|
[*] Define the gpgme interface
|
||||||
|
[ ] check Permissions (e.g. csp) for the different envs
|
||||||
|
[ ] agree on license
|
||||||
|
[ ] tests
|
||||||
|
|
||||||
|
|
||||||
|
Problems:
|
||||||
|
[X] gpgme-json: interactive mode vs. bytelength; filename
|
||||||
|
[X] nativeApp chokes on arrays. We will get rid of that bnativeapp anyhow
|
9
lang/js/CHECKLIST_build
Normal file
9
lang/js/CHECKLIST_build
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
- Checklist for build/install:
|
||||||
|
|
||||||
|
browsers' manifests (see README) need allowedextension added, and the path set
|
||||||
|
|
||||||
|
manifest.json/ csp needs adaption
|
||||||
|
|
||||||
|
/dist contains a current build which is used by example app.
|
||||||
|
We may either want to update it on every commit, or never at all, but not
|
||||||
|
inconsistently.
|
52
lang/js/README
Normal file
52
lang/js/README
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
This is an example app for gpgme-json.
|
||||||
|
As of now, it only encrypts a given text.
|
||||||
|
|
||||||
|
Installation
|
||||||
|
-------------
|
||||||
|
|
||||||
|
gpgmejs uses webpack, the builds can be found in dist/
|
||||||
|
(the testapplication uses that script at that location). To create a new
|
||||||
|
package, the command is npx webpack --config webpack.conf.js.
|
||||||
|
If you want a more debuggable (i.e. not minified) build, just change the mode
|
||||||
|
in webpack.conf.js.
|
||||||
|
|
||||||
|
Demo WebExtension:
|
||||||
|
As soon as a bundled webpack is in dist/ (TODO: .gitignore or not?),
|
||||||
|
the gpgmejs folder can just be included in the extensions tab of the browser in
|
||||||
|
questions (extension debug mode needs to be active). For chrome, selecting the
|
||||||
|
folder is sufficient, for firefox, the manifest.json needs to be selected.
|
||||||
|
|
||||||
|
In the browsers' nativeMessaging configuration folder a file 'gpgmejs.json'
|
||||||
|
is needed, with the following content:
|
||||||
|
|
||||||
|
(The path to the native app gpgme-json may need adaption)
|
||||||
|
|
||||||
|
Chromium:
|
||||||
|
~/.config/chromium/NativeMessagingHosts/gpgmejson.json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "gpgmejson",
|
||||||
|
"description": "This is a test application for gpgmejs",
|
||||||
|
"path": "/usr/bin/gpgme-json",
|
||||||
|
"type": "stdio",
|
||||||
|
"allowed_origins": ["chrome-extension://ExtensionIdentifier/"]
|
||||||
|
}
|
||||||
|
The ExtensionIdentifier can be seen on the chrome://extensions page, and
|
||||||
|
changes on each reinstallation. Note the slashes in allowed_origins.
|
||||||
|
|
||||||
|
|
||||||
|
Firefox:
|
||||||
|
~/.mozilla/native-messaging-hosts/gpgmejson.json
|
||||||
|
{
|
||||||
|
"name": "gpgmejson",
|
||||||
|
"description": "This is a test application for gpgmejs",
|
||||||
|
"path": "/usr/bin/gpgme-json",
|
||||||
|
"type": "stdio",
|
||||||
|
"allowed_extensions": ["ExtensionIdentifier@temporary-addon"]
|
||||||
|
}
|
||||||
|
The ExtensionIdentifier can be seen as Extension ID on the about:addons page if
|
||||||
|
addon-debugging is active. In firefox, the temporary addon is removed once
|
||||||
|
firefox exits, and the identifier will need to be changed more often.
|
||||||
|
|
||||||
|
For testing purposes, it could be a good idea to change the keyID in the
|
||||||
|
ui.html, to not having to type it every time.
|
18
lang/js/manifest.json
Normal file
18
lang/js/manifest.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 2,
|
||||||
|
|
||||||
|
"name": "gpgme-json with native Messaging",
|
||||||
|
"description": "This should be able to encrypt a text using gpgme-json",
|
||||||
|
"version": "0.1",
|
||||||
|
"content_security_policy": "default-src 'self' 'unsafe-eval' filesystem",
|
||||||
|
"browser_action": {
|
||||||
|
"default_icon": "testicon.png",
|
||||||
|
"default_title": "gpgme.js",
|
||||||
|
"default_popup": "ui.html"
|
||||||
|
},
|
||||||
|
"permissions": ["nativeMessaging", "activeTab"],
|
||||||
|
|
||||||
|
"background": {
|
||||||
|
"scripts": [ "dist/gpgmejs.bundle.js"]
|
||||||
|
}
|
||||||
|
}
|
17
lang/js/package.json
Normal file
17
lang/js/package.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "gpgmejs",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "javascript part of a nativeMessaging gnupg integration",
|
||||||
|
"main": "src/gpgmejs.js",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "",
|
||||||
|
"devDependencies": {
|
||||||
|
"webpack": "^4.3.0",
|
||||||
|
"webpack-cli": "^2.0.13"
|
||||||
|
}
|
||||||
|
}
|
76
lang/js/src/Connection.js
Normal file
76
lang/js/src/Connection.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* A connection port will be opened for each communication between gpgmejs and
|
||||||
|
* gnupg. It should be alive as long as there are additional messages to be
|
||||||
|
* expected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function Connection(){
|
||||||
|
if (!this.connection){
|
||||||
|
this.connection = connect();
|
||||||
|
this._msg = {
|
||||||
|
'always-trust': true,
|
||||||
|
// 'no-encrypt-to': false,
|
||||||
|
// 'no-compress': true,
|
||||||
|
// 'throw-keyids': false,
|
||||||
|
// 'wrap': false,
|
||||||
|
'armor': true,
|
||||||
|
'base64': false
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
this.disconnect = function () {
|
||||||
|
if (this.connection){
|
||||||
|
this.connection.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a message and resolves with the answer.
|
||||||
|
* @param {*} operation The interaction requested from gpgme
|
||||||
|
* @param {*} message A json-capable object to pass the operation details.
|
||||||
|
* TODO: _msg should contain configurable parameters
|
||||||
|
*/
|
||||||
|
this.post = function(operation, message){
|
||||||
|
let timeout = 5000;
|
||||||
|
let me = this;
|
||||||
|
if (!message || !operation){
|
||||||
|
return Promise.reject('no message'); // TBD
|
||||||
|
}
|
||||||
|
|
||||||
|
let keys = Object.keys(message);
|
||||||
|
for (let i=0; i < keys.length; i++){
|
||||||
|
let property = keys[i];
|
||||||
|
me._msg[property] = message[property];
|
||||||
|
}
|
||||||
|
me._msg['op'] = operation;
|
||||||
|
// TODO fancier checks if what we want is consistent with submitted content
|
||||||
|
return new Promise(function(resolve, reject){
|
||||||
|
me.connection.onMessage.addListener(function(msg) {
|
||||||
|
if (!msg){
|
||||||
|
reject('empty answer.');
|
||||||
|
}
|
||||||
|
if (msg.type === "error"){
|
||||||
|
reject(msg.msg);
|
||||||
|
}
|
||||||
|
resolve(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
me.connection.postMessage(me._msg);
|
||||||
|
setTimeout(
|
||||||
|
function(){
|
||||||
|
me.disconnect();
|
||||||
|
reject('Timeout');
|
||||||
|
}, timeout);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function connect(){
|
||||||
|
let connection = chrome.runtime.connectNative('gpgmejson');
|
||||||
|
if (!connection){
|
||||||
|
let msg = chrome.runtime.lastError || 'no message'; //TBD
|
||||||
|
throw(msg);
|
||||||
|
}
|
||||||
|
return connection;
|
||||||
|
};
|
187
lang/js/src/gpgmejs.js
Normal file
187
lang/js/src/gpgmejs.js
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import {Connection} from "./Connection"
|
||||||
|
|
||||||
|
export function encrypt(data, publicKeys, privateKeys, passwords=null,
|
||||||
|
sessionKey, filename, compression, armor=true, detached=false,
|
||||||
|
signature=null, returnSessionKey=false, wildcard=false, date=new Date()){
|
||||||
|
// gpgme_op_encrypt ( <-gpgme doc on this operation
|
||||||
|
// gpgme_ctx_t ctx,
|
||||||
|
// gpgme_key_t recp[],
|
||||||
|
// gpgme_encrypt_flags_t flags,
|
||||||
|
// gpgme_data_t plain,
|
||||||
|
// gpgme_data_t cipher)
|
||||||
|
// flags:
|
||||||
|
// GPGME_ENCRYPT_ALWAYS_TRUST
|
||||||
|
// GPGME_ENCRYPT_NO_ENCRYPT_TO
|
||||||
|
// GPGME_ENCRYPT_NO_COMPRESS
|
||||||
|
// GPGME_ENCRYPT_PREPARE
|
||||||
|
// GPGME_ENCRYPT_EXPECT_SIGN
|
||||||
|
// GPGME_ENCRYPT_SYMMETRIC
|
||||||
|
// GPGME_ENCRYPT_THROW_KEYIDS
|
||||||
|
// GPGME_ENCRYPT_WRAP
|
||||||
|
if (passwords !== null){
|
||||||
|
throw('Password!'); // TBD
|
||||||
|
}
|
||||||
|
|
||||||
|
let pubkeys = toKeyIdArray(publicKeys);
|
||||||
|
let privkeys = toKeyIdArray(privateKeys);
|
||||||
|
|
||||||
|
// TODO filename: data is supposed to be empty, file is provided
|
||||||
|
// TODO config compression detached signature
|
||||||
|
// TODO signature to add to the encrypted message (?) || privateKeys: signature is desired
|
||||||
|
// gpgme_op_encrypt_sign (gpgme_ctx_t ctx, gpgme_key_t recp[], gpgme_encrypt_flags_t flags, gpgme_data_t plain, gpgme_data_t cipher)
|
||||||
|
|
||||||
|
// TODO sign date overwriting implemented in gnupg?
|
||||||
|
|
||||||
|
let conn = new Connection();
|
||||||
|
if (wildcard){
|
||||||
|
// Connection.set('throw-keyids', true); TODO Connection.set not yet existant
|
||||||
|
}
|
||||||
|
return conn.post('encrypt', {
|
||||||
|
'data': data,
|
||||||
|
'keys': publicKeys,
|
||||||
|
'armor': armor});
|
||||||
|
};
|
||||||
|
|
||||||
|
export function decrypt(message, privateKeys, passwords, sessionKeys, publicKeys,
|
||||||
|
format='utf8', signature=null, date=new Date()) {
|
||||||
|
if (passwords !== null){
|
||||||
|
throw('Password!'); // TBD
|
||||||
|
}
|
||||||
|
if (format === 'binary'){
|
||||||
|
// Connection.set('base64', true);
|
||||||
|
}
|
||||||
|
if (publicKeys || signature){
|
||||||
|
// Connection.set('signature', signature);
|
||||||
|
// request verification, too
|
||||||
|
}
|
||||||
|
//privateKeys optionally if keyId was thrown?
|
||||||
|
// gpgme_op_decrypt (gpgme_ctx_t ctx, gpgme_data_t cipher, gpgme_data_t plain)
|
||||||
|
// response is gpgme_op_decrypt_result (gpgme_ctx_t ctx) (next available?)
|
||||||
|
return conn.post('decrypt', {
|
||||||
|
'data': message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// BIG TODO.
|
||||||
|
export function generateKey({userIds=[], passphrase, numBits=2048, unlocked=false, keyExpirationTime=0, curve="", date=new Date()}){
|
||||||
|
throw('not implemented here');
|
||||||
|
// gpgme_op_createkey (gpgme_ctx_t ctx, const char *userid, const char *algo, unsigned long reserved, unsigned long expires, gpgme_key_t extrakey, unsigned int flags);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sign({ data, privateKeys, armor=true, detached=false, date=new Date() }) {
|
||||||
|
//TODO detached GPGME_SIG_MODE_DETACH | GPGME_SIG_MODE_NORMAL
|
||||||
|
// gpgme_op_sign (gpgme_ctx_t ctx, gpgme_data_t plain, gpgme_data_t sig, gpgme_sig_mode_t mode)
|
||||||
|
// TODO date not supported
|
||||||
|
|
||||||
|
let conn = new Connection();
|
||||||
|
let privkeys = toKeyIdArray(privateKeys);
|
||||||
|
return conn.post('sign', {
|
||||||
|
'data': data,
|
||||||
|
'keys': privkeys,
|
||||||
|
'armor': armor});
|
||||||
|
};
|
||||||
|
|
||||||
|
export function verify({ message, publicKeys, signature=null, date=new Date() }) {
|
||||||
|
//TODO extra signature: sig, signed_text, plain: null
|
||||||
|
// inline sig: signed_text:null, plain as writable (?)
|
||||||
|
// date not supported
|
||||||
|
//gpgme_op_verify (gpgme_ctx_t ctx, gpgme_data_t sig, gpgme_data_t signed_text, gpgme_data_t plain)
|
||||||
|
let conn = new Connection();
|
||||||
|
let privkeys = toKeyIdArray(privateKeys);
|
||||||
|
return conn.post('sign', {
|
||||||
|
'data': data,
|
||||||
|
'keys': privkeys,
|
||||||
|
'armor': armor});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function reformatKey(privateKey, userIds=[], passphrase="", unlocked=false, keyExpirationTime=0){
|
||||||
|
let privKey = toKeyIdArray(privateKey);
|
||||||
|
if (privKey.length !== 1){
|
||||||
|
return false; //TODO some error handling. There is not exactly ONE key we are editing
|
||||||
|
}
|
||||||
|
let conn = new Connection();
|
||||||
|
// TODO key management needs to be changed somewhat
|
||||||
|
return conn.post('TODO', {
|
||||||
|
'key': privKey[0],
|
||||||
|
'keyExpirationTime': keyExpirationTime, //TODO check if this is 0 or a positive and plausible number
|
||||||
|
'userIds': userIds //TODO check if empty or plausible strings
|
||||||
|
});
|
||||||
|
// unlocked will be ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptKey({ privateKey, passphrase }) {
|
||||||
|
throw('not implemented here');
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function encryptKey({ privateKey, passphrase }) {
|
||||||
|
throw('not implemented here');
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function encryptSessionKey({data, algorithm, publicKeys, passwords, wildcard=false }) {
|
||||||
|
//openpgpjs:
|
||||||
|
// Encrypt a symmetric session key with public keys, passwords, or both at
|
||||||
|
// once. At least either public keys or passwords must be specified.
|
||||||
|
throw('not implemented here');
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function decryptSessionKeys({ message, privateKeys, passwords }) {
|
||||||
|
throw('not implemented here');
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// //TODO worker handling
|
||||||
|
|
||||||
|
// //TODO key representation
|
||||||
|
// //TODO: keyring handling
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper functions and checks
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the submitted value is a keyID.
|
||||||
|
* TODO: should accept all strings that are accepted as keyID by gnupg
|
||||||
|
* TODO: See if Key becomes an object later on
|
||||||
|
* @param {*} key input value. Is expected to be a string of 8,16 or 40 chars
|
||||||
|
* representing hex values. Will return false if that expectation is not met
|
||||||
|
*/
|
||||||
|
function isKeyId(key){
|
||||||
|
if (!key || typeof(key) !== "string"){
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ([8,16,40].indexOf(key.length) < 0){
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let regexp= /^[0-9a-fA-F]*$/i;
|
||||||
|
return regexp.test(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to return an array of keyID values, either from a string or an array.
|
||||||
|
* Filters out those that do not meet the criteria. (TODO: silently for now)
|
||||||
|
* @param {*} array Input value.
|
||||||
|
*/
|
||||||
|
function toKeyIdArray(array){
|
||||||
|
let result = [];
|
||||||
|
if (!array){
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(array)){
|
||||||
|
if (isKeyId(array) === true){
|
||||||
|
return [keyId];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (let i=0; i < array.length; i++){
|
||||||
|
if (isKeyId(array[i]) === true){
|
||||||
|
result.push(array[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
14
lang/js/src/index.js
Normal file
14
lang/js/src/index.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import * as gpgmejs from'./gpgmejs'
|
||||||
|
export default gpgmejs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export each high level api function separately.
|
||||||
|
* Usage:
|
||||||
|
*
|
||||||
|
* import { encryptMessage } from 'gpgme.js'
|
||||||
|
* encryptMessage(keys, text)
|
||||||
|
*/
|
||||||
|
export {
|
||||||
|
encrypt, decrypt, sign, verify,
|
||||||
|
generateKey, reformatKey
|
||||||
|
} from './gpgmejs';
|
21
lang/js/testapplication.js
Normal file
21
lang/js/testapplication.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Testing nativeMessaging. This is a temporary plugin using the gpgmejs
|
||||||
|
implemetation as contained in src/
|
||||||
|
*/
|
||||||
|
function buttonclicked(event){
|
||||||
|
let data = document.getElementById("text0").value;
|
||||||
|
let keyId = document.getElementById("key").value;
|
||||||
|
let enc = Gpgmejs.encrypt(data, [keyId]).then(function(answer){
|
||||||
|
console.log(answer);
|
||||||
|
console.log(answer.type);
|
||||||
|
console.log(answer.data);
|
||||||
|
alert(answer.data);
|
||||||
|
}, function(errormsg){
|
||||||
|
alert('Error: '+ errormsg);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
document.getElementById("button0").addEventListener("click",
|
||||||
|
buttonclicked);
|
||||||
|
});
|
BIN
lang/js/testicon.png
Normal file
BIN
lang/js/testicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
10
lang/js/ui.css
Normal file
10
lang/js/ui.css
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
ul {
|
||||||
|
list-style-type: none;
|
||||||
|
padding-left: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li span {
|
||||||
|
float: left;
|
||||||
|
width: 120px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
24
lang/js/ui.html
Normal file
24
lang/js/ui.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link rel="stylesheet" href="ui.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!--TODO: replace this mess with require -->
|
||||||
|
<script src="dist/gpgmejs.bundle.js"></script>
|
||||||
|
<script src="testapplication.js"></script>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<span class="label">Text: </span>
|
||||||
|
<input type="text" id='text0' />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="label">Public key ID: </span>
|
||||||
|
<input type="text" id="key" value="Your Public Key ID here" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button id="button0">Encrypt</button><br>
|
||||||
|
<div id="answer"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
13
lang/js/webpack.conf.js
Normal file
13
lang/js/webpack.conf.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entry: './src/index.js',
|
||||||
|
// mode: 'development',
|
||||||
|
mode: 'production',
|
||||||
|
output: {
|
||||||
|
path: path.resolve(__dirname, 'dist'),
|
||||||
|
filename: 'gpgmejs.bundle.js',
|
||||||
|
libraryTarget: 'var',
|
||||||
|
library: 'Gpgmejs'
|
||||||
|
}
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user