Merge remote-tracking branch 'upstream/clearnet' into message-sending-refactor

pull/1166/head
Audric Ackermann 5 years ago
commit da1edab63d
No known key found for this signature in database
GPG Key ID: 999F434D76324AD4

@ -124,7 +124,7 @@
};
// require for PoW to work
const setClockParams = async () => {
window.setClockParams = async () => {
// Set server-client time difference
const maxTimeDifferential = 30 + 15; // + 15 for onion requests
const timeDifferential = await getTimeDifferential();
@ -133,5 +133,5 @@
window.clientClockSynced = Math.abs(timeDifferential) < maxTimeDifferential;
return window.clientClockSynced;
};
setClockParams();
window.setClockParams();
})();

@ -1914,8 +1914,13 @@
toastOptions.id = 'expiredWarning';
}
if (!window.clientClockSynced) {
// Check to see if user has updated their clock to current time
const clockSynced = await window.LokiPublicChatAPI.setClockParams();
let clockSynced = false;
if (window.setClockParams) {
// Check to see if user has updated their clock to current time
clockSynced = await window.setClockParams();
} else {
window.log.info('setClockParams not loaded yet');
}
if (clockSynced) {
toastOptions.title = i18n('clockOutOfSync');
toastOptions.id = 'clockOutOfSync';

@ -474,6 +474,8 @@ if (
};
/* eslint-enable global-require, import/no-extraneous-dependencies */
window.lokiFeatureFlags = {};
// eslint-disable-next-line global-require
window.StubLokiSnodeAPI = require('./integration_test/stubs/stub_loki_snode_api');
window.lokiSnodeAPI = new window.StubLokiSnodeAPI(); // no need stub out each function here
}
if (config.environment.includes('test-integration')) {

@ -6,6 +6,7 @@ import { MessageSender } from '../sending';
import { RawMessage } from '../types/RawMessage';
import { EncryptionType } from '../types/EncryptionType';
import { TextEncoder } from 'util';
import * as MessageUtils from '../utils';
interface StringToNumberMap {
[key: string]: number;
@ -117,21 +118,9 @@ export class SessionProtocol {
// so we know we already triggered a new session with that device
SessionProtocol.pendingSendSessionsTimestamp.add(device);
// FIXME to remove
function toRawMessage(m: any): RawMessage {
return {
identifier: 'identifier',
plainTextBuffer: new TextEncoder().encode('jk'),
timestamp: Date.now(),
device: 'device',
ttl: 10,
encryption: EncryptionType.SessionReset,
};
}
try {
// TODO: Send out the request via MessageSender
const rawMessage = toRawMessage(message);
const rawMessage = MessageUtils.toRawMessage(device, message);
await MessageSender.send(rawMessage);
await SessionProtocol.updateSentSessionTimestamp(device, timestamp);
} catch (e) {

@ -1,10 +1,10 @@
import {
ClosedGroupMessage,
ContentMessage,
OpenGroupMessage,
} from '../messages/outgoing';
import { RawMessage } from '../types/RawMessage';
import { TypedEventEmitter } from '../utils';
import { ClosedGroupMessage } from '../messages/outgoing/content/data/group';
type GroupMessageType = OpenGroupMessage | ClosedGroupMessage;

@ -5,7 +5,7 @@ import { OpenGroupMessage } from '../messages/outgoing';
import { SignalService } from '../../protobuf';
import { UserUtil } from '../../util';
import { MessageEncrypter } from '../crypto';
import { lokiMessageAPI, lokiPublicChatAPI, textsecure } from '../../window';
import { lokiMessageAPI, lokiPublicChatAPI } from '../../window';
// ================ Regular ================

@ -1,36 +1,135 @@
import { RawMessage } from '../types/RawMessage';
import { createOrUpdateItem, getItemById } from '../../../js/modules/data';
import { PartialRawMessage, RawMessage } from '../types/RawMessage';
import { ContentMessage } from '../messages/outgoing';
import { PubKey } from '../types';
import * as MessageUtils from '../utils';
// TODO: We should be able to import functions straight from the db here without going through the window object
// This is an abstraction for storing pending messages.
// Ideally we want to store pending messages in the database so that
// on next launch we can re-send the pending messages, but we don't want
// to constantly fetch pending messages from the database.
// Thus we have an intermediary cache which will store pending messagesin
// memory and sync its state with the database on modification (add or remove).
export class PendingMessageCache {
private readonly cachedMessages: Array<RawMessage> = [];
public readonly isReady: Promise<boolean>;
private cache: Array<RawMessage>;
constructor() {
// TODO: We should load pending messages from db here
// Load pending messages from the database
// You should await isReady on making a new PendingMessageCache
// if you'd like to have instant access to the cache
this.cache = [];
this.isReady = new Promise(async resolve => {
await this.loadFromDB();
resolve(true);
});
}
public getAllPending(): Array<RawMessage> {
// Get all pending from cache, sorted with oldest first
return [...this.cache].sort((a, b) => a.timestamp - b.timestamp);
}
public getForDevice(device: PubKey): Array<RawMessage> {
return this.getAllPending().filter(m => m.device === device.key);
}
public getDevices(): Array<PubKey> {
// Gets all unique devices with pending messages
const pubkeyStrings = [...new Set(this.cache.map(m => m.device))];
return pubkeyStrings.map(PubKey.from).filter((k): k is PubKey => !!k);
}
public addPendingMessage(
device: string,
public async add(
device: PubKey,
message: ContentMessage
): RawMessage {
// TODO: Maybe have a util for converting OutgoingContentMessage to RawMessage?
// TODO: Raw message has uuid, how are we going to set that? maybe use a different identifier?
// One could be device + timestamp would make a unique identifier
// TODO: Return previous pending message if it exists
return {} as RawMessage;
): Promise<RawMessage> {
const rawMessage = MessageUtils.toRawMessage(device, message);
// Does it exist in cache already?
if (this.find(rawMessage)) {
return rawMessage;
}
this.cache.push(rawMessage);
await this.saveToDB();
return rawMessage;
}
public async remove(
message: RawMessage
): Promise<Array<RawMessage> | undefined> {
// Should only be called after message is processed
// Return if message doesn't exist in cache
if (!this.find(message)) {
return;
}
// Remove item from cache and sync with database
const updatedCache = this.cache.filter(
m => m.identifier !== message.identifier
);
this.cache = updatedCache;
await this.saveToDB();
return updatedCache;
}
public find(message: RawMessage): RawMessage | undefined {
// Find a message in the cache
return this.cache.find(
m => m.device === message.device && m.timestamp === message.timestamp
);
}
public removePendingMessage(message: RawMessage) {
// TODO: implement
public async clear() {
// Clears the cache and syncs to DB
this.cache = [];
await this.saveToDB();
}
public getPendingDevices(): Array<String> {
// TODO: this should return all devices which have pending messages
return [];
public async loadFromDB() {
const messages = await this.getFromStorage();
this.cache = messages;
}
public getPendingMessages(device: string): Array<RawMessage> {
return [];
private async getFromStorage(): Promise<Array<RawMessage>> {
const data = await getItemById('pendingMessages');
if (!data || !data.value) {
return [];
}
const barePending = JSON.parse(String(data.value)) as Array<
PartialRawMessage
>;
// Rebuild plainTextBuffer
return barePending.map((message: PartialRawMessage) => {
return {
...message,
plainTextBuffer: new Uint8Array(message.plainTextBuffer),
} as RawMessage;
});
}
private async saveToDB() {
// For each plainTextBuffer in cache, save in as a simple Array<number> to avoid
// Node issues with JSON stringifying Buffer without strict typing
const encodedCache = [...this.cache].map(item => {
const plainTextBuffer = Array.from(item.plainTextBuffer);
return { ...item, plainTextBuffer };
});
const encodedPendingMessages = JSON.stringify(encodedCache) || '[]';
await createOrUpdateItem({
id: 'pendingMessages',
value: encodedPendingMessages,
});
}
}

@ -0,0 +1,28 @@
export class PubKey {
public static readonly PUBKEY_LEN = 66;
private static readonly regex: string = `^05[0-9a-fA-F]{${PubKey.PUBKEY_LEN -
2}}$`;
public readonly key: string;
constructor(pubkeyString: string) {
PubKey.validate(pubkeyString);
this.key = pubkeyString;
}
public static from(pubkeyString: string): PubKey | undefined {
// Returns a new instance if the pubkey is valid
if (PubKey.validate(pubkeyString)) {
return new PubKey(pubkeyString);
}
return undefined;
}
public static validate(pubkeyString: string): boolean {
if (pubkeyString.match(PubKey.regex)) {
return true;
}
return false;
}
}

@ -10,3 +10,13 @@ export interface RawMessage {
ttl: number;
encryption: EncryptionType;
}
// For building RawMessages from JSON
export interface PartialRawMessage {
identifier: string;
plainTextBuffer: any;
timestamp: number;
device: string;
ttl: number;
encryption: number;
}

@ -0,0 +1,3 @@
export * from './EncryptionType';
export * from './RawMessage';
export * from './PubKey';

@ -0,0 +1,24 @@
import { RawMessage } from '../types/RawMessage';
import { ContentMessage } from '../messages/outgoing';
import { EncryptionType, PubKey } from '../types';
export function toRawMessage(
device: PubKey,
message: ContentMessage
): RawMessage {
const ttl = message.ttl();
const timestamp = message.timestamp;
const plainTextBuffer = message.plainTextBuffer();
// tslint:disable-next-line: no-unnecessary-local-variable
const rawMessage: RawMessage = {
identifier: message.identifier,
plainTextBuffer,
timestamp,
device: device.key,
ttl,
encryption: EncryptionType.Signal,
};
return rawMessage;
}

@ -1,2 +1,3 @@
export * from './TypedEmitter';
export * from './JobQueue';
export * from './Messages';

@ -0,0 +1,179 @@
import { expect } from 'chai';
import * as crypto from 'crypto';
import * as sinon from 'sinon';
import { toNumber } from 'lodash';
import { MessageSender } from '../../../session/sending';
import LokiMessageAPI from '../../../../js/modules/loki_message_api';
import { TestUtils } from '../../test-utils';
import { UserUtil } from '../../../util';
import { MessageEncrypter } from '../../../session/crypto';
import { SignalService } from '../../../protobuf';
import LokiPublicChatFactoryAPI from '../../../../js/modules/loki_public_chat_api';
import { OpenGroupMessage } from '../../../session/messages/outgoing';
import { LokiPublicChannelAPI } from '../../../../js/modules/loki_app_dot_net_api';
import { EncryptionType } from '../../../session/types/EncryptionType';
describe('MessageSender', () => {
const sandbox = sinon.createSandbox();
afterEach(() => {
sandbox.restore();
TestUtils.restoreStubs();
});
describe('send', () => {
const ourNumber = 'ourNumber';
let lokiMessageAPIStub: sinon.SinonStubbedInstance<LokiMessageAPI>;
let messageEncyrptReturnEnvelopeType =
SignalService.Envelope.Type.CIPHERTEXT;
beforeEach(() => {
// We can do this because LokiMessageAPI has a module export in it
lokiMessageAPIStub = sandbox.createStubInstance(LokiMessageAPI, {
sendMessage: sandbox.stub(),
});
TestUtils.stubWindow('lokiMessageAPI', lokiMessageAPIStub);
sandbox.stub(UserUtil, 'getCurrentDevicePubKey').resolves(ourNumber);
sandbox
.stub(MessageEncrypter, 'encrypt')
.callsFake(async (_device, plainTextBuffer, _type) => ({
envelopeType: messageEncyrptReturnEnvelopeType,
cipherText: plainTextBuffer,
}));
});
it('should pass the correct values to lokiMessageAPI', async () => {
const device = '0';
const timestamp = Date.now();
const ttl = 100;
await MessageSender.send({
identifier: '1',
device,
plainTextBuffer: crypto.randomBytes(10),
encryption: EncryptionType.Signal,
timestamp,
ttl,
});
const args = lokiMessageAPIStub.sendMessage.getCall(0).args;
expect(args[0]).to.equal(device);
expect(args[2]).to.equal(timestamp);
expect(args[3]).to.equal(ttl);
});
it('should correctly build the envelope', async () => {
messageEncyrptReturnEnvelopeType = SignalService.Envelope.Type.CIPHERTEXT;
// This test assumes the encryption stub returns the plainText passed into it.
const plainTextBuffer = crypto.randomBytes(10);
const timestamp = Date.now();
await MessageSender.send({
identifier: '1',
device: '0',
plainTextBuffer,
encryption: EncryptionType.Signal,
timestamp,
ttl: 1,
});
const data = lokiMessageAPIStub.sendMessage.getCall(0).args[1];
const webSocketMessage = SignalService.WebSocketMessage.decode(data);
expect(webSocketMessage.request?.body).to.not.equal(
undefined,
'Request body should not be undefined'
);
expect(webSocketMessage.request?.body).to.not.equal(
null,
'Request body should not be null'
);
const envelope = SignalService.Envelope.decode(
webSocketMessage.request?.body as Uint8Array
);
expect(envelope.type).to.equal(SignalService.Envelope.Type.CIPHERTEXT);
expect(envelope.source).to.equal(ourNumber);
expect(envelope.sourceDevice).to.equal(1);
expect(toNumber(envelope.timestamp)).to.equal(timestamp);
expect(envelope.content).to.deep.equal(plainTextBuffer);
});
describe('UNIDENTIFIED_SENDER', () => {
it('should set the envelope source to be empty', async () => {
messageEncyrptReturnEnvelopeType =
SignalService.Envelope.Type.UNIDENTIFIED_SENDER;
// This test assumes the encryption stub returns the plainText passed into it.
const plainTextBuffer = crypto.randomBytes(10);
const timestamp = Date.now();
await MessageSender.send({
identifier: '1',
device: '0',
plainTextBuffer,
encryption: EncryptionType.Signal,
timestamp,
ttl: 1,
});
const data = lokiMessageAPIStub.sendMessage.getCall(0).args[1];
const webSocketMessage = SignalService.WebSocketMessage.decode(data);
expect(webSocketMessage.request?.body).to.not.equal(
undefined,
'Request body should not be undefined'
);
expect(webSocketMessage.request?.body).to.not.equal(
null,
'Request body should not be null'
);
const envelope = SignalService.Envelope.decode(
webSocketMessage.request?.body as Uint8Array
);
expect(envelope.type).to.equal(
SignalService.Envelope.Type.UNIDENTIFIED_SENDER
);
expect(envelope.source).to.equal(
'',
'envelope source should be empty in UNIDENTIFIED_SENDER'
);
});
});
});
describe('sendToOpenGroup', () => {
it('should send the message to the correct server and channel', async () => {
// We can do this because LokiPublicChatFactoryAPI has a module export in it
const stub = sandbox.createStubInstance(LokiPublicChatFactoryAPI, {
findOrCreateChannel: sandbox.stub().resolves({
sendMessage: sandbox.stub(),
} as LokiPublicChannelAPI) as any,
});
TestUtils.stubWindow('lokiPublicChatAPI', stub);
const group = {
server: 'server',
channel: 1,
conversationId: '0',
};
const message = new OpenGroupMessage({
timestamp: Date.now(),
group,
});
await MessageSender.sendToOpenGroup(message);
const [
server,
channel,
conversationId,
] = stub.findOrCreateChannel.getCall(0).args;
expect(server).to.equal(group.server);
expect(channel).to.equal(group.channel);
expect(conversationId).to.equal(group.conversationId);
});
});
});

@ -0,0 +1,259 @@
import { expect } from 'chai';
import * as _ from 'lodash';
import * as MessageUtils from '../../../session/utils';
import { TestUtils } from '../../../test/test-utils';
import { PendingMessageCache } from '../../../session/sending/PendingMessageCache';
// Equivalent to Data.StorageItem
interface StorageItem {
id: string;
value: any;
}
describe('PendingMessageCache', () => {
// Initialize new stubbed cache
let data: StorageItem;
let pendingMessageCacheStub: PendingMessageCache;
beforeEach(async () => {
// Stub out methods which touch the database
const storageID = 'pendingMessages';
data = {
id: storageID,
value: '[]',
};
TestUtils.stubData('getItemById')
.withArgs('pendingMessages')
.callsFake(async () => {
return data;
});
TestUtils.stubData('createOrUpdateItem').callsFake((item: StorageItem) => {
if (item.id === storageID) {
data = item;
}
});
pendingMessageCacheStub = new PendingMessageCache();
await pendingMessageCacheStub.isReady;
});
afterEach(() => {
TestUtils.restoreStubs();
});
it('can initialize cache', async () => {
const cache = pendingMessageCacheStub.getAllPending();
// We expect the cache to initialise as an empty array
expect(cache).to.be.instanceOf(Array);
expect(cache).to.have.length(0);
});
it('can add to cache', async () => {
const device = TestUtils.generateFakePubkey();
const message = TestUtils.generateUniqueChatMessage();
const rawMessage = MessageUtils.toRawMessage(device, message);
await pendingMessageCacheStub.add(device, message);
// Verify that the message is in the cache
const finalCache = pendingMessageCacheStub.getAllPending();
expect(finalCache).to.have.length(1);
const addedMessage = finalCache[0];
expect(addedMessage.device).to.deep.equal(rawMessage.device);
expect(addedMessage.timestamp).to.deep.equal(rawMessage.timestamp);
});
it('can remove from cache', async () => {
const device = TestUtils.generateFakePubkey();
const message = TestUtils.generateUniqueChatMessage();
const rawMessage = MessageUtils.toRawMessage(device, message);
await pendingMessageCacheStub.add(device, message);
const initialCache = pendingMessageCacheStub.getAllPending();
expect(initialCache).to.have.length(1);
// Remove the message
await pendingMessageCacheStub.remove(rawMessage);
const finalCache = pendingMessageCacheStub.getAllPending();
// Verify that the message was removed
expect(finalCache).to.have.length(0);
});
it('can get devices', async () => {
const cacheItems = [
{
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
{
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
{
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
];
cacheItems.forEach(async item => {
await pendingMessageCacheStub.add(item.device, item.message);
});
const cache = pendingMessageCacheStub.getAllPending();
expect(cache).to.have.length(cacheItems.length);
// Get list of devices
const devicesKeys = cacheItems.map(item => item.device.key);
const pulledDevices = pendingMessageCacheStub.getDevices();
const pulledDevicesKeys = pulledDevices.map(d => d.key);
// Verify that device list from cache is equivalent to devices added
expect(pulledDevicesKeys).to.have.members(devicesKeys);
});
it('can get pending for device', async () => {
const cacheItems = [
{
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
{
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
];
cacheItems.forEach(async item => {
await pendingMessageCacheStub.add(item.device, item.message);
});
const initialCache = pendingMessageCacheStub.getAllPending();
expect(initialCache).to.have.length(cacheItems.length);
// Get pending for each specific device
cacheItems.forEach(item => {
const pendingForDevice = pendingMessageCacheStub.getForDevice(
item.device
);
expect(pendingForDevice).to.have.length(1);
expect(pendingForDevice[0].device).to.equal(item.device.key);
});
});
it('can find nothing when empty', async () => {
const device = TestUtils.generateFakePubkey();
const message = TestUtils.generateUniqueChatMessage();
const rawMessage = MessageUtils.toRawMessage(device, message);
const foundMessage = pendingMessageCacheStub.find(rawMessage);
expect(foundMessage, 'a message was found in empty cache').to.be.undefined;
});
it('can find message in cache', async () => {
const device = TestUtils.generateFakePubkey();
const message = TestUtils.generateUniqueChatMessage();
const rawMessage = MessageUtils.toRawMessage(device, message);
await pendingMessageCacheStub.add(device, message);
const finalCache = pendingMessageCacheStub.getAllPending();
expect(finalCache).to.have.length(1);
const foundMessage = pendingMessageCacheStub.find(rawMessage);
expect(foundMessage, 'message not found in cache').to.be.ok;
foundMessage && expect(foundMessage.device).to.equal(device.key);
});
it('can clear cache', async () => {
const cacheItems = [
{
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
{
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
{
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
];
cacheItems.forEach(async item => {
await pendingMessageCacheStub.add(item.device, item.message);
});
const initialCache = pendingMessageCacheStub.getAllPending();
expect(initialCache).to.have.length(cacheItems.length);
// Clear cache
await pendingMessageCacheStub.clear();
const finalCache = pendingMessageCacheStub.getAllPending();
expect(finalCache).to.have.length(0);
});
it('can restore from db', async () => {
const cacheItems = [
{
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
{
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
{
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
];
cacheItems.forEach(async item => {
await pendingMessageCacheStub.add(item.device, item.message);
});
const addedMessages = pendingMessageCacheStub.getAllPending();
expect(addedMessages).to.have.length(cacheItems.length);
// Rebuild from DB
const freshCache = new PendingMessageCache();
await freshCache.isReady;
// Verify messages
const rebuiltMessages = freshCache.getAllPending();
rebuiltMessages.forEach((message, index) => {
const addedMessage = addedMessages[index];
// Pull out plainTextBuffer for a separate check
const buffersCompare =
Buffer.compare(
message.plainTextBuffer,
addedMessage.plainTextBuffer
) === 0;
expect(buffersCompare).to.equal(
true,
'buffers were not loaded properly from database'
);
// Compare all other valures
const trimmedAdded = _.omit(addedMessage, ['plainTextBuffer']);
const trimmedRebuilt = _.omit(message, ['plainTextBuffer']);
expect(_.isEqual(trimmedAdded, trimmedRebuilt)).to.equal(
true,
'cached messages were not rebuilt properly'
);
});
});
});

@ -45,7 +45,11 @@ describe('JobQueue', () => {
30,
]);
const timeTaken = Date.now() - start;
assert.closeTo(timeTaken, 600, 50, 'Queue was delayed');
assert.isAtLeast(
timeTaken,
600,
'Queue should take atleast 600ms to run.'
);
});
it('should return the result of the job', async () => {

@ -1,7 +1,12 @@
import * as sinon from 'sinon';
import { ImportMock } from 'ts-mock-imports';
import * as DataShape from '../../../js/modules/data';
import * as crypto from 'crypto';
import * as window from '../../window';
import * as DataShape from '../../../js/modules/data';
import { v4 as uuid } from 'uuid';
import { ImportMock } from 'ts-mock-imports';
import { PubKey } from '../../../ts/session/types';
import { ChatMessage } from '../../session/messages/outgoing';
const sandbox = sinon.createSandbox();
@ -40,3 +45,25 @@ export function restoreStubs() {
ImportMock.restore();
sandbox.restore();
}
export function generateFakePubkey(): PubKey {
// Generates a mock pubkey for testing
const numBytes = PubKey.PUBKEY_LEN / 2 - 1;
const hexBuffer = crypto.randomBytes(numBytes).toString('hex');
const pubkeyString = `05${hexBuffer}`;
return new PubKey(pubkeyString);
}
export function generateUniqueChatMessage(): ChatMessage {
return new ChatMessage({
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
identifier: uuid(),
timestamp: Date.now(),
attachments: undefined,
quote: undefined,
expireTimer: undefined,
lokiProfile: undefined,
preview: undefined,
});
}

@ -82,7 +82,6 @@
// Modifying:
// React components and namespaces are Pascal case
"variable-name": [true, "allow-pascal-case"],
"variable-name": [
true,
"check-format",
@ -90,7 +89,16 @@
"allow-pascal-case"
],
"function-name": [true, { "function-regex": "^_?[a-z][\\w\\d]+$" }],
"function-name": [
true,
{
"function-regex": "^[a-z][\\w\\d]+$",
"method-regex": "^[a-z][\\w\\d]+$",
"private-method-regex": "^[a-z][\\w\\d]+$",
"protected-method-regex": "^[a-z][\\w\\d]+$",
"static-method-regex": "^[a-zA-Z][\\w\\d]+$"
}
],
// Adding select dev dependencies here for now, may turn on all in the future
"no-implicit-dependencies": [true, ["dashdash", "electron"]],

Loading…
Cancel
Save