Detect startup after recent crashes
This commit is contained in:
parent
02a732c511
commit
91f1b62bc7
23 changed files with 650 additions and 101 deletions
|
@ -6625,6 +6625,22 @@
|
|||
"message": "Please close it manually and click Retry to continue.",
|
||||
"description": "Second line of the dialog displayed when Windows installer can't close application automatically and needs user intervention to complete the installation."
|
||||
},
|
||||
"CrashReportDialog__title": {
|
||||
"message": "Application crashed",
|
||||
"description": "A title of the dialog displayed when starting an application after a recent crash"
|
||||
},
|
||||
"CrashReportDialog__body": {
|
||||
"message": "Signal restarted after a crash. You can submit a crash a report to help Signal investigate the issue.",
|
||||
"description": "The body of the dialog displayed when starting an application after a recent crash"
|
||||
},
|
||||
"CrashReportDialog__submit": {
|
||||
"message": "Send",
|
||||
"description": "A button label for submission of the crash reporter data after a recent crash"
|
||||
},
|
||||
"CrashReportDialog__erase": {
|
||||
"message": "Don't Send",
|
||||
"description": "A button label for erasure of the crash reporter data after a recent crash and continuing to start the app"
|
||||
},
|
||||
"CustomizingPreferredReactions__title": {
|
||||
"message": "Customize reactions",
|
||||
"description": "Shown in the header of the modal for customizing the preferred reactions. Also shown in the tooltip for the button that opens this modal."
|
||||
|
|
127
app/crashReports.ts
Normal file
127
app/crashReports.ts
Normal file
|
@ -0,0 +1,127 @@
|
|||
// 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 { basename, join } from 'path';
|
||||
|
||||
import type { LoggerType } from '../ts/types/Logging';
|
||||
import * as Errors from '../ts/types/errors';
|
||||
import { isProduction } from '../ts/util/version';
|
||||
import { upload as uploadDebugLog } from '../ts/logging/uploadDebugLog';
|
||||
import { SignalService as Proto } from '../ts/protobuf';
|
||||
|
||||
async function getPendingDumps(): Promise<ReadonlyArray<string>> {
|
||||
const crashDumpsPath = await realpath(app.getPath('crashDumps'));
|
||||
const pendingDir = join(crashDumpsPath, 'pending');
|
||||
const files = await readdir(pendingDir);
|
||||
|
||||
return files.map(file => join(pendingDir, file));
|
||||
}
|
||||
|
||||
async function eraseDumps(
|
||||
logger: LoggerType,
|
||||
files: ReadonlyArray<string>
|
||||
): Promise<void> {
|
||||
logger.warn(`crashReports: erasing ${files.length} pending dumps`);
|
||||
await Promise.all(
|
||||
files.map(async fullPath => {
|
||||
try {
|
||||
await unlink(fullPath);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`crashReports: failed to unlink crash report ${fullPath} due to error`,
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function setup(getLogger: () => LoggerType): void {
|
||||
const isEnabled = !isProduction(app.getVersion());
|
||||
|
||||
if (isEnabled) {
|
||||
getLogger().info('crashReporter: enabled');
|
||||
crashReporter.start({ uploadToServer: false });
|
||||
}
|
||||
|
||||
ipc.handle('crash-reports:get-count', async () => {
|
||||
if (!isEnabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const pendingDumps = await getPendingDumps();
|
||||
if (pendingDumps.length !== 0) {
|
||||
getLogger().warn(
|
||||
`crashReports: ${pendingDumps.length} pending dumps found`
|
||||
);
|
||||
}
|
||||
return pendingDumps.length;
|
||||
});
|
||||
|
||||
ipc.handle('crash-reports:upload', async () => {
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingDumps = await getPendingDumps();
|
||||
if (pendingDumps.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const logger = getLogger();
|
||||
logger.warn(`crashReports: uploading ${pendingDumps.length} dumps`);
|
||||
|
||||
const maybeDumps = await Promise.all(
|
||||
pendingDumps.map(async fullPath => {
|
||||
try {
|
||||
return {
|
||||
filename: basename(fullPath),
|
||||
content: await readFile(fullPath),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`crashReports: failed to read crash report ${fullPath} due to error`,
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
logger.info('crashReports: upload complete');
|
||||
clipboard.writeText(url);
|
||||
} finally {
|
||||
await eraseDumps(logger, pendingDumps);
|
||||
}
|
||||
});
|
||||
|
||||
ipc.handle('crash-reports:erase', async () => {
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingDumps = await getPendingDumps();
|
||||
|
||||
await eraseDumps(getLogger(), pendingDumps);
|
||||
});
|
||||
}
|
11
app/main.ts
11
app/main.ts
|
@ -7,7 +7,6 @@ import * as os from 'os';
|
|||
import { chmod, realpath, writeFile } from 'fs-extra';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import pify from 'pify';
|
||||
import normalizePath from 'normalize-path';
|
||||
import fastGlob from 'fast-glob';
|
||||
import PQueue from 'p-queue';
|
||||
|
@ -30,6 +29,7 @@ import { z } from 'zod';
|
|||
|
||||
import packageJson from '../package.json';
|
||||
import * as GlobalErrors from './global_errors';
|
||||
import { setup as setupCrashReports } from './crashReports';
|
||||
import { setup as setupSpellChecker } from './spell_check';
|
||||
import { redactAll, addSensitivePath } from '../ts/util/privacy';
|
||||
import { strictAssert } from '../ts/util/assert';
|
||||
|
@ -100,7 +100,6 @@ import { load as loadLocale } from './locale';
|
|||
import type { LoggerType } from '../ts/types/Logging';
|
||||
|
||||
const animationSettings = systemPreferences.getAnimationSettings();
|
||||
const getRealPath = pify(realpath);
|
||||
|
||||
// Keep a global reference of the window object, if you don't, the window will
|
||||
// be closed automatically when the JavaScript object is garbage collected.
|
||||
|
@ -1402,10 +1401,12 @@ function getAppLocale(): string {
|
|||
// Some APIs can only be used after this event occurs.
|
||||
let ready = false;
|
||||
app.on('ready', async () => {
|
||||
const userDataPath = await getRealPath(app.getPath('userData'));
|
||||
const userDataPath = await realpath(app.getPath('userData'));
|
||||
|
||||
logger = await logging.initialize(getMainWindow);
|
||||
|
||||
setupCrashReports(getLogger);
|
||||
|
||||
if (!locale) {
|
||||
const appLocale = getAppLocale();
|
||||
locale = loadLocale({ appLocale, logger });
|
||||
|
@ -1447,7 +1448,7 @@ app.on('ready', async () => {
|
|||
});
|
||||
});
|
||||
|
||||
const installPath = await getRealPath(app.getAppPath());
|
||||
const installPath = await realpath(app.getAppPath());
|
||||
|
||||
addSensitivePath(userDataPath);
|
||||
|
||||
|
@ -2081,7 +2082,7 @@ async function ensureFilePermissions(onlyFiles?: Array<string>) {
|
|||
getLogger().info('Begin ensuring permissions');
|
||||
|
||||
const start = Date.now();
|
||||
const userDataPath = await getRealPath(app.getPath('userData'));
|
||||
const userDataPath = await realpath(app.getPath('userData'));
|
||||
// fast-glob uses `/` for all platforms
|
||||
const userDataGlob = normalizePath(join(userDataPath, '**', '*'));
|
||||
|
||||
|
|
15
protos/CrashReports.proto
Normal file
15
protos/CrashReports.proto
Normal file
|
@ -0,0 +1,15 @@
|
|||
syntax = "proto3";
|
||||
|
||||
// Copyright 2020-2021 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;
|
||||
}
|
|
@ -982,6 +982,10 @@ export async function startApp(): Promise<void> {
|
|||
actionCreators.conversations,
|
||||
store.dispatch
|
||||
),
|
||||
crashReports: bindActionCreators(
|
||||
actionCreators.crashReports,
|
||||
store.dispatch
|
||||
),
|
||||
emojis: bindActionCreators(actionCreators.emojis, store.dispatch),
|
||||
expiration: bindActionCreators(actionCreators.expiration, store.dispatch),
|
||||
globalModals: bindActionCreators(
|
||||
|
@ -2382,6 +2386,11 @@ export async function startApp(): Promise<void> {
|
|||
await window.Signal.Data.saveMessages(messagesToSave, {
|
||||
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
||||
});
|
||||
|
||||
// Process crash reports if any
|
||||
window.reduxActions.crashReports.setCrashReportCount(
|
||||
await window.crashReports.getCount()
|
||||
);
|
||||
}
|
||||
function onReconnect() {
|
||||
// We disable notifications on first connect, but the same applies to reconnect. In
|
||||
|
|
33
ts/components/CrashReportDialog.stories.tsx
Normal file
33
ts/components/CrashReportDialog.stories.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
|
||||
import { CrashReportDialog } from './CrashReportDialog';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import { sleep } from '../util/sleep';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
const story = storiesOf('Components/CrashReportDialog', module);
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
story.add('CrashReportDialog', () => {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
return (
|
||||
<CrashReportDialog
|
||||
i18n={i18n}
|
||||
isPending={isPending}
|
||||
uploadCrashReports={async () => {
|
||||
setIsPending(true);
|
||||
action('uploadCrashReports')();
|
||||
await sleep(5000);
|
||||
setIsPending(false);
|
||||
}}
|
||||
eraseCrashReports={action('eraseCrashReports')}
|
||||
/>
|
||||
);
|
||||
});
|
68
ts/components/CrashReportDialog.tsx
Normal file
68
ts/components/CrashReportDialog.tsx
Normal file
|
@ -0,0 +1,68 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { Button, ButtonVariant } from './Button';
|
||||
import { Modal } from './Modal';
|
||||
import { Spinner } from './Spinner';
|
||||
|
||||
type PropsActionsType = {
|
||||
uploadCrashReports: () => void;
|
||||
eraseCrashReports: () => void;
|
||||
};
|
||||
|
||||
type PropsType = {
|
||||
i18n: LocalizerType;
|
||||
isPending: boolean;
|
||||
} & PropsActionsType;
|
||||
|
||||
export function CrashReportDialog(props: Readonly<PropsType>): JSX.Element {
|
||||
const { i18n, isPending, uploadCrashReports, eraseCrashReports } = props;
|
||||
|
||||
const onEraseClick = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
eraseCrashReports();
|
||||
};
|
||||
|
||||
const onSubmitClick = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
uploadCrashReports();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
moduleClassName="module-Modal--important"
|
||||
i18n={i18n}
|
||||
title={i18n('CrashReportDialog__title')}
|
||||
hasXButton
|
||||
onClose={eraseCrashReports}
|
||||
>
|
||||
<section>{i18n('CrashReportDialog__body')}</section>
|
||||
<Modal.ButtonFooter>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
onClick={onEraseClick}
|
||||
variant={ButtonVariant.Secondary}
|
||||
>
|
||||
{i18n('CrashReportDialog__erase')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
onClick={onSubmitClick}
|
||||
ref={button => button?.focus()}
|
||||
variant={ButtonVariant.Primary}
|
||||
>
|
||||
{isPending ? (
|
||||
<Spinner size="22px" svgSize="small" />
|
||||
) : (
|
||||
i18n('CrashReportDialog__submit')
|
||||
)}
|
||||
</Button>
|
||||
</Modal.ButtonFooter>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -10,6 +10,7 @@ import { storiesOf } from '@storybook/react';
|
|||
import type { PropsType } from './LeftPane';
|
||||
import { LeftPane, LeftPaneMode } from './LeftPane';
|
||||
import { CaptchaDialog } from './CaptchaDialog';
|
||||
import { CrashReportDialog } from './CrashReportDialog';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import { MessageSearchResult } from './conversationList/MessageSearchResult';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
|
@ -104,6 +105,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
['idle', 'required', 'pending'],
|
||||
'idle'
|
||||
),
|
||||
crashReportCount: select('challengeReportCount', [0, 1], 0),
|
||||
setChallengeStatus: action('setChallengeStatus'),
|
||||
renderExpiredBuildDialog: () => <div />,
|
||||
renderMainHeader: () => <div />,
|
||||
|
@ -134,6 +136,14 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
onSkip={action('onCaptchaSkip')}
|
||||
/>
|
||||
),
|
||||
renderCrashReportDialog: () => (
|
||||
<CrashReportDialog
|
||||
i18n={i18n}
|
||||
isPending={false}
|
||||
uploadCrashReports={action('uploadCrashReports')}
|
||||
eraseCrashReports={action('eraseCrashReports')}
|
||||
/>
|
||||
),
|
||||
selectedConversationId: undefined,
|
||||
selectedMessageId: undefined,
|
||||
savePreferredLeftPaneWidth: action('savePreferredLeftPaneWidth'),
|
||||
|
@ -633,6 +643,24 @@ story.add('Captcha dialog: pending', () => (
|
|||
/>
|
||||
));
|
||||
|
||||
// Crash report flow
|
||||
|
||||
story.add('Crash report dialog', () => (
|
||||
<LeftPane
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Inbox,
|
||||
pinnedConversations,
|
||||
conversations: defaultConversations,
|
||||
archivedConversations: [],
|
||||
isAboutToSearchInAConversation: false,
|
||||
startSearchCounter: 0,
|
||||
},
|
||||
crashReportCount: 42,
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
// Set group metadata
|
||||
|
||||
story.add('Group Metadata: No Timer', () => (
|
||||
|
|
|
@ -92,6 +92,7 @@ export type PropsType = {
|
|||
canResizeLeftPane: boolean;
|
||||
challengeStatus: 'idle' | 'required' | 'pending';
|
||||
setChallengeStatus: (status: 'idle') => void;
|
||||
crashReportCount: number;
|
||||
theme: ThemeType;
|
||||
|
||||
// Action Creators
|
||||
|
@ -144,12 +145,14 @@ export type PropsType = {
|
|||
_: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
||||
) => JSX.Element;
|
||||
renderCaptchaDialog: (props: { onSkip(): void }) => JSX.Element;
|
||||
renderCrashReportDialog: () => JSX.Element;
|
||||
};
|
||||
|
||||
export const LeftPane: React.FC<PropsType> = ({
|
||||
cantAddContactToGroup,
|
||||
canResizeLeftPane,
|
||||
challengeStatus,
|
||||
crashReportCount,
|
||||
clearGroupCreationError,
|
||||
clearSearch,
|
||||
closeCantAddContactToGroupModal,
|
||||
|
@ -165,6 +168,7 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
openConversationInternal,
|
||||
preferredWidthFromStorage,
|
||||
renderCaptchaDialog,
|
||||
renderCrashReportDialog,
|
||||
renderExpiredBuildDialog,
|
||||
renderMainHeader,
|
||||
renderMessageSearchResult,
|
||||
|
@ -641,6 +645,7 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
setChallengeStatus('idle');
|
||||
},
|
||||
})}
|
||||
{crashReportCount > 0 && renderCrashReportDialog()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,15 +4,6 @@
|
|||
import { memoize, sortBy } from 'lodash';
|
||||
import os from 'os';
|
||||
import { ipcRenderer as ipc } from 'electron';
|
||||
import { z } from 'zod';
|
||||
import FormData from 'form-data';
|
||||
import { gzip } from 'zlib';
|
||||
import pify from 'pify';
|
||||
import type { Response } from 'got';
|
||||
import got from 'got';
|
||||
import { getUserAgent } from '../util/getUserAgent';
|
||||
import { maybeParseUrl } from '../util/url';
|
||||
import * as log from './log';
|
||||
import { reallyJsonStringify } from '../util/reallyJsonStringify';
|
||||
import type { FetchLogIpcData, LogEntryType } from './shared';
|
||||
import {
|
||||
|
@ -25,78 +16,6 @@ import {
|
|||
import { redactAll } from '../util/privacy';
|
||||
import { getEnvironment } from '../environment';
|
||||
|
||||
const BASE_URL = 'https://debuglogs.org';
|
||||
|
||||
const tokenBodySchema = z
|
||||
.object({
|
||||
fields: z.record(z.unknown()),
|
||||
url: z.string(),
|
||||
})
|
||||
.nonstrict();
|
||||
|
||||
const parseTokenBody = (
|
||||
rawBody: unknown
|
||||
): { fields: Record<string, unknown>; url: string } => {
|
||||
const body = tokenBodySchema.parse(rawBody);
|
||||
|
||||
const parsedUrl = maybeParseUrl(body.url);
|
||||
if (!parsedUrl) {
|
||||
throw new Error("Token body's URL was not a valid URL");
|
||||
}
|
||||
if (parsedUrl.protocol !== 'https:') {
|
||||
throw new Error("Token body's URL was not HTTPS");
|
||||
}
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
export const upload = async (
|
||||
content: string,
|
||||
appVersion: string
|
||||
): Promise<string> => {
|
||||
const headers = { 'User-Agent': getUserAgent(appVersion) };
|
||||
|
||||
const signedForm = await got.get(BASE_URL, { responseType: 'json', headers });
|
||||
const { fields, url } = parseTokenBody(signedForm.body);
|
||||
|
||||
const uploadKey = `${fields.key}.gz`;
|
||||
|
||||
const form = new FormData();
|
||||
// The API expects `key` to be the first field:
|
||||
form.append('key', uploadKey);
|
||||
Object.entries(fields)
|
||||
.filter(([key]) => key !== 'key')
|
||||
.forEach(([key, value]) => {
|
||||
form.append(key, value);
|
||||
});
|
||||
|
||||
const contentBuffer = await pify(gzip)(Buffer.from(content, 'utf8'));
|
||||
const contentType = 'application/gzip';
|
||||
form.append('Content-Type', contentType);
|
||||
form.append('file', contentBuffer, {
|
||||
contentType,
|
||||
filename: `signal-desktop-debug-log-${appVersion}.txt.gz`,
|
||||
});
|
||||
|
||||
log.info('Debug log upload starting...');
|
||||
try {
|
||||
const { statusCode, body } = await got.post(url, { headers, body: form });
|
||||
if (statusCode !== 204) {
|
||||
throw new Error(
|
||||
`Failed to upload to S3, got status ${statusCode}, body '${body}'`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const response = error.response as Response<string>;
|
||||
throw new Error(
|
||||
`Got threw on upload to S3, got status ${response?.statusCode}, body '${response?.body}' `
|
||||
);
|
||||
}
|
||||
log.info('Debug log upload complete.');
|
||||
|
||||
return `${BASE_URL}/${uploadKey}`;
|
||||
};
|
||||
|
||||
// The mechanics of preparing a log for publish
|
||||
|
||||
const headerSectionTitle = (title: string) => `========= ${title} =========`;
|
||||
|
|
109
ts/logging/uploadDebugLog.ts
Normal file
109
ts/logging/uploadDebugLog.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
// Copyright 2018-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { Response } from 'got';
|
||||
import { z } from 'zod';
|
||||
import FormData from 'form-data';
|
||||
import got from 'got';
|
||||
import { gzip } from 'zlib';
|
||||
import pify from 'pify';
|
||||
import { getUserAgent } from '../util/getUserAgent';
|
||||
import { maybeParseUrl } from '../util/url';
|
||||
import * as durations from '../util/durations';
|
||||
import type { LoggerType } from '../types/Logging';
|
||||
|
||||
const BASE_URL = 'https://debuglogs.org';
|
||||
|
||||
const UPLOAD_TIMEOUT = { request: durations.MINUTE };
|
||||
|
||||
const tokenBodySchema = z
|
||||
.object({
|
||||
fields: z.record(z.unknown()),
|
||||
url: z.string(),
|
||||
})
|
||||
.nonstrict();
|
||||
|
||||
const parseTokenBody = (
|
||||
rawBody: unknown
|
||||
): { fields: Record<string, unknown>; url: string } => {
|
||||
const body = tokenBodySchema.parse(rawBody);
|
||||
|
||||
const parsedUrl = maybeParseUrl(body.url);
|
||||
if (!parsedUrl) {
|
||||
throw new Error("Token body's URL was not a valid URL");
|
||||
}
|
||||
if (parsedUrl.protocol !== 'https:') {
|
||||
throw new Error("Token body's URL was not HTTPS");
|
||||
}
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
export type UploadOptionsType = Readonly<{
|
||||
content: string | Buffer | Uint8Array;
|
||||
appVersion: string;
|
||||
logger: LoggerType;
|
||||
extension?: string;
|
||||
contentType?: string;
|
||||
compress?: boolean;
|
||||
}>;
|
||||
|
||||
export const upload = async ({
|
||||
content,
|
||||
appVersion,
|
||||
logger,
|
||||
extension = 'gz',
|
||||
contentType = 'application/gzip',
|
||||
compress = true,
|
||||
}: UploadOptionsType): Promise<string> => {
|
||||
const headers = { 'User-Agent': getUserAgent(appVersion) };
|
||||
|
||||
const signedForm = await got.get(BASE_URL, {
|
||||
responseType: 'json',
|
||||
headers,
|
||||
timeout: UPLOAD_TIMEOUT,
|
||||
});
|
||||
const { fields, url } = parseTokenBody(signedForm.body);
|
||||
|
||||
const uploadKey = `${fields.key}.${extension}`;
|
||||
|
||||
const form = new FormData();
|
||||
// The API expects `key` to be the first field:
|
||||
form.append('key', uploadKey);
|
||||
Object.entries(fields)
|
||||
.filter(([key]) => key !== 'key')
|
||||
.forEach(([key, value]) => {
|
||||
form.append(key, value);
|
||||
});
|
||||
|
||||
const contentBuffer = compress
|
||||
? await pify(gzip)(Buffer.from(content))
|
||||
: Buffer.from(content);
|
||||
form.append('Content-Type', contentType);
|
||||
form.append('file', contentBuffer, {
|
||||
contentType,
|
||||
filename: `signal-desktop-debug-log-${appVersion}.txt.gz`,
|
||||
});
|
||||
|
||||
logger.info('Debug log upload starting...');
|
||||
try {
|
||||
const { statusCode, body } = await got.post(url, {
|
||||
headers,
|
||||
body: form,
|
||||
timeout: UPLOAD_TIMEOUT,
|
||||
});
|
||||
if (statusCode !== 204) {
|
||||
throw new Error(
|
||||
`Failed to upload to S3, got status ${statusCode}, body '${body}'`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const response = error.response as Response<string>;
|
||||
throw new Error(
|
||||
`Got threw on upload to S3, got status ${response?.statusCode}, body '${response?.body}' `
|
||||
);
|
||||
}
|
||||
logger.info('Debug log upload complete.');
|
||||
|
||||
return `${BASE_URL}/${uploadKey}`;
|
||||
};
|
31
ts/scripts/unpack-crash-reports.ts
Normal file
31
ts/scripts/unpack-crash-reports.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
import { SignalService as Proto } from '../protobuf';
|
||||
|
||||
async function main(fileName: string, outDir: string) {
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
|
||||
const encoded = await fs.readFile(fileName);
|
||||
const { reports } = Proto.CrashReportList.decode(encoded);
|
||||
|
||||
await Promise.all(
|
||||
reports.map(async ({ filename, content }) => {
|
||||
if (!filename || !content) {
|
||||
return;
|
||||
}
|
||||
|
||||
const outFile = path.join(outDir, path.basename(filename));
|
||||
console.log(`Extracting to ${outFile}`);
|
||||
await fs.writeFile(outFile, content);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
main(process.argv[2], process.argv[3]).catch(error => {
|
||||
console.error(error.stack);
|
||||
process.exit(1);
|
||||
});
|
|
@ -9,6 +9,7 @@ import { actions as badges } from './ducks/badges';
|
|||
import { actions as calling } from './ducks/calling';
|
||||
import { actions as composer } from './ducks/composer';
|
||||
import { actions as conversations } from './ducks/conversations';
|
||||
import { actions as crashReports } from './ducks/crashReports';
|
||||
import { actions as emojis } from './ducks/emojis';
|
||||
import { actions as expiration } from './ducks/expiration';
|
||||
import { actions as globalModals } from './ducks/globalModals';
|
||||
|
@ -31,6 +32,7 @@ export const actionCreators: ReduxActions = {
|
|||
calling,
|
||||
composer,
|
||||
conversations,
|
||||
crashReports,
|
||||
emojis,
|
||||
expiration,
|
||||
globalModals,
|
||||
|
@ -53,6 +55,7 @@ export const mapDispatchToProps = {
|
|||
...calling,
|
||||
...composer,
|
||||
...conversations,
|
||||
...crashReports,
|
||||
...emojis,
|
||||
...expiration,
|
||||
...globalModals,
|
||||
|
|
135
ts/state/ducks/crashReports.ts
Normal file
135
ts/state/ducks/crashReports.ts
Normal file
|
@ -0,0 +1,135 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as log from '../../logging/log';
|
||||
import { showToast } from '../../util/showToast';
|
||||
import * as Errors from '../../types/errors';
|
||||
import { ToastLinkCopied } from '../../components/ToastLinkCopied';
|
||||
import { ToastDebugLogError } from '../../components/ToastDebugLogError';
|
||||
|
||||
// State
|
||||
|
||||
export type CrashReportsStateType = {
|
||||
count: number;
|
||||
isPending: boolean;
|
||||
};
|
||||
|
||||
// Actions
|
||||
|
||||
const SET_COUNT = 'crashReports/SET_COUNT';
|
||||
const UPLOAD = 'crashReports/UPLOAD';
|
||||
const ERASE = 'crashReports/ERASE';
|
||||
|
||||
type SetCrashReportCountActionType = {
|
||||
type: typeof SET_COUNT;
|
||||
payload: number;
|
||||
};
|
||||
|
||||
type PromiseAction<Type extends string, Payload = void> =
|
||||
| {
|
||||
type: Type;
|
||||
payload: Promise<Payload>;
|
||||
}
|
||||
| {
|
||||
type: `${Type}_PENDING`;
|
||||
}
|
||||
| {
|
||||
type: `${Type}_FULFILLED`;
|
||||
payload: Payload;
|
||||
}
|
||||
| {
|
||||
type: `${Type}_REJECTED`;
|
||||
error: true;
|
||||
payload: Error;
|
||||
};
|
||||
|
||||
type CrashReportsActionType =
|
||||
| SetCrashReportCountActionType
|
||||
| PromiseAction<typeof UPLOAD>
|
||||
| PromiseAction<typeof ERASE>;
|
||||
|
||||
// Action Creators
|
||||
|
||||
export const actions = {
|
||||
setCrashReportCount,
|
||||
uploadCrashReports,
|
||||
eraseCrashReports,
|
||||
};
|
||||
|
||||
function setCrashReportCount(count: number): SetCrashReportCountActionType {
|
||||
return { type: SET_COUNT, payload: count };
|
||||
}
|
||||
|
||||
function uploadCrashReports(): PromiseAction<typeof UPLOAD> {
|
||||
return { type: UPLOAD, payload: window.crashReports.upload() };
|
||||
}
|
||||
|
||||
function eraseCrashReports(): PromiseAction<typeof ERASE> {
|
||||
return { type: ERASE, payload: window.crashReports.erase() };
|
||||
}
|
||||
|
||||
// Reducer
|
||||
|
||||
export function getEmptyState(): CrashReportsStateType {
|
||||
return {
|
||||
count: 0,
|
||||
isPending: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function reducer(
|
||||
state: Readonly<CrashReportsStateType> = getEmptyState(),
|
||||
action: Readonly<CrashReportsActionType>
|
||||
): CrashReportsStateType {
|
||||
if (action.type === SET_COUNT) {
|
||||
return {
|
||||
...state,
|
||||
count: action.payload,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
action.type === `${UPLOAD}_PENDING` ||
|
||||
action.type === `${ERASE}_PENDING`
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
isPending: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
action.type === `${UPLOAD}_FULFILLED` ||
|
||||
action.type === `${ERASE}_FULFILLED`
|
||||
) {
|
||||
if (action.type === `${UPLOAD}_FULFILLED`) {
|
||||
showToast(ToastLinkCopied);
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
count: 0,
|
||||
isPending: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
action.type === (`${UPLOAD}_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)}`
|
||||
);
|
||||
|
||||
showToast(ToastDebugLogError);
|
||||
|
||||
return {
|
||||
...state,
|
||||
count: 0,
|
||||
isPending: false,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
|
@ -11,6 +11,7 @@ import { reducer as badges } from './ducks/badges';
|
|||
import { reducer as calling } from './ducks/calling';
|
||||
import { reducer as composer } from './ducks/composer';
|
||||
import { reducer as conversations } from './ducks/conversations';
|
||||
import { reducer as crashReports } from './ducks/crashReports';
|
||||
import { reducer as emojis } from './ducks/emojis';
|
||||
import { reducer as expiration } from './ducks/expiration';
|
||||
import { reducer as globalModals } from './ducks/globalModals';
|
||||
|
@ -33,6 +34,7 @@ export const reducer = combineReducers({
|
|||
calling,
|
||||
composer,
|
||||
conversations,
|
||||
crashReports,
|
||||
emojis,
|
||||
expiration,
|
||||
globalModals,
|
||||
|
|
19
ts/state/smart/CrashReportDialog.tsx
Normal file
19
ts/state/smart/CrashReportDialog.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { mapDispatchToProps } from '../actions';
|
||||
import { CrashReportDialog } from '../../components/CrashReportDialog';
|
||||
import type { StateType } from '../reducer';
|
||||
import { getIntl } from '../selectors/user';
|
||||
|
||||
const mapStateToProps = (state: StateType) => {
|
||||
return {
|
||||
...state.crashReports,
|
||||
i18n: getIntl(state),
|
||||
};
|
||||
};
|
||||
|
||||
const smart = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
export const SmartCrashReportDialog = smart(CrashReportDialog);
|
|
@ -58,6 +58,7 @@ import { SmartNetworkStatus } from './NetworkStatus';
|
|||
import { SmartRelinkDialog } from './RelinkDialog';
|
||||
import { SmartUpdateDialog } from './UpdateDialog';
|
||||
import { SmartCaptchaDialog } from './CaptchaDialog';
|
||||
import { SmartCrashReportDialog } from './CrashReportDialog';
|
||||
|
||||
function renderExpiredBuildDialog(
|
||||
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
||||
|
@ -88,6 +89,9 @@ function renderUpdateDialog(
|
|||
function renderCaptchaDialog({ onSkip }: { onSkip(): void }): JSX.Element {
|
||||
return <SmartCaptchaDialog onSkip={onSkip} />;
|
||||
}
|
||||
function renderCrashReportDialog(): JSX.Element {
|
||||
return <SmartCrashReportDialog />;
|
||||
}
|
||||
|
||||
const getModeSpecificProps = (
|
||||
state: StateType
|
||||
|
@ -185,6 +189,7 @@ const mapStateToProps = (state: StateType) => {
|
|||
i18n: getIntl(state),
|
||||
regionCode: getRegionCode(state),
|
||||
challengeStatus: state.network.challengeStatus,
|
||||
crashReportCount: state.crashReports.count,
|
||||
renderExpiredBuildDialog,
|
||||
renderMainHeader,
|
||||
renderMessageSearchResult,
|
||||
|
@ -192,6 +197,7 @@ const mapStateToProps = (state: StateType) => {
|
|||
renderRelinkDialog,
|
||||
renderUpdateDialog,
|
||||
renderCaptchaDialog,
|
||||
renderCrashReportDialog,
|
||||
theme: getTheme(state),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -9,6 +9,7 @@ import type { actions as badges } from './ducks/badges';
|
|||
import type { actions as calling } from './ducks/calling';
|
||||
import type { actions as composer } from './ducks/composer';
|
||||
import type { actions as conversations } from './ducks/conversations';
|
||||
import type { actions as crashReports } from './ducks/crashReports';
|
||||
import type { actions as emojis } from './ducks/emojis';
|
||||
import type { actions as expiration } from './ducks/expiration';
|
||||
import type { actions as globalModals } from './ducks/globalModals';
|
||||
|
@ -30,6 +31,7 @@ export type ReduxActions = {
|
|||
calling: typeof calling;
|
||||
composer: typeof composer;
|
||||
conversations: typeof conversations;
|
||||
crashReports: typeof crashReports;
|
||||
emojis: typeof emojis;
|
||||
expiration: typeof expiration;
|
||||
globalModals: typeof globalModals;
|
||||
|
|
|
@ -8,7 +8,9 @@ import FormData from 'form-data';
|
|||
import * as util from 'util';
|
||||
import * as zlib from 'zlib';
|
||||
|
||||
import { upload } from '../../logging/debuglogs';
|
||||
import * as durations from '../../util/durations';
|
||||
import { upload } from '../../logging/uploadDebugLog';
|
||||
import * as logger from '../../logging/log';
|
||||
|
||||
const gzip: (_: zlib.InputType) => Promise<Buffer> = util.promisify(zlib.gzip);
|
||||
|
||||
|
@ -39,7 +41,7 @@ describe('upload', () => {
|
|||
|
||||
it('makes a request to get the S3 bucket, then uploads it there', async function test() {
|
||||
assert.strictEqual(
|
||||
await upload('hello world', '1.2.3'),
|
||||
await upload({ content: 'hello world', appVersion: '1.2.3', logger }),
|
||||
'https://debuglogs.org/abc123.gz'
|
||||
);
|
||||
|
||||
|
@ -47,6 +49,7 @@ describe('upload', () => {
|
|||
sinon.assert.calledWith(this.fakeGet, 'https://debuglogs.org', {
|
||||
responseType: 'json',
|
||||
headers: { 'User-Agent': 'Signal-Desktop/1.2.3 Linux' },
|
||||
timeout: { request: durations.MINUTE },
|
||||
});
|
||||
|
||||
const compressedContent = await gzip('hello world');
|
||||
|
@ -54,6 +57,7 @@ describe('upload', () => {
|
|||
sinon.assert.calledOnce(this.fakePost);
|
||||
sinon.assert.calledWith(this.fakePost, 'https://example.com/fake-upload', {
|
||||
headers: { 'User-Agent': 'Signal-Desktop/1.2.3 Linux' },
|
||||
timeout: { request: durations.MINUTE },
|
||||
body: sinon.match((value: unknown) => {
|
||||
if (!(value instanceof FormData)) {
|
||||
return false;
|
||||
|
@ -76,7 +80,7 @@ describe('upload', () => {
|
|||
|
||||
let err: unknown;
|
||||
try {
|
||||
await upload('hello world', '1.2.3');
|
||||
await upload({ content: 'hello world', appVersion: '1.2.3', logger });
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
|
@ -102,7 +106,7 @@ describe('upload', () => {
|
|||
try {
|
||||
// Again, these should be run serially.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await upload('hello world', '1.2.3');
|
||||
await upload({ content: 'hello world', appVersion: '1.2.3', logger });
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
|
@ -115,7 +119,7 @@ describe('upload', () => {
|
|||
|
||||
let err: unknown;
|
||||
try {
|
||||
await upload('hello world', '1.2.3');
|
||||
await upload({ content: 'hello world', appVersion: '1.2.3', logger });
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
|
|
|
@ -7888,56 +7888,56 @@
|
|||
},
|
||||
{
|
||||
"rule": "jQuery-append(",
|
||||
"path": "ts/logging/debuglogs.js",
|
||||
"path": "ts/logging/uploadDebugLog.js",
|
||||
"line": " form.append('key', uploadKey);",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2020-12-17T18:08:07.752Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-append(",
|
||||
"path": "ts/logging/debuglogs.js",
|
||||
"path": "ts/logging/uploadDebugLog.js",
|
||||
"line": " form.append(key, value);",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2020-12-17T18:08:07.752Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-append(",
|
||||
"path": "ts/logging/debuglogs.js",
|
||||
"path": "ts/logging/uploadDebugLog.js",
|
||||
"line": " form.append('Content-Type', contentType);",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2020-12-17T18:08:07.752Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-append(",
|
||||
"path": "ts/logging/debuglogs.js",
|
||||
"path": "ts/logging/uploadDebugLog.js",
|
||||
"line": " form.append('file', contentBuffer, {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2020-12-17T18:08:07.752Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-append(",
|
||||
"path": "ts/logging/debuglogs.ts",
|
||||
"path": "ts/logging/uploadDebugLog.ts",
|
||||
"line": " form.append('key', uploadKey);",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2020-12-17T18:08:07.752Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-append(",
|
||||
"path": "ts/logging/debuglogs.ts",
|
||||
"path": "ts/logging/uploadDebugLog.ts",
|
||||
"line": " form.append(key, value);",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2020-12-17T18:08:07.752Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-append(",
|
||||
"path": "ts/logging/debuglogs.ts",
|
||||
"path": "ts/logging/uploadDebugLog.ts",
|
||||
"line": " form.append('Content-Type', contentType);",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2020-12-17T18:08:07.752Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-append(",
|
||||
"path": "ts/logging/debuglogs.ts",
|
||||
"path": "ts/logging/uploadDebugLog.ts",
|
||||
"line": " form.append('file', contentBuffer, {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2020-12-17T18:08:07.752Z"
|
||||
|
|
5
ts/window.d.ts
vendored
5
ts/window.d.ts
vendored
|
@ -190,6 +190,11 @@ declare global {
|
|||
baseAttachmentsPath: string;
|
||||
baseStickersPath: string;
|
||||
baseTempPath: string;
|
||||
crashReports: {
|
||||
getCount: () => Promise<number>;
|
||||
upload: () => Promise<void>;
|
||||
erase: () => Promise<void>;
|
||||
};
|
||||
drawAttention: () => void;
|
||||
enterKeyboardMode: () => void;
|
||||
enterMouseMode: () => void;
|
||||
|
|
|
@ -8,6 +8,8 @@ import { contextBridge, ipcRenderer } from 'electron';
|
|||
import { SignalContext } from '../context';
|
||||
import { DebugLogWindow } from '../../components/DebugLogWindow';
|
||||
import * as debugLog from '../../logging/debuglogs';
|
||||
import { upload } from '../../logging/uploadDebugLog';
|
||||
import * as logger from '../../logging/log';
|
||||
|
||||
contextBridge.exposeInMainWorld('SignalContext', {
|
||||
...SignalContext,
|
||||
|
@ -32,7 +34,11 @@ contextBridge.exposeInMainWorld('SignalContext', {
|
|||
);
|
||||
},
|
||||
uploadLogs(logs: string) {
|
||||
return debugLog.upload(logs, SignalContext.getVersion());
|
||||
return upload({
|
||||
content: logs,
|
||||
appVersion: SignalContext.getVersion(),
|
||||
logger,
|
||||
});
|
||||
},
|
||||
}),
|
||||
document.getElementById('app')
|
||||
|
|
|
@ -71,3 +71,9 @@ window.getMediaPermissions = () => ipc.invoke('settings:get:mediaPermissions');
|
|||
|
||||
window.getMediaCameraPermissions = () =>
|
||||
ipc.invoke('settings:get:mediaCameraPermissions');
|
||||
|
||||
window.crashReports = {
|
||||
getCount: () => ipc.invoke('crash-reports:get-count'),
|
||||
upload: () => ipc.invoke('crash-reports:upload'),
|
||||
erase: () => ipc.invoke('crash-reports:erase'),
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue