queue user profile avatars update

also add some tests for the promise utils
pull/2242/head
Audric Ackermann 2 years ago
parent a9cc9a7294
commit 00d70db0be
No known key found for this signature in database
GPG Key ID: 999F434D76324AD4

@ -29,6 +29,7 @@
<script>
var exports = {};
</script>
<script type="text/javascript" src="js/components.js"></script>
<script type="text/javascript" src="js/libtextsecure.js"></script>
</head>

@ -85,6 +85,7 @@
"p-retry": "^4.2.0",
"pify": "3.0.0",
"protobufjs": "^6.11.2",
"queue-promise": "^2.2.1",
"rc-slider": "^8.7.1",
"react": "^17.0.2",
"react-contexify": "5.0.0",

@ -1,13 +0,0 @@
import React from 'react';
export const LoadingIndicator = () => {
return (
<div className="loading-widget">
<div className="container">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
</div>
);
};

@ -139,7 +139,7 @@ function openAndMigrateDatabase(filePath: string, key: string) {
// First, we try to open the database without any cipher changes
try {
db = new BetterSqlite3.default(filePath, openDbOptions);
db = new (BetterSqlite3 as any).default(filePath, openDbOptions);
keyDatabase(db, key);
switchToWAL(db);
@ -159,7 +159,7 @@ function openAndMigrateDatabase(filePath: string, key: string) {
let db1;
try {
db1 = new BetterSqlite3.default(filePath, openDbOptions);
db1 = new (BetterSqlite3 as any).default(filePath, openDbOptions);
keyDatabase(db1, key);
// https://www.zetetic.net/blog/2018/11/30/sqlcipher-400-release/#compatability-sqlcipher-4-0-0
@ -177,7 +177,7 @@ function openAndMigrateDatabase(filePath: string, key: string) {
// migrate to the latest ciphers after we've modified the defaults.
let db2;
try {
db2 = new BetterSqlite3.default(filePath, openDbOptions);
db2 = new (BetterSqlite3 as any).default(filePath, openDbOptions);
keyDatabase(db2, key);
db2.pragma('cipher_migrate');

@ -14,15 +14,14 @@ import { configurationMessageReceived, trigger } from '../shims/events';
import { BlockedNumberController } from '../util';
import { removeFromCache } from './cache';
import { handleNewClosedGroup } from './closedGroups';
import { updateProfileOneAtATime } from './dataMessage';
import { EnvelopePlus } from './types';
import { ConversationInteraction } from '../interactions';
import { getLastProfileUpdateTimestamp, setLastProfileUpdateTimestamp } from '../util/storage';
import { appendFetchAvatarAndProfileJob, updateOurProfileSync } from './userProfileImageUpdates';
async function handleOurProfileUpdate(
sentAt: number | Long,
configMessage: SignalService.ConfigurationMessage,
ourPubkey: string
configMessage: SignalService.ConfigurationMessage
) {
const latestProfileUpdateTimestamp = getLastProfileUpdateTimestamp();
if (!latestProfileUpdateTimestamp || sentAt > latestProfileUpdateTimestamp) {
@ -31,17 +30,11 @@ async function handleOurProfileUpdate(
);
const { profileKey, profilePicture, displayName } = configMessage;
const ourConversation = getConversationController().get(ourPubkey);
if (!ourConversation) {
window?.log?.error('We need a convo with ourself at all times');
return;
}
const lokiProfile = {
displayName,
profilePicture,
};
await updateProfileOneAtATime(ourConversation, lokiProfile, profileKey);
await updateOurProfileSync(lokiProfile, profileKey);
await setLastProfileUpdateTimestamp(_.toNumber(sentAt));
// do not trigger a signin by linking if the display name is empty
if (displayName) {
@ -192,7 +185,7 @@ const handleContactFromConfig = async (
await BlockedNumberController.unblock(contactConvo.id);
}
void updateProfileOneAtATime(contactConvo, profile, contactReceived.profileKey);
void appendFetchAvatarAndProfileJob(contactConvo, profile, contactReceived.profileKey);
} catch (e) {
window?.log?.warn('failed to handle a new closed group from configuration message');
}
@ -213,7 +206,7 @@ export async function handleConfigurationMessage(
return removeFromCache(envelope);
}
await handleOurProfileUpdate(envelope.timestamp, configurationMessage, ourPubkey);
await handleOurProfileUpdate(envelope.timestamp, configurationMessage);
await handleGroupsAndContactsFromConfigMessage(envelope, configurationMessage);

@ -5,7 +5,6 @@ import { getEnvelopeId } from './common';
import { PubKey } from '../session/types';
import { handleMessageJob, toRegularMessage } from './queuedJob';
import { downloadAttachment } from './attachments';
import _ from 'lodash';
import { StringUtils, UserUtils } from '../session/utils';
import { getConversationController } from '../session/conversations';
@ -15,104 +14,15 @@ import {
getMessageBySenderAndServerTimestamp,
} from '../../ts/data/data';
import { ConversationModel, ConversationTypeEnum } from '../models/conversation';
import { allowOnlyOneAtATime } from '../session/utils/Promise';
import { toHex } from '../session/utils/String';
import { toLogFormat } from '../types/attachments/Errors';
import { processNewAttachment } from '../types/MessageAttachment';
import { MIME } from '../types';
import { autoScaleForIncomingAvatar } from '../util/attachmentsUtil';
import {
createSwarmMessageSentFromNotUs,
createSwarmMessageSentFromUs,
} from '../models/messageFactory';
import { MessageModel } from '../models/message';
import { isUsFromCache } from '../session/utils/User';
import { decryptProfile } from '../util/crypto/profileEncrypter';
import ByteBuffer from 'bytebuffer';
export async function updateProfileOneAtATime(
conversation: ConversationModel,
profile: SignalService.DataMessage.ILokiProfile,
profileKey?: Uint8Array | null // was any
) {
if (!conversation?.id) {
window?.log?.warn('Cannot update profile with empty convoid');
return;
}
const oneAtaTimeStr = `updateProfileOneAtATime:${conversation.id}`;
return allowOnlyOneAtATime(oneAtaTimeStr, async () => {
return createOrUpdateProfile(conversation, profile, profileKey);
});
}
/**
* Creates a new profile from the profile provided. Creates the profile if it doesn't exist.
*/
async function createOrUpdateProfile(
conversation: ConversationModel,
profile: SignalService.DataMessage.ILokiProfile,
profileKey?: Uint8Array | null
) {
// Retain old values unless changed:
const newProfile = conversation.get('profile') || {};
newProfile.displayName = profile.displayName;
if (profile.profilePicture && profileKey) {
const prevPointer = conversation.get('avatarPointer');
const needsUpdate = !prevPointer || !_.isEqual(prevPointer, profile.profilePicture);
if (needsUpdate) {
try {
const downloaded = await downloadAttachment({
url: profile.profilePicture,
isRaw: true,
});
// null => use placeholder with color and first letter
let path = null;
if (profileKey) {
// Convert profileKey to ArrayBuffer, if needed
const encoding = typeof profileKey === 'string' ? 'base64' : null;
try {
const profileKeyArrayBuffer = dcodeIO.ByteBuffer.wrap(
profileKey,
encoding
).toArrayBuffer();
const decryptedData = await decryptProfile(downloaded.data, profileKeyArrayBuffer);
const scaledData = await autoScaleForIncomingAvatar(decryptedData);
const upgraded = await processNewAttachment({
data: await scaledData.blob.arrayBuffer(),
contentType: MIME.IMAGE_UNKNOWN, // contentType is mostly used to generate previews and screenshot. We do not care for those in this case.
});
// Only update the convo if the download and decrypt is a success
conversation.set('avatarPointer', profile.profilePicture);
conversation.set('profileKey', toHex(profileKey));
({ path } = upgraded);
} catch (e) {
window?.log?.error(`Could not decrypt profile image: ${e}`);
}
}
newProfile.avatar = path;
} catch (e) {
window.log.warn(
`Failed to download attachment at ${profile.profilePicture}. Maybe it expired? ${e.message}`
);
// do not return here, we still want to update the display name even if the avatar failed to download
}
}
} else if (profileKey) {
newProfile.avatar = null;
}
const conv = await getConversationController().getOrCreateAndWait(
conversation.id,
ConversationTypeEnum.PRIVATE
);
await conv.setLokiProfile(newProfile);
await conv.commit();
}
import { appendFetchAvatarAndProfileJob } from './userProfileImageUpdates';
function cleanAttachment(attachment: any) {
return {
@ -303,7 +213,7 @@ export async function handleSwarmDataMessage(
cleanDataMessage.profileKey?.length
) {
// do not await this
void updateProfileOneAtATime(
void appendFetchAvatarAndProfileJob(
senderConversationModel,
cleanDataMessage.profile,
cleanDataMessage.profileKey

@ -8,13 +8,13 @@ import { ConversationModel, ConversationTypeEnum } from '../models/conversation'
import { MessageModel } from '../models/message';
import { getMessageById, getMessageCountByType, getMessagesBySentAt } from '../../ts/data/data';
import { updateProfileOneAtATime } from './dataMessage';
import { SignalService } from '../protobuf';
import { UserUtils } from '../session/utils';
import { showMessageRequestBanner } from '../state/ducks/userConfig';
import { MessageDirection } from '../models/messageType';
import { LinkPreviews } from '../util/linkPreviews';
import { GoogleChrome } from '../util';
import { appendFetchAvatarAndProfileJob } from './userProfileImageUpdates';
function contentTypeSupported(type: string): boolean {
const Chrome = GoogleChrome;
@ -295,7 +295,7 @@ async function handleRegularMessage(
// the only profile we don't update with what is coming here is ours,
// as our profile is shared accross our devices with a ConfigurationMessage
if (type === 'incoming' && rawDataMessage.profile) {
void updateProfileOneAtATime(
void appendFetchAvatarAndProfileJob(
sendingDeviceConversation,
rawDataMessage.profile,
rawDataMessage.profileKey

@ -0,0 +1,157 @@
import Queue from 'queue-promise';
import ByteBuffer from 'bytebuffer';
import _ from 'lodash';
import { downloadAttachment } from './attachments';
import { allowOnlyOneAtATime, hasAlreadyOneAtaTimeMatching } from '../session/utils/Promise';
import { toHex } from '../session/utils/String';
import { processNewAttachment } from '../types/MessageAttachment';
import { MIME } from '../types';
import { autoScaleForIncomingAvatar } from '../util/attachmentsUtil';
import { decryptProfile } from '../util/crypto/profileEncrypter';
import { ConversationModel, ConversationTypeEnum } from '../models/conversation';
import { SignalService } from '../protobuf';
import { getConversationController } from '../session/conversations';
import { UserUtils } from '../session/utils';
const queue = new Queue({
concurrent: 1,
interval: 500,
});
queue.on('dequeue', () => {
// window.log.info('[profile-update] queue is dequeuing');
});
queue.on('resolve', () => {
// window.log.info('[profile-update] task resolved');
});
queue.on('reject', error => {
window.log.warn('[profile-update] task profile image update failed with', error);
});
queue.on('start', () => {
window.log.info('[profile-update] queue is starting');
});
queue.on('stop', () => {
window.log.info('[profile-update] queue is stopping');
});
queue.on('end', () => {
window.log.info('[profile-update] queue is ending');
});
export async function appendFetchAvatarAndProfileJob(
conversation: ConversationModel,
profile: SignalService.DataMessage.ILokiProfile,
profileKey?: Uint8Array | null // was any
) {
if (!conversation?.id) {
window?.log?.warn('[profile-update] Cannot update profile with empty convoid');
return;
}
const oneAtaTimeStr = `appendFetchAvatarAndProfileJob:${conversation.id}`;
if (hasAlreadyOneAtaTimeMatching(oneAtaTimeStr)) {
window.log.info(
'[profile-update] not adding another task of "appendFetchAvatarAndProfileJob" as there is already one scheduled for the conversation: ',
conversation.id
);
return;
}
window.log.info(
'[profile-update] "appendFetchAvatarAndProfileJob" as there is already one scheduled for the conversation: ',
conversation.id
);
const task = allowOnlyOneAtATime(oneAtaTimeStr, async () => {
return createOrUpdateProfile(conversation, profile, profileKey);
});
queue.enqueue(async () => task);
}
/**
* This function should be used only when we have to do a sync update to our conversation with a new profile/avatar image or display name
* It tries to fetch the profile image, scale it, save it, and update the conversationModel
*/
export async function updateOurProfileSync(
profile: SignalService.DataMessage.ILokiProfile,
profileKey?: Uint8Array | null // was any
) {
const ourConvo = getConversationController().get(UserUtils.getOurPubKeyStrFromCache());
if (!ourConvo?.id) {
window?.log?.warn('[profile-update] Cannot update our profile with empty convoid');
return;
}
const oneAtaTimeStr = `appendFetchAvatarAndProfileJob:${ourConvo.id}`;
return allowOnlyOneAtATime(oneAtaTimeStr, async () => {
return createOrUpdateProfile(ourConvo, profile, profileKey);
});
}
/**
* Creates a new profile from the profile provided. Creates the profile if it doesn't exist.
*/
async function createOrUpdateProfile(
conversation: ConversationModel,
profile: SignalService.DataMessage.ILokiProfile,
profileKey?: Uint8Array | null
) {
// Retain old values unless changed:
const newProfile = conversation.get('profile') || {};
newProfile.displayName = profile.displayName;
if (profile.profilePicture && profileKey) {
const prevPointer = conversation.get('avatarPointer');
const needsUpdate = !prevPointer || !_.isEqual(prevPointer, profile.profilePicture);
if (needsUpdate) {
try {
const downloaded = await downloadAttachment({
url: profile.profilePicture,
isRaw: true,
});
// null => use placeholder with color and first letter
let path = null;
if (profileKey) {
// Convert profileKey to ArrayBuffer, if needed
const encoding = typeof profileKey === 'string' ? 'base64' : undefined;
try {
const profileKeyArrayBuffer = ByteBuffer.wrap(profileKey, encoding).toArrayBuffer();
const decryptedData = await decryptProfile(downloaded.data, profileKeyArrayBuffer);
window.log.info(
`[profile-update] about to auto scale avatar for convo ${conversation.id}`
);
const scaledData = await autoScaleForIncomingAvatar(decryptedData);
const upgraded = await processNewAttachment({
data: await scaledData.blob.arrayBuffer(),
contentType: MIME.IMAGE_UNKNOWN, // contentType is mostly used to generate previews and screenshot. We do not care for those in this case.
});
// Only update the convo if the download and decrypt is a success
conversation.set('avatarPointer', profile.profilePicture);
conversation.set('profileKey', toHex(profileKey));
({ path } = upgraded);
} catch (e) {
window?.log?.error(`[profile-update] Could not decrypt profile image: ${e}`);
}
}
newProfile.avatar = path;
} catch (e) {
window.log.warn(
`[profile-update] Failed to download attachment at ${profile.profilePicture}. Maybe it expired? ${e.message}`
);
// do not return here, we still want to update the display name even if the avatar failed to download
}
}
} else if (profileKey) {
newProfile.avatar = null;
}
const conv = await getConversationController().getOrCreateAndWait(
conversation.id,
ConversationTypeEnum.PRIVATE
);
await conv.setLokiProfile(newProfile);
await conv.commit();
}

@ -143,9 +143,8 @@ export const buildUrl = (request: FileServerV2Request | OpenGroupV2Request): URL
};
/**
* Upload a file to the file server v2
* @param fileContent the data to send
* @returns null or the fileID and complete URL to share this file
* Fetch the latest desktop release available on github from the fileserver.
* This call is onion routed and so do not expose our ip to github nor the file server.
*/
export const getLatestDesktopReleaseFileToFsV2 = async (): Promise<string | null> => {
const queryParams = {

@ -15,7 +15,7 @@ export class TaskTimedOutError extends Error {
}
// one action resolves all
const snodeGlobalLocks: Record<string, Promise<any>> = {};
const oneAtaTimeRecord: Record<string, Promise<any>> = {};
export async function allowOnlyOneAtATime(
name: string,
@ -23,16 +23,16 @@ export async function allowOnlyOneAtATime(
timeoutMs?: number
) {
// if currently not in progress
if (snodeGlobalLocks[name] === undefined) {
if (oneAtaTimeRecord[name] === undefined) {
// set lock
snodeGlobalLocks[name] = new Promise(async (resolve, reject) => {
oneAtaTimeRecord[name] = new Promise(async (resolve, reject) => {
// set up timeout feature
let timeoutTimer = null;
if (timeoutMs) {
timeoutTimer = setTimeout(() => {
window?.log?.warn(`allowOnlyOneAtATime - TIMEDOUT after ${timeoutMs}s`);
window?.log?.warn(`allowOnlyOneAtATime - TIMEDOUT after ${timeoutMs}ms`);
// tslint:disable-next-line: no-dynamic-delete
delete snodeGlobalLocks[name]; // clear lock
delete oneAtaTimeRecord[name]; // clear lock
reject();
}, timeoutMs);
}
@ -55,7 +55,7 @@ export async function allowOnlyOneAtATime(
}
}
// tslint:disable-next-line: no-dynamic-delete
delete snodeGlobalLocks[name]; // clear lock
delete oneAtaTimeRecord[name]; // clear lock
reject(e);
}
// clear timeout timer
@ -66,12 +66,16 @@ export async function allowOnlyOneAtATime(
}
}
// tslint:disable-next-line: no-dynamic-delete
delete snodeGlobalLocks[name]; // clear lock
delete oneAtaTimeRecord[name]; // clear lock
// release the kraken
resolve(innerRetVal);
});
}
return snodeGlobalLocks[name];
return oneAtaTimeRecord[name];
}
export function hasAlreadyOneAtaTimeMatching(text: string): boolean {
return Boolean(oneAtaTimeRecord[text]);
}
/**

@ -7,6 +7,13 @@ import { PromiseUtils } from '../../../../session/utils';
// tslint:disable-next-line: no-require-imports no-var-requires
import chaiAsPromised from 'chai-as-promised';
import {
allowOnlyOneAtATime,
hasAlreadyOneAtaTimeMatching,
sleepFor,
} from '../../../../session/utils/Promise';
import { TestUtils } from '../../../test-utils';
chai.use(chaiAsPromised as any);
chai.should();
@ -34,6 +41,7 @@ describe('Promise Utils', () => {
pollSpy = sandbox.spy(PromiseUtils, 'poll');
waitForTaskSpy = sandbox.spy(PromiseUtils, 'waitForTask');
waitUntilSpy = sandbox.spy(PromiseUtils, 'waitUntil');
TestUtils.stubWindowLog();
});
afterEach(() => {
@ -141,4 +149,68 @@ describe('Promise Utils', () => {
return promise.should.eventually.be.rejectedWith('Periodic check timeout');
});
});
describe('allowOnlyOneAtATime', () => {
it('start if not running', async () => {
const spy = sinon.spy(async () => {
return sleepFor(10);
});
await allowOnlyOneAtATime('testing', spy);
expect(spy.callCount).to.be.eq(1);
});
it('starts only once if already running', async () => {
const spy = sinon.spy(async () => {
return sleepFor(10);
});
void allowOnlyOneAtATime('testing', spy);
await allowOnlyOneAtATime('testing', spy);
expect(spy.callCount).to.be.eq(1);
});
it('throw if took longer than expected timeout', async () => {
const spy = sinon.spy(async () => {
return sleepFor(10);
});
try {
await allowOnlyOneAtATime('testing', spy, 5);
throw new Error('should not get here');
} catch (e) {
console.warn(e);
expect(e).to.be.be.eql(undefined, 'should be undefined');
}
expect(spy.callCount).to.be.eq(1);
});
it('does not throw if took less than expected timeout', async () => {
const spy = sinon.spy(async () => {
return sleepFor(10);
});
try {
await allowOnlyOneAtATime('testing', spy, 15);
throw new Error('should get here');
} catch (e) {
console.warn(e);
expect(e.message).to.be.be.eql('should get here');
}
expect(spy.callCount).to.be.eq(1);
});
});
describe('hasAlreadyOneAtaTimeMatching', () => {
it('returns true if already started', () => {
const spy = sinon.spy(async () => {
return sleepFor(10);
});
void allowOnlyOneAtATime('testing', spy);
expect(hasAlreadyOneAtaTimeMatching('testing')).to.be.eq(true, 'should be true');
});
it('returns false if not already started', () => {
expect(hasAlreadyOneAtaTimeMatching('testing2')).to.be.eq(false, 'should be false');
});
});
});

@ -20,6 +20,7 @@ import {
readAttachmentData,
writeNewAttachmentData,
} from '../MessageAttachment';
import { perfEnd, perfStart } from '../../session/utils/Performance';
const DEFAULT_JPEG_QUALITY = 0.85;
@ -30,10 +31,13 @@ const DEFAULT_JPEG_QUALITY = 0.85;
export const autoOrientJpegImage = async (
fileOrBlobOrURL: string | File | Blob
): Promise<string> => {
perfStart(`autoOrientJpegImage`);
const loadedImage = await loadImage(fileOrBlobOrURL, { orientation: true, canvas: true });
const canvas = loadedImage.image as HTMLCanvasElement;
const dataURL = canvas.toDataURL(MIME.IMAGE_JPEG, DEFAULT_JPEG_QUALITY);
perfEnd(`autoOrientJpegImage`, `autoOrientJpegImage`);
const dataURL = (loadedImage.image as HTMLCanvasElement).toDataURL(
MIME.IMAGE_JPEG,
DEFAULT_JPEG_QUALITY
);
return dataURL;
};

@ -11,6 +11,7 @@ import { THUMBNAIL_SIDE } from '../types/attachments/VisualAttachment';
import imageType from 'image-type';
import { MAX_ATTACHMENT_FILESIZE_BYTES } from '../session/constants';
import { perfEnd, perfStart } from '../session/utils/Performance';
/**
* The logic for sending attachments is as follow:
@ -64,6 +65,7 @@ export async function autoScaleForAvatar<T extends { contentType: string; blob:
throw new Error('Cannot autoScaleForAvatar another file than PNG,GIF or JPEG.');
}
window.log.info('autoscale for avatar', maxMeasurements);
return autoScale(attachment, maxMeasurements);
}
@ -88,6 +90,8 @@ export async function autoScaleForIncomingAvatar(incomingAvatar: ArrayBuffer) {
blob,
};
}
window.log.info('autoscale for incoming avatar', maxMeasurements);
return autoScale(
{
blob,
@ -109,6 +113,8 @@ export async function autoScaleForThumbnail<T extends { contentType: string; blo
maxSize: 200 * 1000, // 200 ko
};
window.log.info('autoScaleForThumbnail', maxMeasurements);
return autoScale(attachment, maxMeasurements);
}
@ -168,8 +174,9 @@ export async function autoScale<T extends { contentType: string; blob: Blob }>(
canvas: true,
};
perfStart(`loadimage-*${blob.size}`);
const canvas = await loadImage(blob, loadImgOpts);
perfEnd(`loadimage-*${blob.size}`, `loadimage-*${blob.size}`);
if (!canvas || !canvas.originalWidth || !canvas.originalHeight) {
throw new Error('failed to scale image');
}

@ -167,15 +167,6 @@ async function deriveSymmetricKey(x25519PublicKey: Uint8Array, x25519PrivateKey:
return symmetricKey;
}
async function generateEphemeralKeyPair() {
const ran = (await getSodiumWorker()).randombytes_buf(32);
const keys = generateKeyPair(ran);
return keys;
// Signal protocol prepends with "0x05"
// keys.pubKey = keys.pubKey.slice(1);
// return { pubKey: keys.public, privKey: keys.private };
}
function assertArrayBufferView(val: any) {
if (!ArrayBuffer.isView(val)) {
throw new Error('val type not correct');
@ -189,7 +180,11 @@ async function encryptForPubkey(pubkeyX25519str: string, payloadBytes: Uint8Arra
throw new Error('pubkeyX25519str type not correct');
}
assertArrayBufferView(payloadBytes);
const ephemeral = await generateEphemeralKeyPair();
const ran = (await getSodiumWorker()).randombytes_buf(32);
const ephemeral = generateKeyPair(ran);
// Signal protocol prepends with "0x05"
// keys.pubKey = keys.pubKey.slice(1);
// return { pubKey: keys.public, privKey: keys.private };
const pubkeyX25519Buffer = fromHexToArray(pubkeyX25519str);
const symmetricKey = await deriveSymmetricKey(
pubkeyX25519Buffer,

@ -6080,6 +6080,11 @@ qs@~6.5.2:
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
queue-promise@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/queue-promise/-/queue-promise-2.2.1.tgz#8de03fb79ba458efcb5ebf76368ab029d2669752"
integrity sha512-C3eyRwLF9m6dPV4MtqMVFX+Xmc7keZ9Ievm3jJ/wWM5t3uVbFnGsJXwpYzZ4LaIEcX9bss/mdaKzyrO6xheRuA==
quick-lru@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f"

Loading…
Cancel
Save