Use libsignal-client for parsing crash reports
This commit is contained in:
parent
d7f0978c6d
commit
9ad6d5b66b
18 changed files with 86 additions and 241 deletions
|
@ -1,17 +1,34 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { app, clipboard, crashReporter, ipcMain as ipc } from 'electron';
|
||||
import { realpath, readdir, readFile, unlink } from 'fs-extra';
|
||||
import { app, crashReporter, ipcMain as ipc } from 'electron';
|
||||
import { realpath, readdir, readFile, unlink, stat } from 'fs-extra';
|
||||
import { basename, join } from 'path';
|
||||
import { toJSONString as dumpToJSONString } from '@signalapp/libsignal-client/dist/Minidump';
|
||||
import z from 'zod';
|
||||
|
||||
import type { LoggerType } from '../ts/types/Logging';
|
||||
import * as Errors from '../ts/types/errors';
|
||||
import { isAlpha } from '../ts/util/version';
|
||||
import { upload as uploadDebugLog } from '../ts/logging/uploadDebugLog';
|
||||
import { SignalService as Proto } from '../ts/protobuf';
|
||||
import OS from '../ts/util/os/osMain';
|
||||
|
||||
const dumpSchema = z
|
||||
.object({
|
||||
crashing_thread: z
|
||||
.object({
|
||||
frames: z
|
||||
.object({
|
||||
registers: z.unknown(),
|
||||
})
|
||||
.passthrough()
|
||||
.array()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
async function getPendingDumps(): Promise<ReadonlyArray<string>> {
|
||||
const crashDumpsPath = await realpath(app.getPath('crashDumps'));
|
||||
let pendingDir: string;
|
||||
|
@ -46,7 +63,11 @@ async function eraseDumps(
|
|||
);
|
||||
}
|
||||
|
||||
export function setup(getLogger: () => LoggerType, forceEnable = false): void {
|
||||
export function setup(
|
||||
getLogger: () => LoggerType,
|
||||
showDebugLogWindow: () => Promise<void>,
|
||||
forceEnable = false
|
||||
): void {
|
||||
const isEnabled = isAlpha(app.getVersion()) || forceEnable;
|
||||
|
||||
if (isEnabled) {
|
||||
|
@ -68,7 +89,7 @@ export function setup(getLogger: () => LoggerType, forceEnable = false): void {
|
|||
return pendingDumps.length;
|
||||
});
|
||||
|
||||
ipc.handle('crash-reports:upload', async () => {
|
||||
ipc.handle('crash-reports:write-to-log', async () => {
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
@ -79,17 +100,26 @@ export function setup(getLogger: () => LoggerType, forceEnable = false): void {
|
|||
}
|
||||
|
||||
const logger = getLogger();
|
||||
logger.warn(`crashReports: uploading ${pendingDumps.length} dumps`);
|
||||
logger.warn(`crashReports: logging ${pendingDumps.length} dumps`);
|
||||
|
||||
const maybeDumps = await Promise.all(
|
||||
await Promise.all(
|
||||
pendingDumps.map(async fullPath => {
|
||||
try {
|
||||
return {
|
||||
filename: basename(fullPath),
|
||||
content: await readFile(fullPath),
|
||||
};
|
||||
} catch (error) {
|
||||
const content = await readFile(fullPath);
|
||||
const { mtime } = await stat(fullPath);
|
||||
|
||||
const dump = dumpSchema.parse(JSON.parse(dumpToJSONString(content)));
|
||||
for (const frame of dump.crashing_thread?.frames ?? []) {
|
||||
delete frame.registers;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`crashReports: dump=${basename(fullPath)} ` +
|
||||
`mtime=${JSON.stringify(mtime)}`,
|
||||
JSON.stringify(dump, null, 2)
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`crashReports: failed to read crash report ${fullPath} due to error`,
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
|
@ -98,30 +128,8 @@ export function setup(getLogger: () => LoggerType, forceEnable = false): void {
|
|||
})
|
||||
);
|
||||
|
||||
const content = Proto.CrashReportList.encode({
|
||||
reports: maybeDumps.filter(
|
||||
(dump): dump is { filename: string; content: Buffer } => {
|
||||
return dump !== undefined;
|
||||
}
|
||||
),
|
||||
}).finish();
|
||||
|
||||
try {
|
||||
const url = await uploadDebugLog({
|
||||
content,
|
||||
appVersion: app.getVersion(),
|
||||
logger,
|
||||
extension: 'dmp',
|
||||
contentType: 'application/octet-stream',
|
||||
compress: false,
|
||||
prefix: 'desktop-crash-',
|
||||
});
|
||||
|
||||
logger.info(`crashReports: upload complete, ${url}`);
|
||||
clipboard.writeText(url);
|
||||
} finally {
|
||||
await eraseDumps(logger, pendingDumps);
|
||||
}
|
||||
await eraseDumps(logger, pendingDumps);
|
||||
await showDebugLogWindow();
|
||||
});
|
||||
|
||||
ipc.handle('crash-reports:erase', async () => {
|
||||
|
|
|
@ -211,7 +211,7 @@ const FORCE_ENABLE_CRASH_REPORTS = process.argv.some(
|
|||
|
||||
const CLI_LANG = cliOptions.lang as string | undefined;
|
||||
|
||||
setupCrashReports(getLogger, FORCE_ENABLE_CRASH_REPORTS);
|
||||
setupCrashReports(getLogger, showDebugLogWindow, FORCE_ENABLE_CRASH_REPORTS);
|
||||
|
||||
let sendDummyKeystroke: undefined | (() => void);
|
||||
if (OS.isWindows()) {
|
||||
|
|
|
@ -97,7 +97,7 @@
|
|||
"@react-aria/utils": "3.16.0",
|
||||
"@react-spring/web": "9.5.5",
|
||||
"@signalapp/better-sqlite3": "8.6.0",
|
||||
"@signalapp/libsignal-client": "0.39.1",
|
||||
"@signalapp/libsignal-client": "0.40.0",
|
||||
"@signalapp/ringrtc": "2.37.1",
|
||||
"@signalapp/windows-dummy-keystroke": "1.0.0",
|
||||
"@types/fabric": "4.5.3",
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
syntax = "proto3";
|
||||
|
||||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package signalservice;
|
||||
|
||||
message CrashReport {
|
||||
string filename = 1;
|
||||
bytes content = 2;
|
||||
}
|
||||
|
||||
message CrashReportList {
|
||||
repeated CrashReport reports = 1;
|
||||
}
|
|
@ -1356,7 +1356,9 @@ export class ConversationController {
|
|||
async _forgetE164(e164: string): Promise<void> {
|
||||
const { server } = window.textsecure;
|
||||
strictAssert(server, 'Server must be initialized');
|
||||
const serviceIdMap = await getServiceIdsForE164s(server, [e164]);
|
||||
const { entries: serviceIdMap } = await getServiceIdsForE164s(server, [
|
||||
e164,
|
||||
]);
|
||||
|
||||
const pni = serviceIdMap.get(e164)?.pni;
|
||||
|
||||
|
|
|
@ -24,9 +24,9 @@ export function Basic(): JSX.Element {
|
|||
<CrashReportDialog
|
||||
i18n={i18n}
|
||||
isPending={isPending}
|
||||
uploadCrashReports={async () => {
|
||||
writeCrashReportsToLog={async () => {
|
||||
setIsPending(true);
|
||||
action('uploadCrashReports')();
|
||||
action('writeCrashReportsToLog')();
|
||||
await sleep(5000);
|
||||
setIsPending(false);
|
||||
}}
|
||||
|
|
|
@ -9,7 +9,7 @@ import { Modal } from './Modal';
|
|||
import { Spinner } from './Spinner';
|
||||
|
||||
type PropsActionsType = {
|
||||
uploadCrashReports: () => void;
|
||||
writeCrashReportsToLog: () => void;
|
||||
eraseCrashReports: () => void;
|
||||
};
|
||||
|
||||
|
@ -19,7 +19,7 @@ export type PropsType = {
|
|||
} & PropsActionsType;
|
||||
|
||||
export function CrashReportDialog(props: Readonly<PropsType>): JSX.Element {
|
||||
const { i18n, isPending, uploadCrashReports, eraseCrashReports } = props;
|
||||
const { i18n, isPending, writeCrashReportsToLog, eraseCrashReports } = props;
|
||||
|
||||
const onEraseClick = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
@ -30,7 +30,7 @@ export function CrashReportDialog(props: Readonly<PropsType>): JSX.Element {
|
|||
const onSubmitClick = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
uploadCrashReports();
|
||||
writeCrashReportsToLog();
|
||||
};
|
||||
|
||||
const footer = (
|
||||
|
|
|
@ -240,7 +240,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
|||
<CrashReportDialog
|
||||
i18n={i18n}
|
||||
isPending={false}
|
||||
uploadCrashReports={action('uploadCrashReports')}
|
||||
writeCrashReportsToLog={action('writeCrashReportsToLog')}
|
||||
eraseCrashReports={action('eraseCrashReports')}
|
||||
/>
|
||||
),
|
||||
|
|
|
@ -1,153 +0,0 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import { tmpdir } from 'os';
|
||||
import { execFile as rawExecFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { strictAssert } from '../util/assert';
|
||||
import { wrapEventEmitterOnce } from '../util/wrapEventEmitterOnce';
|
||||
import { SignalService as Proto } from '../protobuf';
|
||||
|
||||
const execFile = promisify(rawExecFile);
|
||||
|
||||
const TARGET_URL = 'https://symbols.electronjs.org';
|
||||
const CLI_OPTIONS = [];
|
||||
const CLI_ARGS = [];
|
||||
|
||||
for (const arg of process.argv.slice(2)) {
|
||||
if (arg.startsWith('--')) {
|
||||
CLI_OPTIONS.push(arg.slice(2));
|
||||
} else {
|
||||
CLI_ARGS.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
const [OUTPUT_DIR, ...CRASH_REPORTS] = CLI_ARGS;
|
||||
|
||||
main(OUTPUT_DIR, CRASH_REPORTS, CLI_OPTIONS).catch(error => {
|
||||
console.error(error.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
async function main(
|
||||
outDir: string,
|
||||
fileNames: ReadonlyArray<string>,
|
||||
options: ReadonlyArray<string>
|
||||
): Promise<void> {
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
|
||||
const substitutions = new Map<string, Buffer>();
|
||||
await Promise.all(
|
||||
options.map(async option => {
|
||||
const match = option.match(/^sub:(.*)=(.*)$/);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
substitutions.set(match[1], await fs.readFile(match[2]));
|
||||
})
|
||||
);
|
||||
|
||||
const proxyServer = http
|
||||
.createServer(async ({ method, url = '/' }, res) => {
|
||||
console.log(`Proxy server got request ${method} ${url}`);
|
||||
if (method !== 'GET') {
|
||||
throw new Error('Unsupported');
|
||||
}
|
||||
|
||||
for (const [name, buffer] of substitutions) {
|
||||
if (url.includes(name)) {
|
||||
console.log(`Providing substitution for ${url}`);
|
||||
res.end(buffer);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
const patchedURL = url.replace(/signal-desktop-[^\/.]+/g, 'electron');
|
||||
|
||||
https.get(`${TARGET_URL}${patchedURL}`, remoteRes => {
|
||||
res.writeHead(remoteRes.statusCode ?? 500, remoteRes.headers);
|
||||
|
||||
remoteRes.pipe(res);
|
||||
});
|
||||
})
|
||||
.unref();
|
||||
|
||||
proxyServer.listen(0);
|
||||
|
||||
await wrapEventEmitterOnce(proxyServer, 'listening');
|
||||
const addr = proxyServer.address();
|
||||
strictAssert(
|
||||
typeof addr === 'object' && addr != null,
|
||||
'Address has to be an object'
|
||||
);
|
||||
|
||||
const { port: proxyPort } = addr;
|
||||
|
||||
console.log(`Proxy server listening on ${proxyPort}`);
|
||||
|
||||
await Promise.all(
|
||||
fileNames.map(fileName => symbolicate(outDir, fileName, proxyPort))
|
||||
);
|
||||
|
||||
proxyServer.close();
|
||||
}
|
||||
|
||||
async function symbolicate(
|
||||
outDir: string,
|
||||
fileName: string,
|
||||
proxyPort: number
|
||||
): Promise<void> {
|
||||
const tmpDir = await fs.mkdtemp(path.join(tmpdir(), 'signal-crashe'));
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
|
||||
const encoded = await fs.readFile(fileName);
|
||||
let reports: ReadonlyArray<Proto.ICrashReport>;
|
||||
if (fileName.endsWith('.raw')) {
|
||||
reports = [
|
||||
{
|
||||
filename: 'report.dmp',
|
||||
content: encoded,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
({ reports } = Proto.CrashReportList.decode(encoded));
|
||||
}
|
||||
|
||||
const { name: prefix } = path.parse(fileName);
|
||||
|
||||
await Promise.all(
|
||||
reports.map(async ({ filename: reportName, content }) => {
|
||||
if (!reportName || !content) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { base, name, ext } = path.parse(reportName);
|
||||
if (ext !== '.dmp') {
|
||||
console.log(`Ignoring ${reportName}, wrong extension`);
|
||||
return;
|
||||
}
|
||||
|
||||
const dumpFile = path.join(tmpDir, `${prefix}-${base}`);
|
||||
console.log(`Extracting to ${dumpFile}`);
|
||||
await fs.writeFile(dumpFile, content);
|
||||
|
||||
const outFile = path.join(outDir, `${prefix}-${name}.txt`);
|
||||
|
||||
await execFile('minidump-stackwalk', [
|
||||
'--symbols-url',
|
||||
`http://127.0.0.1:${proxyPort}`,
|
||||
'--output-file',
|
||||
outFile,
|
||||
dumpFile,
|
||||
]);
|
||||
console.log(`Symbolicating ${dumpFile} to ${outFile}`);
|
||||
})
|
||||
);
|
||||
}
|
|
@ -93,7 +93,7 @@ function checkForAccount(
|
|||
|
||||
log.info(`checkForAccount: looking ${phoneNumber} up on server`);
|
||||
try {
|
||||
const serviceIdLookup = await getServiceIdsForE164s(server, [
|
||||
const { entries: serviceIdLookup } = await getServiceIdsForE164s(server, [
|
||||
phoneNumber,
|
||||
]);
|
||||
const maybePair = serviceIdLookup.get(phoneNumber);
|
||||
|
|
|
@ -22,7 +22,7 @@ export type CrashReportsStateType = ReadonlyDeep<{
|
|||
// Actions
|
||||
|
||||
const SET_COUNT = 'crashReports/SET_COUNT';
|
||||
const UPLOAD = 'crashReports/UPLOAD';
|
||||
const WRITE_TO_LOG = 'crashReports/WRITE_TO_LOG';
|
||||
const ERASE = 'crashReports/ERASE';
|
||||
|
||||
type SetCrashReportCountActionType = ReadonlyDeep<{
|
||||
|
@ -32,7 +32,7 @@ type SetCrashReportCountActionType = ReadonlyDeep<{
|
|||
|
||||
type CrashReportsActionType = ReadonlyDeep<
|
||||
| SetCrashReportCountActionType
|
||||
| PromiseAction<typeof UPLOAD>
|
||||
| PromiseAction<typeof WRITE_TO_LOG>
|
||||
| PromiseAction<typeof ERASE>
|
||||
>;
|
||||
|
||||
|
@ -40,7 +40,7 @@ type CrashReportsActionType = ReadonlyDeep<
|
|||
|
||||
export const actions = {
|
||||
setCrashReportCount,
|
||||
uploadCrashReports,
|
||||
writeCrashReportsToLog,
|
||||
eraseCrashReports,
|
||||
};
|
||||
|
||||
|
@ -48,23 +48,22 @@ function setCrashReportCount(count: number): SetCrashReportCountActionType {
|
|||
return { type: SET_COUNT, payload: count };
|
||||
}
|
||||
|
||||
function uploadCrashReports(): ThunkAction<
|
||||
function writeCrashReportsToLog(): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
PromiseAction<typeof UPLOAD> | ShowToastActionType
|
||||
PromiseAction<typeof WRITE_TO_LOG> | ShowToastActionType
|
||||
> {
|
||||
return dispatch => {
|
||||
async function run() {
|
||||
try {
|
||||
await window.IPC.crashReports.upload();
|
||||
dispatch(showToast({ toastType: ToastType.LinkCopied }));
|
||||
await window.IPC.crashReports.writeToLog();
|
||||
} catch (error) {
|
||||
dispatch(showToast({ toastType: ToastType.DebugLogError }));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
dispatch({ type: UPLOAD, payload: run() });
|
||||
dispatch({ type: WRITE_TO_LOG, payload: run() });
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -108,7 +107,7 @@ export function reducer(
|
|||
}
|
||||
|
||||
if (
|
||||
action.type === `${UPLOAD}_PENDING` ||
|
||||
action.type === `${WRITE_TO_LOG}_PENDING` ||
|
||||
action.type === `${ERASE}_PENDING`
|
||||
) {
|
||||
return {
|
||||
|
@ -118,7 +117,7 @@ export function reducer(
|
|||
}
|
||||
|
||||
if (
|
||||
action.type === `${UPLOAD}_FULFILLED` ||
|
||||
action.type === `${WRITE_TO_LOG}_FULFILLED` ||
|
||||
action.type === `${ERASE}_FULFILLED`
|
||||
) {
|
||||
return {
|
||||
|
@ -129,13 +128,13 @@ export function reducer(
|
|||
}
|
||||
|
||||
if (
|
||||
action.type === (`${UPLOAD}_REJECTED` as const) ||
|
||||
action.type === (`${WRITE_TO_LOG}_REJECTED` as const) ||
|
||||
action.type === (`${ERASE}_REJECTED` as const)
|
||||
) {
|
||||
const { error } = action;
|
||||
|
||||
log.error(
|
||||
`Failed to upload crash report due to error ${Errors.toLogFormat(error)}`
|
||||
`Failed to write crash report due to error ${Errors.toLogFormat(error)}`
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
@ -153,7 +153,9 @@ describe('updateConversationsWithUuidLookup', () => {
|
|||
|
||||
sinonSandbox.stub(window.Signal.Data, 'updateConversation');
|
||||
|
||||
fakeCdsLookup = sinonSandbox.stub().resolves(new Map());
|
||||
fakeCdsLookup = sinonSandbox.stub().resolves({
|
||||
entries: new Map(),
|
||||
});
|
||||
fakeCheckAccountExistence = sinonSandbox.stub().resolves(false);
|
||||
fakeServer = {
|
||||
cdsLookup: fakeCdsLookup,
|
||||
|
@ -198,12 +200,12 @@ describe('updateConversationsWithUuidLookup', () => {
|
|||
const aci1 = generateAci();
|
||||
const aci2 = generateAci();
|
||||
|
||||
fakeCdsLookup.resolves(
|
||||
new Map([
|
||||
fakeCdsLookup.resolves({
|
||||
entries: new Map([
|
||||
['+13215559876', { aci: aci1, pni: undefined }],
|
||||
['+16545559876', { aci: aci2, pni: undefined }],
|
||||
])
|
||||
);
|
||||
]),
|
||||
});
|
||||
|
||||
await updateConversationsWithUuidLookup({
|
||||
conversationController: new FakeConversationController([
|
||||
|
|
|
@ -122,7 +122,7 @@ export abstract class CDSSocketBase<
|
|||
}
|
||||
|
||||
log.info('CDSSocket.request(): done');
|
||||
return resultMap;
|
||||
return { debugPermitsUsed: 0, entries: resultMap };
|
||||
}
|
||||
|
||||
// Abstract methods
|
||||
|
|
|
@ -27,7 +27,7 @@ export async function updateConversationsWithUuidLookup({
|
|||
return;
|
||||
}
|
||||
|
||||
const serverLookup = await getServiceIdsForE164s(server, e164s);
|
||||
const { entries: serverLookup } = await getServiceIdsForE164s(server, e164s);
|
||||
|
||||
await Promise.all(
|
||||
conversations.map(async conversation => {
|
||||
|
|
|
@ -69,7 +69,9 @@ export async function lookupConversationWithoutServiceId(
|
|||
try {
|
||||
let conversationId: string | undefined;
|
||||
if (options.type === 'e164') {
|
||||
const serverLookup = await getServiceIdsForE164s(server, [options.e164]);
|
||||
const { entries: serverLookup } = await getServiceIdsForE164s(server, [
|
||||
options.e164,
|
||||
]);
|
||||
|
||||
const maybePair = serverLookup.get(options.e164);
|
||||
|
||||
|
|
2
ts/window.d.ts
vendored
2
ts/window.d.ts
vendored
|
@ -61,7 +61,7 @@ export type IPCType = {
|
|||
closeAbout: () => void;
|
||||
crashReports: {
|
||||
getCount: () => Promise<number>;
|
||||
upload: () => Promise<void>;
|
||||
writeToLog: () => Promise<void>;
|
||||
erase: () => Promise<void>;
|
||||
};
|
||||
drawAttention: () => void;
|
||||
|
|
|
@ -78,7 +78,7 @@ const IPC: IPCType = {
|
|||
closeAbout: () => ipc.send('close-about'),
|
||||
crashReports: {
|
||||
getCount: () => ipc.invoke('crash-reports:get-count'),
|
||||
upload: () => ipc.invoke('crash-reports:upload'),
|
||||
writeToLog: () => ipc.invoke('crash-reports:write-to-log'),
|
||||
erase: () => ipc.invoke('crash-reports:erase'),
|
||||
},
|
||||
drawAttention: () => {
|
||||
|
|
|
@ -3955,10 +3955,10 @@
|
|||
bindings "^1.5.0"
|
||||
tar "^6.1.0"
|
||||
|
||||
"@signalapp/libsignal-client@0.39.1":
|
||||
version "0.39.1"
|
||||
resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.39.1.tgz#15b41f15c516ae3eecf8a098a9c9c7aac00444d7"
|
||||
integrity sha512-Drna/0rQTa/jB475KssoBA86Da/DLdJYDznkbiFG2YD/OeWEKoDpi64bp+BIpnc2o16GnVhGLFzNvMfVkI41eQ==
|
||||
"@signalapp/libsignal-client@0.40.0":
|
||||
version "0.40.0"
|
||||
resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.40.0.tgz#2997f44b69d4d73aa474550243998b628d0d3a1a"
|
||||
integrity sha512-RrVe46KDHSfspvz+rmqkP2KMrlctWb6voejEFzhxSB8yXbnmEQuwEu7bqJj+uYsLhv8nKpIfC1qniNQc0SDPeA==
|
||||
dependencies:
|
||||
node-gyp-build "^4.2.3"
|
||||
type-fest "^3.5.0"
|
||||
|
|
Loading…
Reference in a new issue