Detect startup after recent crashes

This commit is contained in:
Fedor Indutny 2022-01-11 12:02:46 -08:00 committed by GitHub
parent 02a732c511
commit 91f1b62bc7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 650 additions and 101 deletions

View file

@ -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
View 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);
});
}

View file

@ -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
View 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;
}

View file

@ -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

View 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')}
/>
);
});

View 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>
);
}

View file

@ -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', () => (

View file

@ -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>
);
};

View file

@ -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} =========`;

View 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}`;
};

View 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);
});

View file

@ -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,

View 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;
}

View file

@ -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,

View 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);

View file

@ -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),
};
};

View file

@ -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;

View file

@ -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;
}

View file

@ -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
View file

@ -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;

View file

@ -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')

View file

@ -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'),
};