diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 518e70382..9e6a02d6c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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." diff --git a/app/crashReports.ts b/app/crashReports.ts new file mode 100644 index 000000000..3cb60bece --- /dev/null +++ b/app/crashReports.ts @@ -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> { + 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 +): Promise { + 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); + }); +} diff --git a/app/main.ts b/app/main.ts index 13c03137e..1f8a27289 100644 --- a/app/main.ts +++ b/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) { 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, '**', '*')); diff --git a/protos/CrashReports.proto b/protos/CrashReports.proto new file mode 100644 index 000000000..9542f53cb --- /dev/null +++ b/protos/CrashReports.proto @@ -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; +} diff --git a/ts/background.ts b/ts/background.ts index cd91dae82..bb44bc64b 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -982,6 +982,10 @@ export async function startApp(): Promise { 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 { 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 diff --git a/ts/components/CrashReportDialog.stories.tsx b/ts/components/CrashReportDialog.stories.tsx new file mode 100644 index 000000000..1c38649b3 --- /dev/null +++ b/ts/components/CrashReportDialog.stories.tsx @@ -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 ( + { + setIsPending(true); + action('uploadCrashReports')(); + await sleep(5000); + setIsPending(false); + }} + eraseCrashReports={action('eraseCrashReports')} + /> + ); +}); diff --git a/ts/components/CrashReportDialog.tsx b/ts/components/CrashReportDialog.tsx new file mode 100644 index 000000000..78b513140 --- /dev/null +++ b/ts/components/CrashReportDialog.tsx @@ -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): 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 ( + +
{i18n('CrashReportDialog__body')}
+ + + + +
+ ); +} diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index f7e59875d..977655f7e 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -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 => ({ ['idle', 'required', 'pending'], 'idle' ), + crashReportCount: select('challengeReportCount', [0, 1], 0), setChallengeStatus: action('setChallengeStatus'), renderExpiredBuildDialog: () =>
, renderMainHeader: () =>
, @@ -134,6 +136,14 @@ const useProps = (overrideProps: Partial = {}): PropsType => ({ onSkip={action('onCaptchaSkip')} /> ), + renderCrashReportDialog: () => ( + + ), selectedConversationId: undefined, selectedMessageId: undefined, savePreferredLeftPaneWidth: action('savePreferredLeftPaneWidth'), @@ -633,6 +643,24 @@ story.add('Captcha dialog: pending', () => ( /> )); +// Crash report flow + +story.add('Crash report dialog', () => ( + +)); + // Set group metadata story.add('Group Metadata: No Timer', () => ( diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index 074db6696..79f6b1ea9 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -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 = ({ cantAddContactToGroup, canResizeLeftPane, challengeStatus, + crashReportCount, clearGroupCreationError, clearSearch, closeCantAddContactToGroupModal, @@ -165,6 +168,7 @@ export const LeftPane: React.FC = ({ openConversationInternal, preferredWidthFromStorage, renderCaptchaDialog, + renderCrashReportDialog, renderExpiredBuildDialog, renderMainHeader, renderMessageSearchResult, @@ -641,6 +645,7 @@ export const LeftPane: React.FC = ({ setChallengeStatus('idle'); }, })} + {crashReportCount > 0 && renderCrashReportDialog()}
); }; diff --git a/ts/logging/debuglogs.ts b/ts/logging/debuglogs.ts index e5fa354e3..d431f60e6 100644 --- a/ts/logging/debuglogs.ts +++ b/ts/logging/debuglogs.ts @@ -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; 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 => { - 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; - 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} =========`; diff --git a/ts/logging/uploadDebugLog.ts b/ts/logging/uploadDebugLog.ts new file mode 100644 index 000000000..9fac6020b --- /dev/null +++ b/ts/logging/uploadDebugLog.ts @@ -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; 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 => { + 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; + 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}`; +}; diff --git a/ts/scripts/unpack-crash-reports.ts b/ts/scripts/unpack-crash-reports.ts new file mode 100644 index 000000000..8144916b4 --- /dev/null +++ b/ts/scripts/unpack-crash-reports.ts @@ -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); +}); diff --git a/ts/state/actions.ts b/ts/state/actions.ts index 44ffede0f..31b806d7e 100644 --- a/ts/state/actions.ts +++ b/ts/state/actions.ts @@ -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, diff --git a/ts/state/ducks/crashReports.ts b/ts/state/ducks/crashReports.ts new file mode 100644 index 000000000..70edf096f --- /dev/null +++ b/ts/state/ducks/crashReports.ts @@ -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: Type; + payload: Promise; + } + | { + type: `${Type}_PENDING`; + } + | { + type: `${Type}_FULFILLED`; + payload: Payload; + } + | { + type: `${Type}_REJECTED`; + error: true; + payload: Error; + }; + +type CrashReportsActionType = + | SetCrashReportCountActionType + | PromiseAction + | PromiseAction; + +// Action Creators + +export const actions = { + setCrashReportCount, + uploadCrashReports, + eraseCrashReports, +}; + +function setCrashReportCount(count: number): SetCrashReportCountActionType { + return { type: SET_COUNT, payload: count }; +} + +function uploadCrashReports(): PromiseAction { + return { type: UPLOAD, payload: window.crashReports.upload() }; +} + +function eraseCrashReports(): PromiseAction { + return { type: ERASE, payload: window.crashReports.erase() }; +} + +// Reducer + +export function getEmptyState(): CrashReportsStateType { + return { + count: 0, + isPending: false, + }; +} + +export function reducer( + state: Readonly = getEmptyState(), + action: Readonly +): 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; +} diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index 0ba6736aa..58edeb315 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -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, diff --git a/ts/state/smart/CrashReportDialog.tsx b/ts/state/smart/CrashReportDialog.tsx new file mode 100644 index 000000000..6453a102c --- /dev/null +++ b/ts/state/smart/CrashReportDialog.tsx @@ -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); diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index 986bb4093..6af7e4fa0 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -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 ; } +function renderCrashReportDialog(): JSX.Element { + return ; +} 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), }; }; diff --git a/ts/state/types.ts b/ts/state/types.ts index 684b4eb2e..141ef5298 100644 --- a/ts/state/types.ts +++ b/ts/state/types.ts @@ -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; diff --git a/ts/test-node/logging/uploadDebugLogs_test.ts b/ts/test-node/logging/uploadDebugLogs_test.ts index 67159ee64..592c4d3d7 100644 --- a/ts/test-node/logging/uploadDebugLogs_test.ts +++ b/ts/test-node/logging/uploadDebugLogs_test.ts @@ -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 = 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; } diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 77833ed12..e13caf3d4 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -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" @@ -8369,4 +8369,4 @@ "reasonCategory": "usageTrusted", "updated": "2021-09-17T21:02:59.414Z" } -] \ No newline at end of file +] diff --git a/ts/window.d.ts b/ts/window.d.ts index 7153d5a3b..3a4251c8e 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -190,6 +190,11 @@ declare global { baseAttachmentsPath: string; baseStickersPath: string; baseTempPath: string; + crashReports: { + getCount: () => Promise; + upload: () => Promise; + erase: () => Promise; + }; drawAttention: () => void; enterKeyboardMode: () => void; enterMouseMode: () => void; diff --git a/ts/windows/debuglog/preload.ts b/ts/windows/debuglog/preload.ts index 7e4e8174f..1a68f2422 100644 --- a/ts/windows/debuglog/preload.ts +++ b/ts/windows/debuglog/preload.ts @@ -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') diff --git a/ts/windows/preload.ts b/ts/windows/preload.ts index cf31d395b..7e5a7fa88 100644 --- a/ts/windows/preload.ts +++ b/ts/windows/preload.ts @@ -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'), +};