Move all files under /app to typescript

This commit is contained in:
Scott Nonnenberg 2021-06-18 10:04:27 -07:00 committed by GitHub
parent 7bb6ad534f
commit 24960d481e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 745 additions and 620 deletions

View file

@ -24,6 +24,7 @@ libtextsecure/test/blanket_mocha.js
test/blanket_mocha.js test/blanket_mocha.js
# TypeScript generated files # TypeScript generated files
app/**/*.js
ts/**/*.js ts/**/*.js
sticker-creator/**/*.js sticker-creator/**/*.js
!sticker-creator/preload.js !sticker-creator/preload.js

View file

@ -143,7 +143,7 @@ module.exports = {
overrides: [ overrides: [
{ {
files: ['ts/**/*.ts', 'ts/**/*.tsx'], files: ['ts/**/*.ts', 'ts/**/*.tsx', 'app/**/*.ts'],
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
parserOptions: { parserOptions: {
project: 'tsconfig.json', project: 'tsconfig.json',

1
.gitignore vendored
View file

@ -26,6 +26,7 @@ stylesheets/*.css
test/test.js test/test.js
# React / TypeScript # React / TypeScript
app/*.js
ts/**/*.js ts/**/*.js
ts/protobuf/*.d.ts ts/protobuf/*.d.ts
sticker-creator/**/*.js sticker-creator/**/*.js

View file

@ -2,6 +2,7 @@
# supports `.gitignore`: https://github.com/prettier/prettier/issues/2294 # supports `.gitignore`: https://github.com/prettier/prettier/issues/2294
# Generated files # Generated files
app/**/*.js
config/local-*.json config/local-*.json
config/local.json config/local.json
dist/** dist/**

View file

@ -3120,33 +3120,6 @@ Signal Desktop makes use of the following open source projects.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
## to-arraybuffer
The MIT License
Copyright (c) 2016 John Hiesey
Permission is hereby granted, free of charge,
to any person obtaining a copy of this software and
associated documentation files (the "Software"), to
deal in the Software without restriction, including
without limitation the rights to use, copy, modify,
merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom
the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR
ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## typeface-inter ## typeface-inter
Copyright (c) 2016-2018 The Inter Project Authors (me@rsms.me) Copyright (c) 2016-2018 The Inter Project Authors (me@rsms.me)

View file

@ -1,9 +1,12 @@
// Copyright 2018-2020 Signal Messenger, LLC // Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
module.exports = { // For reference: https://github.com/airbnb/javascript
rules: {
// On the node.js side, we're still using console.log const rules = {
'no-console': 'off', 'no-console': 'off',
}, };
module.exports = {
rules,
}; };

View file

@ -1,15 +1,14 @@
// Copyright 2018-2020 Signal Messenger, LLC // Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
const electron = require('electron'); import { ipcMain } from 'electron';
const rimraf = require('rimraf'); import * as rimraf from 'rimraf';
const Attachments = require('./attachments'); import {
getPath,
const { ipcMain } = electron; getStickersPath,
getTempPath,
module.exports = { getDraftPath,
initialize, } from './attachments';
};
let initialized = false; let initialized = false;
@ -19,16 +18,22 @@ const ERASE_TEMP_KEY = 'erase-temp';
const ERASE_DRAFTS_KEY = 'erase-drafts'; const ERASE_DRAFTS_KEY = 'erase-drafts';
const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments'; const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments';
function initialize({ configDir, cleanupOrphanedAttachments }) { export function initialize({
configDir,
cleanupOrphanedAttachments,
}: {
configDir: string;
cleanupOrphanedAttachments: () => Promise<void>;
}): void {
if (initialized) { if (initialized) {
throw new Error('initialze: Already initialized!'); throw new Error('initialze: Already initialized!');
} }
initialized = true; initialized = true;
const attachmentsDir = Attachments.getPath(configDir); const attachmentsDir = getPath(configDir);
const stickersDir = Attachments.getStickersPath(configDir); const stickersDir = getStickersPath(configDir);
const tempDir = Attachments.getTempPath(configDir); const tempDir = getTempPath(configDir);
const draftDir = Attachments.getDraftPath(configDir); const draftDir = getDraftPath(configDir);
ipcMain.on(ERASE_TEMP_KEY, event => { ipcMain.on(ERASE_TEMP_KEY, event => {
try { try {

View file

@ -1,4 +0,0 @@
// Copyright 2019-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export function getTempPath(userDataPath: string): string;

View file

@ -1,26 +1,30 @@
// Copyright 2018-2020 Signal Messenger, LLC // Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
const crypto = require('crypto'); import { randomBytes } from 'crypto';
const path = require('path'); import { basename, extname, join, normalize, relative } from 'path';
const { app, dialog, shell, remote } = require('electron'); import { app, dialog, shell, remote } from 'electron';
const fastGlob = require('fast-glob'); import fastGlob from 'fast-glob';
const glob = require('glob'); import glob from 'glob';
const pify = require('pify'); import pify from 'pify';
const fse = require('fs-extra'); import fse from 'fs-extra';
const toArrayBuffer = require('to-arraybuffer'); import { map, isArrayBuffer, isString } from 'lodash';
const { map, isArrayBuffer, isString } = require('lodash'); import normalizePath from 'normalize-path';
const normalizePath = require('normalize-path'); import sanitizeFilename from 'sanitize-filename';
const sanitizeFilename = require('sanitize-filename'); import getGuid from 'uuid/v4';
const getGuid = require('uuid/v4');
const { isPathInside } = require('../ts/util/isPathInside'); import { typedArrayToArrayBuffer } from '../ts/Crypto';
const { isWindows } = require('../ts/OS'); import { isPathInside } from '../ts/util/isPathInside';
const { import { isWindows } from '../ts/OS';
writeWindowsZoneIdentifier, import { writeWindowsZoneIdentifier } from '../ts/util/windowsZoneIdentifier';
} = require('../ts/util/windowsZoneIdentifier');
type FSAttrType = {
set: (path: string, attribute: string, value: string) => Promise<void>;
};
let xattr: FSAttrType | undefined;
let xattr;
try { try {
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
// eslint-disable-next-line global-require, import/no-extraneous-dependencies, import/no-unresolved // eslint-disable-next-line global-require, import/no-extraneous-dependencies, import/no-unresolved
@ -36,113 +40,115 @@ const DRAFT_PATH = 'drafts.noindex';
const getApp = () => app || remote.app; const getApp = () => app || remote.app;
exports.getAllAttachments = async userDataPath => { export const getAllAttachments = async (
const dir = exports.getPath(userDataPath); userDataPath: string
const pattern = normalizePath(path.join(dir, '**', '*')); ): Promise<ReadonlyArray<string>> => {
const dir = getPath(userDataPath);
const pattern = normalizePath(join(dir, '**', '*'));
const files = await fastGlob(pattern, { onlyFiles: true }); const files = await fastGlob(pattern, { onlyFiles: true });
return map(files, file => path.relative(dir, file)); return map(files, file => relative(dir, file));
}; };
exports.getAllStickers = async userDataPath => { export const getAllStickers = async (
const dir = exports.getStickersPath(userDataPath); userDataPath: string
const pattern = normalizePath(path.join(dir, '**', '*')); ): Promise<ReadonlyArray<string>> => {
const dir = getStickersPath(userDataPath);
const pattern = normalizePath(join(dir, '**', '*'));
const files = await fastGlob(pattern, { onlyFiles: true }); const files = await fastGlob(pattern, { onlyFiles: true });
return map(files, file => path.relative(dir, file)); return map(files, file => relative(dir, file));
}; };
exports.getAllDraftAttachments = async userDataPath => { export const getAllDraftAttachments = async (
const dir = exports.getDraftPath(userDataPath); userDataPath: string
const pattern = normalizePath(path.join(dir, '**', '*')); ): Promise<ReadonlyArray<string>> => {
const dir = getDraftPath(userDataPath);
const pattern = normalizePath(join(dir, '**', '*'));
const files = await fastGlob(pattern, { onlyFiles: true }); const files = await fastGlob(pattern, { onlyFiles: true });
return map(files, file => path.relative(dir, file)); return map(files, file => relative(dir, file));
}; };
exports.getBuiltInImages = async () => { export const getBuiltInImages = async (): Promise<ReadonlyArray<string>> => {
const dir = path.join(__dirname, '../images'); const dir = join(__dirname, '../images');
const pattern = path.join(dir, '**', '*.svg'); const pattern = join(dir, '**', '*.svg');
// Note: we cannot use fast-glob here because, inside of .asar files, readdir will not // Note: we cannot use fast-glob here because, inside of .asar files, readdir will not
// honor the withFileTypes flag: https://github.com/electron/electron/issues/19074 // honor the withFileTypes flag: https://github.com/electron/electron/issues/19074
const files = await pify(glob)(pattern, { nodir: true }); const files = await pify(glob)(pattern, { nodir: true });
return map(files, file => path.relative(dir, file)); return map(files, file => relative(dir, file));
}; };
// getPath :: AbsolutePath -> AbsolutePath export const getPath = (userDataPath: string): string => {
exports.getPath = userDataPath => {
if (!isString(userDataPath)) { if (!isString(userDataPath)) {
throw new TypeError("'userDataPath' must be a string"); throw new TypeError("'userDataPath' must be a string");
} }
return path.join(userDataPath, PATH); return join(userDataPath, PATH);
}; };
// getStickersPath :: AbsolutePath -> AbsolutePath export const getStickersPath = (userDataPath: string): string => {
exports.getStickersPath = userDataPath => {
if (!isString(userDataPath)) { if (!isString(userDataPath)) {
throw new TypeError("'userDataPath' must be a string"); throw new TypeError("'userDataPath' must be a string");
} }
return path.join(userDataPath, STICKER_PATH); return join(userDataPath, STICKER_PATH);
}; };
// getTempPath :: AbsolutePath -> AbsolutePath export const getTempPath = (userDataPath: string): string => {
exports.getTempPath = userDataPath => {
if (!isString(userDataPath)) { if (!isString(userDataPath)) {
throw new TypeError("'userDataPath' must be a string"); throw new TypeError("'userDataPath' must be a string");
} }
return path.join(userDataPath, TEMP_PATH); return join(userDataPath, TEMP_PATH);
}; };
// getDraftPath :: AbsolutePath -> AbsolutePath export const getDraftPath = (userDataPath: string): string => {
exports.getDraftPath = userDataPath => {
if (!isString(userDataPath)) { if (!isString(userDataPath)) {
throw new TypeError("'userDataPath' must be a string"); throw new TypeError("'userDataPath' must be a string");
} }
return path.join(userDataPath, DRAFT_PATH); return join(userDataPath, DRAFT_PATH);
}; };
// clearTempPath :: AbsolutePath -> AbsolutePath export const clearTempPath = (userDataPath: string): Promise<void> => {
exports.clearTempPath = userDataPath => { const tempPath = getTempPath(userDataPath);
const tempPath = exports.getTempPath(userDataPath);
return fse.emptyDir(tempPath); return fse.emptyDir(tempPath);
}; };
// createReader :: AttachmentsPath -> export const createReader = (
// RelativePath -> root: string
// IO (Promise ArrayBuffer) ): ((relativePath: string) => Promise<ArrayBuffer>) => {
exports.createReader = root => {
if (!isString(root)) { if (!isString(root)) {
throw new TypeError("'root' must be a path"); throw new TypeError("'root' must be a path");
} }
return async relativePath => { return async (relativePath: string): Promise<ArrayBuffer> => {
if (!isString(relativePath)) { if (!isString(relativePath)) {
throw new TypeError("'relativePath' must be a string"); throw new TypeError("'relativePath' must be a string");
} }
const absolutePath = path.join(root, relativePath); const absolutePath = join(root, relativePath);
const normalized = path.normalize(absolutePath); const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) { if (!isPathInside(normalized, root)) {
throw new Error('Invalid relative path'); throw new Error('Invalid relative path');
} }
const buffer = await fse.readFile(normalized); const buffer = await fse.readFile(normalized);
return toArrayBuffer(buffer); return typedArrayToArrayBuffer(buffer);
}; };
}; };
exports.createDoesExist = root => { export const createDoesExist = (
root: string
): ((relativePath: string) => Promise<boolean>) => {
if (!isString(root)) { if (!isString(root)) {
throw new TypeError("'root' must be a path"); throw new TypeError("'root' must be a path");
} }
return async relativePath => { return async (relativePath: string): Promise<boolean> => {
if (!isString(relativePath)) { if (!isString(relativePath)) {
throw new TypeError("'relativePath' must be a string"); throw new TypeError("'relativePath' must be a string");
} }
const absolutePath = path.join(root, relativePath); const absolutePath = join(root, relativePath);
const normalized = path.normalize(absolutePath); const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) { if (!isPathInside(normalized, root)) {
throw new Error('Invalid relative path'); throw new Error('Invalid relative path');
} }
@ -155,14 +161,16 @@ exports.createDoesExist = root => {
}; };
}; };
exports.copyIntoAttachmentsDirectory = root => { export const copyIntoAttachmentsDirectory = (
root: string
): ((sourcePath: string) => Promise<string>) => {
if (!isString(root)) { if (!isString(root)) {
throw new TypeError("'root' must be a path"); throw new TypeError("'root' must be a path");
} }
const userDataPath = getApp().getPath('userData'); const userDataPath = getApp().getPath('userData');
return async sourcePath => { return async (sourcePath: string): Promise<string> => {
if (!isString(sourcePath)) { if (!isString(sourcePath)) {
throw new TypeError('sourcePath must be a string'); throw new TypeError('sourcePath must be a string');
} }
@ -173,10 +181,10 @@ exports.copyIntoAttachmentsDirectory = root => {
); );
} }
const name = exports.createName(); const name = createName();
const relativePath = exports.getRelativePath(name); const relativePath = getRelativePath(name);
const absolutePath = path.join(root, relativePath); const absolutePath = join(root, relativePath);
const normalized = path.normalize(absolutePath); const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) { if (!isPathInside(normalized, root)) {
throw new Error('Invalid relative path'); throw new Error('Invalid relative path');
} }
@ -187,15 +195,22 @@ exports.copyIntoAttachmentsDirectory = root => {
}; };
}; };
exports.writeToDownloads = async ({ data, name }) => { export const writeToDownloads = async ({
data,
name,
}: {
data: ArrayBuffer;
name: string;
}): Promise<{ fullPath: string; name: string }> => {
const appToUse = getApp(); const appToUse = getApp();
const downloadsPath = const downloadsPath =
appToUse.getPath('downloads') || appToUse.getPath('home'); appToUse.getPath('downloads') || appToUse.getPath('home');
const sanitized = sanitizeFilename(name); const sanitized = sanitizeFilename(name);
const extension = path.extname(sanitized); const extension = extname(sanitized);
const basename = path.basename(sanitized, extension); const fileBasename = basename(sanitized, extension);
const getCandidateName = count => `${basename} (${count})${extension}`; const getCandidateName = (count: number) =>
`${fileBasename} (${count})${extension}`;
const existingFiles = await fse.readdir(downloadsPath); const existingFiles = await fse.readdir(downloadsPath);
let candidateName = sanitized; let candidateName = sanitized;
@ -205,13 +220,13 @@ exports.writeToDownloads = async ({ data, name }) => {
candidateName = getCandidateName(count); candidateName = getCandidateName(count);
} }
const target = path.join(downloadsPath, candidateName); const target = join(downloadsPath, candidateName);
const normalized = path.normalize(target); const normalized = normalize(target);
if (!isPathInside(normalized, downloadsPath)) { if (!isPathInside(normalized, downloadsPath)) {
throw new Error('Invalid filename!'); throw new Error('Invalid filename!');
} }
await writeWithAttributes(normalized, Buffer.from(data)); await writeWithAttributes(normalized, data);
return { return {
fullPath: normalized, fullPath: normalized,
@ -219,7 +234,10 @@ exports.writeToDownloads = async ({ data, name }) => {
}; };
}; };
async function writeWithAttributes(target, data) { async function writeWithAttributes(
target: string,
data: ArrayBuffer
): Promise<void> {
await fse.writeFile(target, Buffer.from(data)); await fse.writeFile(target, Buffer.from(data));
if (process.platform === 'darwin' && xattr) { if (process.platform === 'darwin' && xattr) {
@ -246,15 +264,15 @@ async function writeWithAttributes(target, data) {
} }
} }
exports.openFileInDownloads = async name => { export const openFileInDownloads = async (name: string): Promise<void> => {
const shellToUse = shell || remote.shell; const shellToUse = shell || remote.shell;
const appToUse = getApp(); const appToUse = getApp();
const downloadsPath = const downloadsPath =
appToUse.getPath('downloads') || appToUse.getPath('home'); appToUse.getPath('downloads') || appToUse.getPath('home');
const target = path.join(downloadsPath, name); const target = join(downloadsPath, name);
const normalized = path.normalize(target); const normalized = normalize(target);
if (!isPathInside(normalized, downloadsPath)) { if (!isPathInside(normalized, downloadsPath)) {
throw new Error('Invalid filename!'); throw new Error('Invalid filename!');
} }
@ -262,7 +280,13 @@ exports.openFileInDownloads = async name => {
shellToUse.showItemInFolder(normalized); shellToUse.showItemInFolder(normalized);
}; };
exports.saveAttachmentToDisk = async ({ data, name }) => { export const saveAttachmentToDisk = async ({
data,
name,
}: {
data: ArrayBuffer;
name: string;
}): Promise<null | { fullPath: string; name: string }> => {
const dialogToUse = dialog || remote.dialog; const dialogToUse = dialog || remote.dialog;
const browserWindow = remote.getCurrentWindow(); const browserWindow = remote.getCurrentWindow();
@ -273,57 +297,61 @@ exports.saveAttachmentToDisk = async ({ data, name }) => {
} }
); );
if (canceled) { if (canceled || !filePath) {
return null; return null;
} }
await writeWithAttributes(filePath, Buffer.from(data)); await writeWithAttributes(filePath, data);
const basename = path.basename(filePath); const fileBasename = basename(filePath);
return { return {
fullPath: filePath, fullPath: filePath,
name: basename, name: fileBasename,
}; };
}; };
exports.openFileInFolder = async target => { export const openFileInFolder = async (target: string): Promise<void> => {
const shellToUse = shell || remote.shell; const shellToUse = shell || remote.shell;
shellToUse.showItemInFolder(target); shellToUse.showItemInFolder(target);
}; };
// createWriterForNew :: AttachmentsPath -> export const createWriterForNew = (
// ArrayBuffer -> root: string
// IO (Promise RelativePath) ): ((arrayBuffer: ArrayBuffer) => Promise<string>) => {
exports.createWriterForNew = root => {
if (!isString(root)) { if (!isString(root)) {
throw new TypeError("'root' must be a path"); throw new TypeError("'root' must be a path");
} }
return async arrayBuffer => { return async (arrayBuffer: ArrayBuffer) => {
if (!isArrayBuffer(arrayBuffer)) { if (!isArrayBuffer(arrayBuffer)) {
throw new TypeError("'arrayBuffer' must be an array buffer"); throw new TypeError("'arrayBuffer' must be an array buffer");
} }
const name = exports.createName(); const name = createName();
const relativePath = exports.getRelativePath(name); const relativePath = getRelativePath(name);
return exports.createWriterForExisting(root)({ return createWriterForExisting(root)({
data: arrayBuffer, data: arrayBuffer,
path: relativePath, path: relativePath,
}); });
}; };
}; };
// createWriter :: AttachmentsPath -> export const createWriterForExisting = (
// { data: ArrayBuffer, path: RelativePath } -> root: string
// IO (Promise RelativePath) ): ((options: { data: ArrayBuffer; path: string }) => Promise<string>) => {
exports.createWriterForExisting = root => {
if (!isString(root)) { if (!isString(root)) {
throw new TypeError("'root' must be a path"); throw new TypeError("'root' must be a path");
} }
return async ({ data: arrayBuffer, path: relativePath } = {}) => { return async ({
data: arrayBuffer,
path: relativePath,
}: {
data: ArrayBuffer;
path: string;
}): Promise<string> => {
if (!isString(relativePath)) { if (!isString(relativePath)) {
throw new TypeError("'relativePath' must be a path"); throw new TypeError("'relativePath' must be a path");
} }
@ -333,8 +361,8 @@ exports.createWriterForExisting = root => {
} }
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
const absolutePath = path.join(root, relativePath); const absolutePath = join(root, relativePath);
const normalized = path.normalize(absolutePath); const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) { if (!isPathInside(normalized, root)) {
throw new Error('Invalid relative path'); throw new Error('Invalid relative path');
} }
@ -345,21 +373,20 @@ exports.createWriterForExisting = root => {
}; };
}; };
// createDeleter :: AttachmentsPath -> export const createDeleter = (
// RelativePath -> root: string
// IO Unit ): ((relativePath: string) => Promise<void>) => {
exports.createDeleter = root => {
if (!isString(root)) { if (!isString(root)) {
throw new TypeError("'root' must be a path"); throw new TypeError("'root' must be a path");
} }
return async relativePath => { return async (relativePath: string): Promise<void> => {
if (!isString(relativePath)) { if (!isString(relativePath)) {
throw new TypeError("'relativePath' must be a string"); throw new TypeError("'relativePath' must be a string");
} }
const absolutePath = path.join(root, relativePath); const absolutePath = join(root, relativePath);
const normalized = path.normalize(absolutePath); const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) { if (!isPathInside(normalized, root)) {
throw new Error('Invalid relative path'); throw new Error('Invalid relative path');
} }
@ -367,8 +394,14 @@ exports.createDeleter = root => {
}; };
}; };
exports.deleteAll = async ({ userDataPath, attachments }) => { export const deleteAll = async ({
const deleteFromDisk = exports.createDeleter(exports.getPath(userDataPath)); userDataPath,
attachments,
}: {
userDataPath: string;
attachments: ReadonlyArray<string>;
}): Promise<void> => {
const deleteFromDisk = createDeleter(getPath(userDataPath));
for (let index = 0, max = attachments.length; index < max; index += 1) { for (let index = 0, max = attachments.length; index < max; index += 1) {
const file = attachments[index]; const file = attachments[index];
@ -379,10 +412,14 @@ exports.deleteAll = async ({ userDataPath, attachments }) => {
console.log(`deleteAll: deleted ${attachments.length} files`); console.log(`deleteAll: deleted ${attachments.length} files`);
}; };
exports.deleteAllStickers = async ({ userDataPath, stickers }) => { export const deleteAllStickers = async ({
const deleteFromDisk = exports.createDeleter( userDataPath,
exports.getStickersPath(userDataPath) stickers,
); }: {
userDataPath: string;
stickers: ReadonlyArray<string>;
}): Promise<void> => {
const deleteFromDisk = createDeleter(getStickersPath(userDataPath));
for (let index = 0, max = stickers.length; index < max; index += 1) { for (let index = 0, max = stickers.length; index < max; index += 1) {
const file = stickers[index]; const file = stickers[index];
@ -393,40 +430,43 @@ exports.deleteAllStickers = async ({ userDataPath, stickers }) => {
console.log(`deleteAllStickers: deleted ${stickers.length} files`); console.log(`deleteAllStickers: deleted ${stickers.length} files`);
}; };
exports.deleteAllDraftAttachments = async ({ userDataPath, stickers }) => { export const deleteAllDraftAttachments = async ({
const deleteFromDisk = exports.createDeleter( userDataPath,
exports.getDraftPath(userDataPath) attachments,
); }: {
userDataPath: string;
attachments: ReadonlyArray<string>;
}): Promise<void> => {
const deleteFromDisk = createDeleter(getDraftPath(userDataPath));
for (let index = 0, max = stickers.length; index < max; index += 1) { for (let index = 0, max = attachments.length; index < max; index += 1) {
const file = stickers[index]; const file = attachments[index];
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await deleteFromDisk(file); await deleteFromDisk(file);
} }
console.log(`deleteAllDraftAttachments: deleted ${stickers.length} files`); console.log(`deleteAllDraftAttachments: deleted ${attachments.length} files`);
}; };
// createName :: Unit -> IO String export const createName = (): string => {
exports.createName = () => { const buffer = randomBytes(32);
const buffer = crypto.randomBytes(32);
return buffer.toString('hex'); return buffer.toString('hex');
}; };
// getRelativePath :: String -> Path export const getRelativePath = (name: string): string => {
exports.getRelativePath = name => {
if (!isString(name)) { if (!isString(name)) {
throw new TypeError("'name' must be a string"); throw new TypeError("'name' must be a string");
} }
const prefix = name.slice(0, 2); const prefix = name.slice(0, 2);
return path.join(prefix, name); return join(prefix, name);
}; };
// createAbsolutePathGetter :: RootPath -> RelativePath -> AbsolutePath export const createAbsolutePathGetter = (rootPath: string) => (
exports.createAbsolutePathGetter = rootPath => relativePath => { relativePath: string
const absolutePath = path.join(rootPath, relativePath); ): string => {
const normalized = path.normalize(absolutePath); const absolutePath = join(rootPath, relativePath);
const normalized = normalize(absolutePath);
if (!isPathInside(normalized, rootPath)) { if (!isPathInside(normalized, rootPath)) {
throw new Error('Invalid relative path'); throw new Error('Invalid relative path');
} }

View file

@ -1,62 +0,0 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const fs = require('fs');
const _ = require('lodash');
const ENCODING = 'utf8';
module.exports = {
start,
};
function start(name, targetPath, options = {}) {
const { allowMalformedOnStartup } = options;
let cachedValue = null;
try {
const text = fs.readFileSync(targetPath, ENCODING);
cachedValue = JSON.parse(text);
console.log(`config/get: Successfully read ${name} config file`);
if (!cachedValue) {
console.log(
`config/get: ${name} config value was falsy, cache is now empty object`
);
cachedValue = Object.create(null);
}
} catch (error) {
if (!allowMalformedOnStartup && error.code !== 'ENOENT') {
throw error;
}
console.log(
`config/get: Did not find ${name} config file, cache is now empty object`
);
cachedValue = Object.create(null);
}
function get(keyPath) {
return _.get(cachedValue, keyPath);
}
function set(keyPath, value) {
_.set(cachedValue, keyPath, value);
console.log(`config/set: Saving ${name} config to disk`);
const text = JSON.stringify(cachedValue, null, ' ');
fs.writeFileSync(targetPath, text, ENCODING);
}
function remove() {
console.log(`config/remove: Deleting ${name} config from disk`);
fs.unlinkSync(targetPath);
cachedValue = Object.create(null);
}
return {
set,
get,
remove,
};
}

71
app/base_config.ts Normal file
View file

@ -0,0 +1,71 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { readFileSync, writeFileSync, unlinkSync } from 'fs';
import { get, set } from 'lodash';
const ENCODING = 'utf8';
type ConfigType = Record<string, unknown>;
export function start(
name: string,
targetPath: string,
options?: { allowMalformedOnStartup?: boolean }
): {
set: (keyPath: string, value: unknown) => void;
get: (keyPath: string) => unknown;
remove: () => void;
} {
let cachedValue: ConfigType | undefined;
try {
const text = readFileSync(targetPath, ENCODING);
cachedValue = JSON.parse(text);
console.log(`config/get: Successfully read ${name} config file`);
if (!cachedValue) {
console.log(
`config/get: ${name} config value was falsy, cache is now empty object`
);
cachedValue = Object.create(null);
}
} catch (error) {
if (!options?.allowMalformedOnStartup && error.code !== 'ENOENT') {
throw error;
}
console.log(
`config/get: Did not find ${name} config file, cache is now empty object`
);
cachedValue = Object.create(null);
}
function ourGet(keyPath: string): unknown {
return get(cachedValue, keyPath);
}
function ourSet(keyPath: string, value: unknown): void {
if (!cachedValue) {
throw new Error('ourSet: no cachedValue!');
}
set(cachedValue, keyPath, value);
console.log(`config/set: Saving ${name} config to disk`);
const text = JSON.stringify(cachedValue, null, ' ');
writeFileSync(targetPath, text, ENCODING);
}
function remove(): void {
console.log(`config/remove: Deleting ${name} config from disk`);
unlinkSync(targetPath);
cachedValue = Object.create(null);
}
return {
set: ourSet,
get: ourGet,
remove,
};
}

View file

@ -1,14 +1,15 @@
// Copyright 2017-2020 Signal Messenger, LLC // Copyright 2017-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
const path = require('path'); import { join } from 'path';
const { app } = require('electron'); import { app } from 'electron';
const {
import {
Environment, Environment,
getEnvironment, getEnvironment,
setEnvironment, setEnvironment,
parseEnvironment, parseEnvironment,
} = require('../ts/environment'); } from '../ts/environment';
// In production mode, NODE_ENV cannot be customized by the user // In production mode, NODE_ENV cannot be customized by the user
if (app.isPackaged) { if (app.isPackaged) {
@ -19,12 +20,12 @@ if (app.isPackaged) {
// Set environment vars to configure node-config before requiring it // Set environment vars to configure node-config before requiring it
process.env.NODE_ENV = getEnvironment(); process.env.NODE_ENV = getEnvironment();
process.env.NODE_CONFIG_DIR = path.join(__dirname, '..', 'config'); process.env.NODE_CONFIG_DIR = join(__dirname, '..', 'config');
if (getEnvironment() === Environment.Production) { if (getEnvironment() === Environment.Production) {
// harden production config against the local env // harden production config against the local env
process.env.NODE_CONFIG = ''; process.env.NODE_CONFIG = '';
process.env.NODE_CONFIG_STRICT_MODE = true; process.env.NODE_CONFIG_STRICT_MODE = 'true';
process.env.HOSTNAME = ''; process.env.HOSTNAME = '';
process.env.NODE_APP_INSTANCE = ''; process.env.NODE_APP_INSTANCE = '';
process.env.ALLOW_CONFIG_MUTATIONS = ''; process.env.ALLOW_CONFIG_MUTATIONS = '';
@ -33,9 +34,18 @@ if (getEnvironment() === Environment.Production) {
process.env.SIGNAL_ENABLE_HTTP = ''; process.env.SIGNAL_ENABLE_HTTP = '';
} }
export type ConfigType = {
get: (key: string) => unknown;
has: (key: string) => unknown;
[key: string]: unknown;
util: {
getEnv: (keY: string) => string | undefined;
};
};
// We load config after we've made our modifications to NODE_ENV // We load config after we've made our modifications to NODE_ENV
// eslint-disable-next-line import/order // eslint-disable-next-line @typescript-eslint/no-var-requires
const config = require('config'); const config: ConfigType = require('config');
config.environment = getEnvironment(); config.environment = getEnvironment();
config.enableHttp = process.env.SIGNAL_ENABLE_HTTP; config.enableHttp = process.env.SIGNAL_ENABLE_HTTP;
@ -54,4 +64,4 @@ config.enableHttp = process.env.SIGNAL_ENABLE_HTTP;
console.log(`${s} ${config.util.getEnv(s)}`); console.log(`${s} ${config.util.getEnv(s)}`);
}); });
module.exports = config; export default config;

View file

@ -1,17 +0,0 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const path = require('path');
const { app } = require('electron');
const { start } = require('./base_config');
const userDataPath = app.getPath('userData');
const targetPath = path.join(userDataPath, 'ephemeral.json');
const ephemeralConfig = start('ephemeral', targetPath, {
allowMalformedOnStartup: true,
});
module.exports = ephemeralConfig;

19
app/ephemeral_config.ts Normal file
View file

@ -0,0 +1,19 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { join } from 'path';
import { app } from 'electron';
import { start } from './base_config';
const userDataPath = app.getPath('userData');
const targetPath = join(userDataPath, 'ephemeral.json');
const ephemeralConfig = start('ephemeral', targetPath, {
allowMalformedOnStartup: true,
});
export const get = ephemeralConfig.get.bind(ephemeralConfig);
export const remove = ephemeralConfig.remove.bind(ephemeralConfig);
export const set = ephemeralConfig.set.bind(ephemeralConfig);

View file

@ -1,55 +0,0 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const electron = require('electron');
const Errors = require('../js/modules/types/errors');
const { app, dialog, clipboard } = electron;
const { redactAll } = require('../ts/util/privacy');
// We use hard-coded strings until we're able to update these strings from the locale.
let quitText = 'Quit';
let copyErrorAndQuitText = 'Copy error and quit';
function handleError(prefix, error) {
if (console._error) {
console._error(`${prefix}:`, Errors.toLogFormat(error));
}
console.error(`${prefix}:`, Errors.toLogFormat(error));
if (app.isReady()) {
// title field is not shown on macOS, so we don't use it
const buttonIndex = dialog.showMessageBoxSync({
buttons: [quitText, copyErrorAndQuitText],
defaultId: 0,
detail: redactAll(error.stack),
message: prefix,
noLink: true,
type: 'error',
});
if (buttonIndex === 1) {
clipboard.writeText(`${prefix}\n\n${redactAll(error.stack)}`);
}
} else {
dialog.showErrorBox(prefix, error.stack);
}
app.exit(1);
}
exports.updateLocale = messages => {
quitText = messages.quit.message;
copyErrorAndQuitText = messages.copyErrorAndQuit.message;
};
exports.addHandler = () => {
process.on('uncaughtException', error => {
handleError('Unhandled Error', error);
});
process.on('unhandledRejection', error => {
handleError('Unhandled Promise Rejection', error);
});
};

64
app/global_errors.ts Normal file
View file

@ -0,0 +1,64 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { app, dialog, clipboard } from 'electron';
import * as Errors from '../js/modules/types/errors';
import { redactAll } from '../ts/util/privacy';
import { LocaleMessagesType } from '../ts/types/I18N';
import { reallyJsonStringify } from '../ts/util/reallyJsonStringify';
// We use hard-coded strings until we're able to update these strings from the locale.
let quitText = 'Quit';
let copyErrorAndQuitText = 'Copy error and quit';
function handleError(prefix: string, error: Error): void {
if (console._error) {
console._error(`${prefix}:`, Errors.toLogFormat(error));
}
console.error(`${prefix}:`, Errors.toLogFormat(error));
if (app.isReady()) {
// title field is not shown on macOS, so we don't use it
const buttonIndex = dialog.showMessageBoxSync({
buttons: [quitText, copyErrorAndQuitText],
defaultId: 0,
detail: redactAll(error.stack || ''),
message: prefix,
noLink: true,
type: 'error',
});
if (buttonIndex === 1) {
clipboard.writeText(`${prefix}\n\n${redactAll(error.stack || '')}`);
}
} else {
dialog.showErrorBox(prefix, error.stack || '');
}
app.exit(1);
}
export const updateLocale = (messages: LocaleMessagesType): void => {
quitText = messages.quit.message;
copyErrorAndQuitText = messages.copyErrorAndQuit.message;
};
function _getError(reason: unknown): Error {
if (reason instanceof Error) {
return reason;
}
const errorString = reallyJsonStringify(reason);
return new Error(`Promise rejected with a non-error: ${errorString}`);
}
export const addHandler = (): void => {
process.on('uncaughtException', (reason: unknown) => {
handleError('Unhandled Error', _getError(reason));
});
process.on('unhandledRejection', (reason: unknown) => {
handleError('Unhandled Promise Rejection', _getError(reason));
});
};

View file

@ -1,12 +1,15 @@
// Copyright 2017-2020 Signal Messenger, LLC // Copyright 2017-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
const path = require('path'); import { join } from 'path';
const fs = require('fs'); import { readFileSync } from 'fs';
const _ = require('lodash'); import { merge } from 'lodash';
const { setup } = require('../js/modules/i18n'); import { setup } from '../js/modules/i18n';
function normalizeLocaleName(locale) { import { LoggerType } from '../ts/types/Logging';
import { LocalizerType, LocaleMessagesType } from '../ts/types/I18N';
function normalizeLocaleName(locale: string): string {
if (/^en-/.test(locale)) { if (/^en-/.test(locale)) {
return 'en'; return 'en';
} }
@ -14,10 +17,10 @@ function normalizeLocaleName(locale) {
return locale; return locale;
} }
function getLocaleMessages(locale) { function getLocaleMessages(locale: string): LocaleMessagesType {
const onDiskLocale = locale.replace('-', '_'); const onDiskLocale = locale.replace('-', '_');
const targetFile = path.join( const targetFile = join(
__dirname, __dirname,
'..', '..',
'_locales', '_locales',
@ -25,10 +28,20 @@ function getLocaleMessages(locale) {
'messages.json' 'messages.json'
); );
return JSON.parse(fs.readFileSync(targetFile, 'utf-8')); return JSON.parse(readFileSync(targetFile, 'utf-8'));
} }
function load({ appLocale, logger } = {}) { export function load({
appLocale,
logger,
}: {
appLocale: string;
logger: LoggerType;
}): {
i18n: LocalizerType;
name: string;
messages: LocaleMessagesType;
} {
if (!appLocale) { if (!appLocale) {
throw new TypeError('`appLocale` is required'); throw new TypeError('`appLocale` is required');
} }
@ -51,7 +64,7 @@ function load({ appLocale, logger } = {}) {
messages = getLocaleMessages(localeName); messages = getLocaleMessages(localeName);
// We start with english, then overwrite that with anything present in locale // We start with english, then overwrite that with anything present in locale
messages = _.merge(english, messages); messages = merge(english, messages);
} catch (e) { } catch (e) {
logger.error( logger.error(
`Problem loading messages for locale ${localeName} ${e.stack}` `Problem loading messages for locale ${localeName} ${e.stack}`
@ -70,7 +83,3 @@ function load({ appLocale, logger } = {}) {
messages, messages,
}; };
} }
module.exports = {
load,
};

View file

@ -1,9 +1,41 @@
// Copyright 2017-2020 Signal Messenger, LLC // Copyright 2017-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
const { isString } = require('lodash'); import { isString } from 'lodash';
import { MenuItemConstructorOptions } from 'electron';
exports.createTemplate = (options, messages) => { import { LocaleMessagesType } from '../ts/types/I18N';
export type MenuListType = Array<MenuItemConstructorOptions>;
type OptionsType = {
// options
development: boolean;
devTools: boolean;
includeSetup: boolean;
isBeta: (version: string) => boolean;
platform: string;
// actions
openContactUs: () => unknown;
openForums: () => unknown;
openJoinTheBeta: () => unknown;
openReleaseNotes: () => unknown;
openSupportPage: () => unknown;
setupAsNewDevice: () => unknown;
setupAsStandalone: () => unknown;
showAbout: () => unknown;
showDebugLog: () => unknown;
showKeyboardShortcuts: () => unknown;
showSettings: () => unknown;
showStickerCreator: () => unknown;
showWindow: () => unknown;
};
export const createTemplate = (
options: OptionsType,
messages: LocaleMessagesType
): MenuListType => {
if (!isString(options.platform)) { if (!isString(options.platform)) {
throw new TypeError('`options.platform` must be a string'); throw new TypeError('`options.platform` must be a string');
} }
@ -27,7 +59,7 @@ exports.createTemplate = (options, messages) => {
showStickerCreator, showStickerCreator,
} = options; } = options;
const template = [ const template: MenuListType = [
{ {
label: messages.mainMenuFile.message, label: messages.mainMenuFile.message,
submenu: [ submenu: [
@ -76,7 +108,7 @@ exports.createTemplate = (options, messages) => {
label: messages.editMenuPaste.message, label: messages.editMenuPaste.message,
}, },
{ {
role: 'pasteandmatchstyle', role: 'pasteAndMatchStyle',
label: messages.editMenuPasteAndMatchStyle.message, label: messages.editMenuPasteAndMatchStyle.message,
}, },
{ {
@ -84,7 +116,7 @@ exports.createTemplate = (options, messages) => {
label: messages.editMenuDelete.message, label: messages.editMenuDelete.message,
}, },
{ {
role: 'selectall', role: 'selectAll',
label: messages.editMenuSelectAll.message, label: messages.editMenuSelectAll.message,
}, },
], ],
@ -93,16 +125,16 @@ exports.createTemplate = (options, messages) => {
label: messages.mainMenuView.message, label: messages.mainMenuView.message,
submenu: [ submenu: [
{ {
role: 'resetzoom', role: 'resetZoom',
label: messages.viewMenuResetZoom.message, label: messages.viewMenuResetZoom.message,
}, },
{ {
accelerator: platform === 'darwin' ? 'Command+=' : 'Control+=', accelerator: platform === 'darwin' ? 'Command+=' : 'Control+=',
role: 'zoomin', role: 'zoomIn',
label: messages.viewMenuZoomIn.message, label: messages.viewMenuZoomIn.message,
}, },
{ {
role: 'zoomout', role: 'zoomOut',
label: messages.viewMenuZoomOut.message, label: messages.viewMenuZoomOut.message,
}, },
{ {
@ -122,10 +154,10 @@ exports.createTemplate = (options, messages) => {
...(devTools ...(devTools
? [ ? [
{ {
type: 'separator', type: 'separator' as const,
}, },
{ {
role: 'toggledevtools', role: 'toggleDevTools' as const,
label: messages.viewMenuToggleDevTools.message, label: messages.viewMenuToggleDevTools.message,
}, },
] ]
@ -192,21 +224,25 @@ exports.createTemplate = (options, messages) => {
if (includeSetup) { if (includeSetup) {
const fileMenu = template[0]; const fileMenu = template[0];
// These are in reverse order, since we're prepending them one at a time if (Array.isArray(fileMenu.submenu)) {
if (options.development) { // These are in reverse order, since we're prepending them one at a time
fileMenu.submenu.unshift({ if (options.development) {
label: messages.menuSetupAsStandalone.message, fileMenu.submenu.unshift({
click: setupAsStandalone, label: messages.menuSetupAsStandalone.message,
}); click: setupAsStandalone,
} });
}
fileMenu.submenu.unshift({ fileMenu.submenu.unshift({
type: 'separator', type: 'separator',
}); });
fileMenu.submenu.unshift({ fileMenu.submenu.unshift({
label: messages.menuSetupAsNewDevice.message, label: messages.menuSetupAsNewDevice.message,
click: setupAsNewDevice, click: setupAsNewDevice,
}); });
} else {
throw new Error('createTemplate: fileMenu.submenu was not an array!');
}
} }
if (platform === 'darwin') { if (platform === 'darwin') {
@ -216,30 +252,43 @@ exports.createTemplate = (options, messages) => {
return template; return template;
}; };
function updateForMac(template, messages, options) { function updateForMac(
template: MenuListType,
messages: LocaleMessagesType,
options: OptionsType
): MenuListType {
const { showAbout, showSettings, showWindow } = options; const { showAbout, showSettings, showWindow } = options;
// Remove About item and separator from Help menu, since they're in the app menu // Remove About item and separator from Help menu, since they're in the app menu
template[4].submenu.pop(); const aboutMenu = template[4];
template[4].submenu.pop(); if (Array.isArray(aboutMenu.submenu)) {
aboutMenu.submenu.pop();
aboutMenu.submenu.pop();
} else {
throw new Error('updateForMac: help.submenu was not an array!');
}
// Remove preferences, separator, and quit from the File menu, since they're // Remove preferences, separator, and quit from the File menu, since they're
// in the app menu // in the app menu
const fileMenu = template[0]; const fileMenu = template[0];
fileMenu.submenu.pop(); if (Array.isArray(fileMenu.submenu)) {
fileMenu.submenu.pop(); fileMenu.submenu.pop();
fileMenu.submenu.pop(); fileMenu.submenu.pop();
// And insert "close". fileMenu.submenu.pop();
fileMenu.submenu.push( // And insert "close".
{ fileMenu.submenu.push(
type: 'separator', {
}, type: 'separator',
{ },
label: messages.windowMenuClose.message, {
accelerator: 'CmdOrCtrl+W', label: messages.windowMenuClose.message,
role: 'close', accelerator: 'CmdOrCtrl+W',
} role: 'close',
); }
);
} else {
throw new Error('updateForMac: fileMenu.submenu was not an array!');
}
// Add the OSX-specific Signal Desktop menu at the far left // Add the OSX-specific Signal Desktop menu at the far left
template.unshift({ template.unshift({
@ -273,7 +322,7 @@ function updateForMac(template, messages, options) {
}, },
{ {
label: messages.appMenuHideOthers.message, label: messages.appMenuHideOthers.message,
role: 'hideothers', role: 'hideOthers',
}, },
{ {
label: messages.appMenuUnhide.message, label: messages.appMenuUnhide.message,
@ -289,25 +338,29 @@ function updateForMac(template, messages, options) {
], ],
}); });
// Add to Edit menu const editMenu = template[2];
template[2].submenu.push( if (Array.isArray(editMenu.submenu)) {
{ editMenu.submenu.push(
type: 'separator', {
}, type: 'separator',
{ },
label: messages.speech.message, {
submenu: [ label: messages.speech.message,
{ submenu: [
role: 'startspeaking', {
label: messages.editMenuStartSpeaking.message, role: 'startSpeaking',
}, label: messages.editMenuStartSpeaking.message,
{ },
role: 'stopspeaking', {
label: messages.editMenuStopSpeaking.message, role: 'stopSpeaking',
}, label: messages.editMenuStopSpeaking.message,
], },
} ],
); }
);
} else {
throw new Error('updateForMac: edit.submenu was not an array!');
}
// Replace Window menu // Replace Window menu
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign

View file

@ -4,7 +4,11 @@
// The list of permissions is here: // The list of permissions is here:
// https://electronjs.org/docs/api/session#sessetpermissionrequesthandlerhandler // https://electronjs.org/docs/api/session#sessetpermissionrequesthandlerhandler
const PERMISSIONS = { import { session as ElectronSession } from 'electron';
import { ConfigType } from './config';
const PERMISSIONS: Record<string, boolean> = {
// Allowed // Allowed
fullscreen: true, // required to show videos in full-screen fullscreen: true, // required to show videos in full-screen
notifications: true, // required to show OS notifications for new messages notifications: true, // required to show OS notifications for new messages
@ -19,47 +23,60 @@ const PERMISSIONS = {
pointerLock: false, pointerLock: false,
}; };
function _createPermissionHandler(userConfig) { function _createPermissionHandler(
return (webContents, permission, callback, details) => { userConfig: ConfigType
): Parameters<typeof ElectronSession.prototype.setPermissionRequestHandler>[0] {
return (_webContents, permission, callback, details): void => {
// We default 'media' permission to false, but the user can override that for // We default 'media' permission to false, but the user can override that for
// the microphone and camera. // the microphone and camera.
if (permission === 'media') { if (permission === 'media') {
if ( if (
details.mediaTypes.includes('audio') || details.mediaTypes?.includes('audio') ||
details.mediaTypes.includes('video') details.mediaTypes?.includes('video')
) { ) {
if ( if (
details.mediaTypes.includes('audio') && details.mediaTypes?.includes('audio') &&
userConfig.get('mediaPermissions') userConfig.get('mediaPermissions')
) { ) {
return callback(true); callback(true);
return;
} }
if ( if (
details.mediaTypes.includes('video') && details.mediaTypes?.includes('video') &&
userConfig.get('mediaCameraPermissions') userConfig.get('mediaCameraPermissions')
) { ) {
return callback(true); callback(true);
return;
} }
return callback(false); callback(false);
return;
} }
// If it doesn't have 'video' or 'audio', it's probably screenshare. // If it doesn't have 'video' or 'audio', it's probably screenshare.
// TODO: DESKTOP-1611 // TODO: DESKTOP-1611
return callback(true); callback(true);
return;
} }
if (PERMISSIONS[permission]) { if (PERMISSIONS[permission]) {
console.log(`Approving request for permission '${permission}'`); console.log(`Approving request for permission '${permission}'`);
return callback(true); callback(true);
return;
} }
console.log(`Denying request for permission '${permission}'`); console.log(`Denying request for permission '${permission}'`);
return callback(false); callback(false);
}; };
} }
function installPermissionsHandler({ session, userConfig }) { export function installPermissionsHandler({
session,
userConfig,
}: {
session: typeof ElectronSession;
userConfig: ConfigType;
}): void {
// Setting the permission request handler to null first forces any permissions to be // Setting the permission request handler to null first forces any permissions to be
// requested again. Without this, revoked permissions might still be available if // requested again. Without this, revoked permissions might still be available if
// they've already been used successfully. // they've already been used successfully.
@ -69,7 +86,3 @@ function installPermissionsHandler({ session, userConfig }) {
_createPermissionHandler(userConfig) _createPermissionHandler(userConfig)
); );
} }
module.exports = {
installPermissionsHandler,
};

View file

@ -1,10 +1,21 @@
// Copyright 2018-2020 Signal Messenger, LLC // Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
const path = require('path'); import {
const fs = require('fs'); protocol as ElectronProtocol,
ProtocolRequest,
ProtocolResponse,
} from 'electron';
function _eliminateAllAfterCharacter(string, character) { import { isAbsolute, normalize } from 'path';
import { existsSync, realpathSync } from 'fs';
type CallbackType = (response: string | ProtocolResponse) => void;
function _eliminateAllAfterCharacter(
string: string,
character: string
): string {
const index = string.indexOf(character); const index = string.indexOf(character);
if (index < 0) { if (index < 0) {
return string; return string;
@ -13,20 +24,38 @@ function _eliminateAllAfterCharacter(string, character) {
return string.slice(0, index); return string.slice(0, index);
} }
function _urlToPath(targetUrl, options = {}) { export function _urlToPath(
const { isWindows } = options; targetUrl: string,
options?: { isWindows: boolean }
): string {
const decoded = decodeURIComponent(targetUrl); const decoded = decodeURIComponent(targetUrl);
const withoutScheme = decoded.slice(isWindows ? 8 : 7); const withoutScheme = decoded.slice(options?.isWindows ? 8 : 7);
const withoutQuerystring = _eliminateAllAfterCharacter(withoutScheme, '?'); const withoutQuerystring = _eliminateAllAfterCharacter(withoutScheme, '?');
const withoutHash = _eliminateAllAfterCharacter(withoutQuerystring, '#'); const withoutHash = _eliminateAllAfterCharacter(withoutQuerystring, '#');
return withoutHash; return withoutHash;
} }
function _createFileHandler({ userDataPath, installPath, isWindows }) { function _createFileHandler({
return (request, callback) => { userDataPath,
installPath,
isWindows,
}: {
userDataPath: string;
installPath: string;
isWindows: boolean;
}) {
return (request: ProtocolRequest, callback: CallbackType): void => {
let targetPath; let targetPath;
if (!request.url) {
// This is an "invalid URL" error. See [Chromium's net error list][0].
//
// [0]: https://source.chromium.org/chromium/chromium/src/+/master:net/base/net_error_list.h;l=563;drc=a836ee9868cf1b9673fce362a82c98aba3e195de
callback({ error: -300 });
return;
}
try { try {
targetPath = _urlToPath(request.url, { isWindows }); targetPath = _urlToPath(request.url, { isWindows });
} catch (err) { } catch (err) {
@ -38,24 +67,26 @@ function _createFileHandler({ userDataPath, installPath, isWindows }) {
`Warning: denying request because of an error: ${errorMessage}` `Warning: denying request because of an error: ${errorMessage}`
); );
// This is an "invalid URL" error. See [Chromium's net error list][0]. callback({ error: -300 });
// return;
// [0]: https://source.chromium.org/chromium/chromium/src/+/master:net/base/net_error_list.h;l=563;drc=a836ee9868cf1b9673fce362a82c98aba3e195de
return callback({ error: -300 });
} }
// normalize() is primarily useful here for switching / to \ on windows // normalize() is primarily useful here for switching / to \ on windows
const target = path.normalize(targetPath); const target = normalize(targetPath);
// here we attempt to follow symlinks to the ultimate final path, reflective of what // here we attempt to follow symlinks to the ultimate final path, reflective of what
// we do in main.js on userDataPath and installPath // we do in main.js on userDataPath and installPath
const realPath = fs.existsSync(target) ? fs.realpathSync(target) : target; const realPath = existsSync(target) ? realpathSync(target) : target;
// finally we do case-insensitive checks on windows // finally we do case-insensitive checks on windows
const properCasing = isWindows ? realPath.toLowerCase() : realPath; const properCasing = isWindows ? realPath.toLowerCase() : realPath;
if (!path.isAbsolute(realPath)) { if (!isAbsolute(realPath)) {
console.log( console.log(
`Warning: denying request to non-absolute path '${realPath}'` `Warning: denying request to non-absolute path '${realPath}'`
); );
return callback(); // This is an "Access Denied" error. See [Chromium's net error list][0].
//
// [0]: https://source.chromium.org/chromium/chromium/src/+/master:net/base/net_error_list.h;l=57;drc=a836ee9868cf1b9673fce362a82c98aba3e195de
callback({ error: -10 });
return;
} }
if ( if (
@ -69,21 +100,27 @@ function _createFileHandler({ userDataPath, installPath, isWindows }) {
console.log( console.log(
`Warning: denying request to path '${realPath}' (userDataPath: '${userDataPath}', installPath: '${installPath}')` `Warning: denying request to path '${realPath}' (userDataPath: '${userDataPath}', installPath: '${installPath}')`
); );
return callback(); callback({ error: -10 });
return;
} }
return callback({ callback({
path: realPath, path: realPath,
}); });
}; };
} }
function installFileHandler({ export function installFileHandler({
protocol, protocol,
userDataPath, userDataPath,
installPath, installPath,
isWindows, isWindows,
}) { }: {
protocol: typeof ElectronProtocol;
userDataPath: string;
installPath: string;
isWindows: boolean;
}): void {
protocol.interceptFileProtocol( protocol.interceptFileProtocol(
'file', 'file',
_createFileHandler({ userDataPath, installPath, isWindows }) _createFileHandler({ userDataPath, installPath, isWindows })
@ -91,11 +128,20 @@ function installFileHandler({
} }
// Turn off browser URI scheme since we do all network requests via Node.js // Turn off browser URI scheme since we do all network requests via Node.js
function _disabledHandler(request, callback) { function _disabledHandler(
return callback(); _request: ProtocolRequest,
callback: CallbackType
): void {
callback({ error: -10 });
} }
function installWebHandler({ protocol, enableHttp }) { export function installWebHandler({
protocol,
enableHttp,
}: {
protocol: typeof ElectronProtocol;
enableHttp: string;
}): void {
protocol.interceptFileProtocol('about', _disabledHandler); protocol.interceptFileProtocol('about', _disabledHandler);
protocol.interceptFileProtocol('content', _disabledHandler); protocol.interceptFileProtocol('content', _disabledHandler);
protocol.interceptFileProtocol('chrome', _disabledHandler); protocol.interceptFileProtocol('chrome', _disabledHandler);
@ -114,9 +160,3 @@ function installWebHandler({ protocol, enableHttp }) {
protocol.interceptFileProtocol('wss', _disabledHandler); protocol.interceptFileProtocol('wss', _disabledHandler);
} }
} }
module.exports = {
_urlToPath,
installFileHandler,
installWebHandler,
};

View file

@ -3,13 +3,20 @@
/* eslint-disable strict */ /* eslint-disable strict */
const { Menu, clipboard, nativeImage } = require('electron'); import { BrowserWindow, Menu, clipboard, nativeImage } from 'electron';
const osLocale = require('os-locale'); import { sync as osLocaleSync } from 'os-locale';
const { uniq } = require('lodash'); import { uniq } from 'lodash';
const url = require('url'); import { fileURLToPath } from 'url';
const { maybeParseUrl } = require('../ts/util/url');
function getLanguages(userLocale, availableLocales) { import { maybeParseUrl } from '../ts/util/url';
import { LocaleMessagesType } from '../ts/types/I18N';
import { MenuListType } from './menu';
export function getLanguages(
userLocale: string,
availableLocales: ReadonlyArray<string>
): Array<string> {
const baseLocale = userLocale.split('-')[0]; const baseLocale = userLocale.split('-')[0];
// Attempt to find the exact locale // Attempt to find the exact locale
const candidateLocales = uniq([userLocale, baseLocale]).filter(l => const candidateLocales = uniq([userLocale, baseLocale]).filter(l =>
@ -25,9 +32,12 @@ function getLanguages(userLocale, availableLocales) {
return uniq(availableLocales.filter(l => l.startsWith(baseLocale))); return uniq(availableLocales.filter(l => l.startsWith(baseLocale)));
} }
exports.setup = (browserWindow, messages) => { export const setup = (
browserWindow: BrowserWindow,
messages: LocaleMessagesType
): void => {
const { session } = browserWindow.webContents; const { session } = browserWindow.webContents;
const userLocale = osLocale.sync().replace(/_/g, '-'); const userLocale = osLocaleSync().replace(/_/g, '-');
const availableLocales = session.availableSpellCheckerLanguages; const availableLocales = session.availableSpellCheckerLanguages;
const languages = getLanguages(userLocale, availableLocales); const languages = getLanguages(userLocale, availableLocales);
console.log(`spellcheck: user locale: ${userLocale}`); console.log(`spellcheck: user locale: ${userLocale}`);
@ -49,7 +59,7 @@ exports.setup = (browserWindow, messages) => {
// Popup editor menu // Popup editor menu
if (showMenu) { if (showMenu) {
const template = []; const template: MenuListType = [];
if (isMisspelled) { if (isMisspelled) {
if (params.dictionarySuggestions.length > 0) { if (params.dictionarySuggestions.length > 0) {
@ -104,7 +114,7 @@ exports.setup = (browserWindow, messages) => {
} }
const image = nativeImage.createFromPath( const image = nativeImage.createFromPath(
url.fileURLToPath(params.srcURL) fileURLToPath(params.srcURL)
); );
clipboard.writeImage(image); clipboard.writeImage(image);
}; };
@ -136,14 +146,14 @@ exports.setup = (browserWindow, messages) => {
if (editFlags.canSelectAll && params.isEditable) { if (editFlags.canSelectAll && params.isEditable) {
template.push({ template.push({
label: messages.editMenuSelectAll.message, label: messages.editMenuSelectAll.message,
role: 'selectall', role: 'selectAll',
}); });
} }
const menu = Menu.buildFromTemplate(template); const menu = Menu.buildFromTemplate(template);
menu.popup(browserWindow); menu.popup({
window: browserWindow,
});
} }
}); });
}; };
exports.getLanguages = getLanguages;

View file

@ -1,24 +1,23 @@
// Copyright 2018-2020 Signal Messenger, LLC // Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
const electron = require('electron'); import { ipcMain } from 'electron';
const { remove: removeUserConfig } = require('./user_config');
const { remove: removeEphemeralConfig } = require('./ephemeral_config');
const { ipcMain } = electron; import { remove as removeUserConfig } from './user_config';
import { remove as removeEphemeralConfig } from './ephemeral_config';
let sql; type SQLType = {
sqlCall(callName: string, args: ReadonlyArray<unknown>): unknown;
module.exports = {
initialize,
}; };
let sql: SQLType | undefined;
let initialized = false; let initialized = false;
const SQL_CHANNEL_KEY = 'sql-channel'; const SQL_CHANNEL_KEY = 'sql-channel';
const ERASE_SQL_KEY = 'erase-sql-key'; const ERASE_SQL_KEY = 'erase-sql-key';
function initialize(mainSQL) { export function initialize(mainSQL: SQLType): void {
if (initialized) { if (initialized) {
throw new Error('sqlChannels: already initialized!'); throw new Error('sqlChannels: already initialized!');
} }
@ -28,6 +27,9 @@ function initialize(mainSQL) {
ipcMain.on(SQL_CHANNEL_KEY, async (event, jobId, callName, ...args) => { ipcMain.on(SQL_CHANNEL_KEY, async (event, jobId, callName, ...args) => {
try { try {
if (!sql) {
throw new Error(`${SQL_CHANNEL_KEY}: Not yet initialized!`);
}
const result = await sql.sqlCall(callName, args); const result = await sql.sqlCall(callName, args);
event.sender.send(`${SQL_CHANNEL_KEY}-done`, jobId, null, result); event.sender.send(`${SQL_CHANNEL_KEY}-done`, jobId, null, result);
} catch (error) { } catch (error) {

View file

@ -1,17 +1,22 @@
// Copyright 2017-2021 Signal Messenger, LLC // Copyright 2017-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
const path = require('path'); import { join } from 'path';
import { existsSync } from 'fs';
const fs = require('fs'); import { BrowserWindow, app, Menu, Tray } from 'electron';
const { app, Menu, Tray } = require('electron'); import * as DockIcon from '../ts/dock_icon';
const dockIcon = require('../ts/dock_icon');
import { LocaleMessagesType } from '../ts/types/I18N';
let trayContextMenu = null; let trayContextMenu = null;
let tray = null; let tray: Tray | undefined;
function createTrayIcon(getMainWindow, messages) { export default function createTrayIcon(
let iconSize; getMainWindow: () => BrowserWindow | undefined,
messages: LocaleMessagesType
): { updateContextMenu: () => void; updateIcon: (count: number) => void } {
let iconSize: string;
switch (process.platform) { switch (process.platform) {
case 'darwin': case 'darwin':
iconSize = '16'; iconSize = '16';
@ -24,7 +29,7 @@ function createTrayIcon(getMainWindow, messages) {
break; break;
} }
const iconNoNewMessages = path.join( const iconNoNewMessages = join(
__dirname, __dirname,
'..', '..',
'images', 'images',
@ -33,7 +38,7 @@ function createTrayIcon(getMainWindow, messages) {
tray = new Tray(iconNoNewMessages); tray = new Tray(iconNoNewMessages);
tray.forceOnTop = mainWindow => { const forceOnTop = (mainWindow: BrowserWindow) => {
if (mainWindow) { if (mainWindow) {
// On some versions of GNOME the window may not be on top when restored. // On some versions of GNOME the window may not be on top when restored.
// This trick should fix it. // This trick should fix it.
@ -44,35 +49,35 @@ function createTrayIcon(getMainWindow, messages) {
} }
}; };
tray.toggleWindowVisibility = () => { const toggleWindowVisibility = () => {
const mainWindow = getMainWindow(); const mainWindow = getMainWindow();
if (mainWindow) { if (mainWindow) {
if (mainWindow.isVisible()) { if (mainWindow.isVisible()) {
mainWindow.hide(); mainWindow.hide();
dockIcon.hide(); DockIcon.hide();
} else { } else {
mainWindow.show(); mainWindow.show();
dockIcon.show(); DockIcon.show();
tray.forceOnTop(mainWindow); forceOnTop(mainWindow);
} }
} }
tray.updateContextMenu(); updateContextMenu();
}; };
tray.showWindow = () => { const showWindow = () => {
const mainWindow = getMainWindow(); const mainWindow = getMainWindow();
if (mainWindow) { if (mainWindow) {
if (!mainWindow.isVisible()) { if (!mainWindow.isVisible()) {
mainWindow.show(); mainWindow.show();
} }
tray.forceOnTop(mainWindow); forceOnTop(mainWindow);
} }
tray.updateContextMenu(); updateContextMenu();
}; };
tray.updateContextMenu = () => { const updateContextMenu = () => {
const mainWindow = getMainWindow(); const mainWindow = getMainWindow();
// NOTE: we want to have the show/hide entry available in the tray icon // NOTE: we want to have the show/hide entry available in the tray icon
@ -85,7 +90,7 @@ function createTrayIcon(getMainWindow, messages) {
label: label:
messages[mainWindow && mainWindow.isVisible() ? 'hide' : 'show'] messages[mainWindow && mainWindow.isVisible() ? 'hide' : 'show']
.message, .message,
click: tray.toggleWindowVisibility, click: toggleWindowVisibility,
}, },
{ {
id: 'quit', id: 'quit',
@ -94,25 +99,25 @@ function createTrayIcon(getMainWindow, messages) {
}, },
]); ]);
tray.setContextMenu(trayContextMenu); tray?.setContextMenu(trayContextMenu);
}; };
tray.updateIcon = unreadCount => { const updateIcon = (unreadCount: number) => {
let image; let image;
if (unreadCount > 0) { if (unreadCount > 0) {
const filename = `${String(unreadCount >= 10 ? 10 : unreadCount)}.png`; const filename = `${String(unreadCount >= 10 ? 10 : unreadCount)}.png`;
image = path.join(__dirname, '..', 'images', 'alert', iconSize, filename); image = join(__dirname, '..', 'images', 'alert', iconSize, filename);
} else { } else {
image = iconNoNewMessages; image = iconNoNewMessages;
} }
if (!fs.existsSync(image)) { if (!existsSync(image)) {
console.log('tray.updateIcon: Image for tray update does not exist!'); console.log('tray.updateIcon: Image for tray update does not exist!');
return; return;
} }
try { try {
tray.setImage(image); tray?.setImage(image);
} catch (error) { } catch (error) {
console.log( console.log(
'tray.setImage error:', 'tray.setImage error:',
@ -121,12 +126,13 @@ function createTrayIcon(getMainWindow, messages) {
} }
}; };
tray.on('click', tray.showWindow); tray.on('click', showWindow);
tray.setToolTip(messages.signalDesktop.message); tray.setToolTip(messages.signalDesktop.message);
tray.updateContextMenu(); updateContextMenu();
return tray; return {
updateContextMenu,
updateIcon,
};
} }
module.exports = createTrayIcon;

View file

@ -1,4 +0,0 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export function remove(): void;

View file

@ -1,16 +1,15 @@
// Copyright 2017-2020 Signal Messenger, LLC // Copyright 2017-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
const path = require('path'); import { join } from 'path';
import { app } from 'electron';
const { app } = require('electron'); import { start } from './base_config';
import config from './config';
const { start } = require('./base_config');
const config = require('./config');
// Use separate data directory for development // Use separate data directory for development
if (config.has('storageProfile')) { if (config.has('storageProfile')) {
const userData = path.join( const userData = join(
app.getPath('appData'), app.getPath('appData'),
`Signal-${config.get('storageProfile')}` `Signal-${config.get('storageProfile')}`
); );
@ -21,8 +20,10 @@ if (config.has('storageProfile')) {
console.log(`userData: ${app.getPath('userData')}`); console.log(`userData: ${app.getPath('userData')}`);
const userDataPath = app.getPath('userData'); const userDataPath = app.getPath('userData');
const targetPath = path.join(userDataPath, 'config.json'); const targetPath = join(userDataPath, 'config.json');
const userConfig = start('user', targetPath); const userConfig = start('user', targetPath);
module.exports = userConfig; export const get = userConfig.get.bind(userConfig);
export const remove = userConfig.remove.bind(userConfig);
export const set = userConfig.set.bind(userConfig);

View file

@ -1,5 +0,0 @@
// Copyright 2019-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export function markShouldQuit(): void;
export function shouldQuit(): void;

View file

@ -3,15 +3,10 @@
let shouldQuitFlag = false; let shouldQuitFlag = false;
function markShouldQuit() { export function markShouldQuit(): void {
shouldQuitFlag = true; shouldQuitFlag = true;
} }
function shouldQuit() { export function shouldQuit(): boolean {
return shouldQuitFlag; return shouldQuitFlag;
} }
module.exports = {
shouldQuit,
markShouldQuit,
};

View file

@ -71,7 +71,7 @@ const startInTray = process.argv.some(arg => arg === '--start-in-tray');
const usingTrayIcon = const usingTrayIcon =
startInTray || process.argv.some(arg => arg === '--use-tray-icon'); startInTray || process.argv.some(arg => arg === '--use-tray-icon');
const config = require('./app/config'); const config = require('./app/config').default;
// Very important to put before the single instance check, since it is based on the // Very important to put before the single instance check, since it is based on the
// userData directory. // userData directory.
@ -91,7 +91,7 @@ const attachments = require('./app/attachments');
const attachmentChannel = require('./app/attachment_channel'); const attachmentChannel = require('./app/attachment_channel');
const bounce = require('./ts/services/bounce'); const bounce = require('./ts/services/bounce');
const updater = require('./ts/updater/index'); const updater = require('./ts/updater/index');
const createTrayIcon = require('./app/tray_icon'); const createTrayIcon = require('./app/tray_icon').default;
const dockIcon = require('./ts/dock_icon'); const dockIcon = require('./ts/dock_icon');
const ephemeralConfig = require('./app/ephemeral_config'); const ephemeralConfig = require('./app/ephemeral_config');
const logging = require('./ts/logging/main_process_logging'); const logging = require('./ts/logging/main_process_logging');
@ -1353,7 +1353,7 @@ app.on('ready', async () => {
); );
await attachments.deleteAllDraftAttachments({ await attachments.deleteAllDraftAttachments({
userDataPath, userDataPath,
stickers: orphanedDraftAttachments, attachments: orphanedDraftAttachments,
}); });
} }

View file

@ -157,7 +157,6 @@
"tar": "4.4.8", "tar": "4.4.8",
"testcheck": "1.0.0-rc.2", "testcheck": "1.0.0-rc.2",
"tmp": "0.0.33", "tmp": "0.0.33",
"to-arraybuffer": "1.0.1",
"typeface-inter": "3.10.0", "typeface-inter": "3.10.0",
"underscore": "1.12.1", "underscore": "1.12.1",
"uuid": "3.3.2", "uuid": "3.3.2",

View file

@ -30,7 +30,7 @@
}, },
{ {
"label": "Hide Others", "label": "Hide Others",
"role": "hideothers" "role": "hideOthers"
}, },
{ {
"label": "Show All", "label": "Show All",
@ -97,7 +97,7 @@
}, },
{ {
"label": "Paste and Match Style", "label": "Paste and Match Style",
"role": "pasteandmatchstyle" "role": "pasteAndMatchStyle"
}, },
{ {
"label": "Delete", "label": "Delete",
@ -105,7 +105,7 @@
}, },
{ {
"label": "Select All", "label": "Select All",
"role": "selectall" "role": "selectAll"
}, },
{ {
"type": "separator" "type": "separator"
@ -115,11 +115,11 @@
"submenu": [ "submenu": [
{ {
"label": "Start speaking", "label": "Start speaking",
"role": "startspeaking" "role": "startSpeaking"
}, },
{ {
"label": "Stop speaking", "label": "Stop speaking",
"role": "stopspeaking" "role": "stopSpeaking"
} }
] ]
} }
@ -130,16 +130,16 @@
"submenu": [ "submenu": [
{ {
"label": "Actual Size", "label": "Actual Size",
"role": "resetzoom" "role": "resetZoom"
}, },
{ {
"accelerator": "Command+=", "accelerator": "Command+=",
"label": "Zoom In", "label": "Zoom In",
"role": "zoomin" "role": "zoomIn"
}, },
{ {
"label": "Zoom Out", "label": "Zoom Out",
"role": "zoomout" "role": "zoomOut"
}, },
{ {
"type": "separator" "type": "separator"
@ -160,7 +160,7 @@
}, },
{ {
"label": "Toggle Developer Tools", "label": "Toggle Developer Tools",
"role": "toggledevtools" "role": "toggleDevTools"
} }
] ]
}, },

View file

@ -30,7 +30,7 @@
}, },
{ {
"label": "Hide Others", "label": "Hide Others",
"role": "hideothers" "role": "hideOthers"
}, },
{ {
"label": "Show All", "label": "Show All",
@ -90,7 +90,7 @@
}, },
{ {
"label": "Paste and Match Style", "label": "Paste and Match Style",
"role": "pasteandmatchstyle" "role": "pasteAndMatchStyle"
}, },
{ {
"label": "Delete", "label": "Delete",
@ -98,7 +98,7 @@
}, },
{ {
"label": "Select All", "label": "Select All",
"role": "selectall" "role": "selectAll"
}, },
{ {
"type": "separator" "type": "separator"
@ -108,11 +108,11 @@
"submenu": [ "submenu": [
{ {
"label": "Start speaking", "label": "Start speaking",
"role": "startspeaking" "role": "startSpeaking"
}, },
{ {
"label": "Stop speaking", "label": "Stop speaking",
"role": "stopspeaking" "role": "stopSpeaking"
} }
] ]
} }
@ -123,16 +123,16 @@
"submenu": [ "submenu": [
{ {
"label": "Actual Size", "label": "Actual Size",
"role": "resetzoom" "role": "resetZoom"
}, },
{ {
"accelerator": "Command+=", "accelerator": "Command+=",
"label": "Zoom In", "label": "Zoom In",
"role": "zoomin" "role": "zoomIn"
}, },
{ {
"label": "Zoom Out", "label": "Zoom Out",
"role": "zoomout" "role": "zoomOut"
}, },
{ {
"type": "separator" "type": "separator"
@ -153,7 +153,7 @@
}, },
{ {
"label": "Toggle Developer Tools", "label": "Toggle Developer Tools",
"role": "toggledevtools" "role": "toggleDevTools"
} }
] ]
}, },

View file

@ -55,7 +55,7 @@
}, },
{ {
"label": "Paste and Match Style", "label": "Paste and Match Style",
"role": "pasteandmatchstyle" "role": "pasteAndMatchStyle"
}, },
{ {
"label": "Delete", "label": "Delete",
@ -63,7 +63,7 @@
}, },
{ {
"label": "Select All", "label": "Select All",
"role": "selectall" "role": "selectAll"
} }
] ]
}, },
@ -72,16 +72,16 @@
"submenu": [ "submenu": [
{ {
"label": "Actual Size", "label": "Actual Size",
"role": "resetzoom" "role": "resetZoom"
}, },
{ {
"accelerator": "Control+=", "accelerator": "Control+=",
"label": "Zoom In", "label": "Zoom In",
"role": "zoomin" "role": "zoomIn"
}, },
{ {
"label": "Zoom Out", "label": "Zoom Out",
"role": "zoomout" "role": "zoomOut"
}, },
{ {
"type": "separator" "type": "separator"
@ -102,7 +102,7 @@
}, },
{ {
"label": "Toggle Developer Tools", "label": "Toggle Developer Tools",
"role": "toggledevtools" "role": "toggleDevTools"
} }
] ]
}, },

View file

@ -48,7 +48,7 @@
}, },
{ {
"label": "Paste and Match Style", "label": "Paste and Match Style",
"role": "pasteandmatchstyle" "role": "pasteAndMatchStyle"
}, },
{ {
"label": "Delete", "label": "Delete",
@ -56,7 +56,7 @@
}, },
{ {
"label": "Select All", "label": "Select All",
"role": "selectall" "role": "selectAll"
} }
] ]
}, },
@ -65,16 +65,16 @@
"submenu": [ "submenu": [
{ {
"label": "Actual Size", "label": "Actual Size",
"role": "resetzoom" "role": "resetZoom"
}, },
{ {
"accelerator": "Control+=", "accelerator": "Control+=",
"label": "Zoom In", "label": "Zoom In",
"role": "zoomin" "role": "zoomIn"
}, },
{ {
"label": "Zoom Out", "label": "Zoom Out",
"role": "zoomout" "role": "zoomOut"
}, },
{ {
"type": "separator" "type": "separator"
@ -95,7 +95,7 @@
}, },
{ {
"label": "Toggle Developer Tools", "label": "Toggle Developer Tools",
"role": "toggledevtools" "role": "toggleDevTools"
} }
] ]
}, },

View file

@ -1,51 +0,0 @@
Manual test script
Some things are very difficult to test programmatically. Also, if you don't have adequate test coverage, a good first step is a comprehensive manual test script! https://blog.scottnonnenberg.com/web-application-test-strategy/
Conversation view:
Last seen indicator:
(dismissed three ways: 1. sending a message 2. switching away from conversation and back again 3. clicking scroll down button when last seen indicator is off-screen above)
- Switch away from Signal app, but keep it visible
- Receive messages to conversation out of focus, and the last seen indicator should move up the screen with each new message. When the number of new messages can no longer fit on the screen, the last seen indicator should stay at the top of the screen, and new messages will appear below. The scroll down button will turn blue to indicate new messages out of view.
- Switch back to Signal app, and the last seen indicator and scroll down button should stay where they are.
- Click the scroll down button to go to the bottom of the window, and the button should disappear.
- Send a message, then scroll up. The last seen indicator should be gone.
- Switch to a different conversation, then receive messages on original conversation
- Switch back to original conversation, and the last seen indicator should be visible
- Switch away from conversation and back. The last seen indicator should be gone.
- Switch to a different conversation, then receive a lot of messages on original conversation
- Switch back to original conversation, and the last seen indicator should be visible, along with the scroll down button.
- Click the scroll down button to be taken to the newest message in the conversation
- Scroll up on a conversation then switch to another application, keeping the Signal application visible. Receive new messages on that conversation. Switch back to application. The scroll down button should be blue, and the conversation scroll location should stay where it was. There should be a last seen indicator visible above the new messages.
- Scroll to bottom of a conversation, then switch to another application, keeping Signal application visible. Receive new messages on that conversation. As new messages come in, the last seen indicator should march up the screen. Before it reaches the top, switch back to the application. This will mark those messages as read. Switch away from the application again, and receive new messages. The last seen indicator will scroll off the top of the screen as more and more new messages come in.
- ADVANCED: Set up an automated script (or friend) to send you repeated messages. You should see the right number of unread upon entry of the conversation, along with with the last seen indicator. While the conversation is focused, new messages should increment the last seen indicator until it is offscreen above. Click the scroll down button to eliminate the last seen indicator, then scroll up. New messages received while scrolled up should not scroll the conversation, but will add a new last seen indicator and scroll down button.
- ADVANCED: Set fetch limit to a low number, like 3 (in models/messages.js, fetchConversation function). Load the application, and don't select the conversation. Receive more than four new messages in that conversation. Select the conversation. The last seen indicator should reflect the total number of new messages and all of them should be visible.
Marking messages as unread:
- Switch to a different conversation, then receive lots of messages on original conversation, more than would fill the screen
- Note the count before clicking into the conversation. Count the number of visible messages and ensure that the conversation's unread count is decremented by the right amount.
- Slowly scroll down so that one more message is visible. The conversation unread count should go down by one.
- Click the scroll down button. All messages should be marked read - even if you skipped a couple screens to get to the bottom.
Scrolling:
- If scrolled to bottom of a conversation, should stay there when a new message comes in
- If scrolled to the middle of a conversation, should stay there when a new message comes in
- When you've scrolled up an entire screen's worth, a scroll down button in the bottom right should appear.
Scroll-down button:
- Clicking it takes you to the bottom of the conversation, makes the button disappear
- If a new message comes in while it is already showing, it turns blue
- If a new message comes in while not at the bottom of the conversation (but button is not already showing), it should appear, already blue.
- If you've scrolled up higher than the last seen indicator, then clicking the scroll down button should take you to the last seen indicator. Once there, clicking the button will take you to the bottom of the conversation, at which point the button will disappear.
Electron window locations
- Load app, move and resize window, close app. Start app. Window should be in the same place, with the same size.
- (OSX) Load app, full-screen window, close app. Start app. Window should be full screen.
- (Windows) Load app, maximize window, close app. Start app. Window should be maximized.

10
ts/os-locale.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// We need this until we upgrade os-locale. Newer versions include type definitions.
// We can't upgrade it yet because we patch it to disable its findup/exec behavior.
declare module 'os-locale' {
export function sync(): string;
}

View file

@ -12,10 +12,12 @@ export type ReplacementValuesType<T> = {
[key: string]: T; [key: string]: T;
}; };
export type LocalizerType = (
key: string,
placeholders: Array<string> | ReplacementValuesType<string>
) => string;
export type LocaleType = { export type LocaleType = {
i18n: ( i18n: LocalizerType;
key: string,
placeholders: Array<string> | ReplacementValuesType<string>
) => string;
messages: LocaleMessagesType; messages: LocaleMessagesType;
}; };

View file

@ -1,11 +1,4 @@
[ [
{
"rule": "jQuery-load(",
"path": "app/locale.js",
"line": "function load({ appLocale, logger } = {}) {",
"reasonCategory": "falseMatch",
"updated": "2018-09-13T21:20:44.234Z"
},
{ {
"rule": "jQuery-after(", "rule": "jQuery-after(",
"path": "components/indexeddb-backbonejs-adapter/backbone-indexeddb.js", "path": "components/indexeddb-backbonejs-adapter/backbone-indexeddb.js",

View file

@ -28,6 +28,7 @@ const excludedFilesRegexps = [
'\\.d\\.ts$', '\\.d\\.ts$',
// High-traffic files in our project // High-traffic files in our project
'^app/.+(ts|js)',
'^ts/models/messages.js', '^ts/models/messages.js',
'^ts/models/messages.ts', '^ts/models/messages.ts',
'^ts/models/conversations.js', '^ts/models/conversations.js',

View file

@ -57,6 +57,7 @@
}, },
"include": [ "include": [
"ts/**/*", "ts/**/*",
"app/*",
"node_modules/zkgroup/zkgroup/modules/*", "node_modules/zkgroup/zkgroup/modules/*",
"package.json" "package.json"
] ]

View file

@ -17675,7 +17675,7 @@ tmp@0.1.0, tmp@^0.1.0:
dependencies: dependencies:
rimraf "^2.6.3" rimraf "^2.6.3"
to-arraybuffer@1.0.1, to-arraybuffer@^1.0.0: to-arraybuffer@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43"
integrity sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M= integrity sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=