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
# TypeScript generated files
app/**/*.js
ts/**/*.js
sticker-creator/**/*.js
!sticker-creator/preload.js

View File

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

1
.gitignore vendored
View File

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

View File

@ -2,6 +2,7 @@
# supports `.gitignore`: https://github.com/prettier/prettier/issues/2294
# Generated files
app/**/*.js
config/local-*.json
config/local.json
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
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
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
module.exports = {
rules: {
// On the node.js side, we're still using console.log
'no-console': 'off',
},
// For reference: https://github.com/airbnb/javascript
const rules = {
'no-console': 'off',
};
module.exports = {
rules,
};

View File

@ -1,15 +1,14 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const electron = require('electron');
const rimraf = require('rimraf');
const Attachments = require('./attachments');
const { ipcMain } = electron;
module.exports = {
initialize,
};
import { ipcMain } from 'electron';
import * as rimraf from 'rimraf';
import {
getPath,
getStickersPath,
getTempPath,
getDraftPath,
} from './attachments';
let initialized = false;
@ -19,16 +18,22 @@ const ERASE_TEMP_KEY = 'erase-temp';
const ERASE_DRAFTS_KEY = 'erase-drafts';
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) {
throw new Error('initialze: Already initialized!');
}
initialized = true;
const attachmentsDir = Attachments.getPath(configDir);
const stickersDir = Attachments.getStickersPath(configDir);
const tempDir = Attachments.getTempPath(configDir);
const draftDir = Attachments.getDraftPath(configDir);
const attachmentsDir = getPath(configDir);
const stickersDir = getStickersPath(configDir);
const tempDir = getTempPath(configDir);
const draftDir = getDraftPath(configDir);
ipcMain.on(ERASE_TEMP_KEY, event => {
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
// SPDX-License-Identifier: AGPL-3.0-only
const crypto = require('crypto');
const path = require('path');
const { app, dialog, shell, remote } = require('electron');
import { randomBytes } from 'crypto';
import { basename, extname, join, normalize, relative } from 'path';
import { app, dialog, shell, remote } from 'electron';
const fastGlob = require('fast-glob');
const glob = require('glob');
const pify = require('pify');
const fse = require('fs-extra');
const toArrayBuffer = require('to-arraybuffer');
const { map, isArrayBuffer, isString } = require('lodash');
const normalizePath = require('normalize-path');
const sanitizeFilename = require('sanitize-filename');
const getGuid = require('uuid/v4');
const { isPathInside } = require('../ts/util/isPathInside');
const { isWindows } = require('../ts/OS');
const {
writeWindowsZoneIdentifier,
} = require('../ts/util/windowsZoneIdentifier');
import fastGlob from 'fast-glob';
import glob from 'glob';
import pify from 'pify';
import fse from 'fs-extra';
import { map, isArrayBuffer, isString } from 'lodash';
import normalizePath from 'normalize-path';
import sanitizeFilename from 'sanitize-filename';
import getGuid from 'uuid/v4';
import { typedArrayToArrayBuffer } from '../ts/Crypto';
import { isPathInside } from '../ts/util/isPathInside';
import { isWindows } from '../ts/OS';
import { writeWindowsZoneIdentifier } from '../ts/util/windowsZoneIdentifier';
type FSAttrType = {
set: (path: string, attribute: string, value: string) => Promise<void>;
};
let xattr: FSAttrType | undefined;
let xattr;
try {
// eslint-disable-next-line max-len
// 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;
exports.getAllAttachments = async userDataPath => {
const dir = exports.getPath(userDataPath);
const pattern = normalizePath(path.join(dir, '**', '*'));
export const getAllAttachments = async (
userDataPath: string
): Promise<ReadonlyArray<string>> => {
const dir = getPath(userDataPath);
const pattern = normalizePath(join(dir, '**', '*'));
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 => {
const dir = exports.getStickersPath(userDataPath);
const pattern = normalizePath(path.join(dir, '**', '*'));
export const getAllStickers = async (
userDataPath: string
): Promise<ReadonlyArray<string>> => {
const dir = getStickersPath(userDataPath);
const pattern = normalizePath(join(dir, '**', '*'));
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 => {
const dir = exports.getDraftPath(userDataPath);
const pattern = normalizePath(path.join(dir, '**', '*'));
export const getAllDraftAttachments = async (
userDataPath: string
): Promise<ReadonlyArray<string>> => {
const dir = getDraftPath(userDataPath);
const pattern = normalizePath(join(dir, '**', '*'));
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 () => {
const dir = path.join(__dirname, '../images');
const pattern = path.join(dir, '**', '*.svg');
export const getBuiltInImages = async (): Promise<ReadonlyArray<string>> => {
const dir = join(__dirname, '../images');
const pattern = join(dir, '**', '*.svg');
// 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
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
exports.getPath = userDataPath => {
export const getPath = (userDataPath: string): string => {
if (!isString(userDataPath)) {
throw new TypeError("'userDataPath' must be a string");
}
return path.join(userDataPath, PATH);
return join(userDataPath, PATH);
};
// getStickersPath :: AbsolutePath -> AbsolutePath
exports.getStickersPath = userDataPath => {
export const getStickersPath = (userDataPath: string): string => {
if (!isString(userDataPath)) {
throw new TypeError("'userDataPath' must be a string");
}
return path.join(userDataPath, STICKER_PATH);
return join(userDataPath, STICKER_PATH);
};
// getTempPath :: AbsolutePath -> AbsolutePath
exports.getTempPath = userDataPath => {
export const getTempPath = (userDataPath: string): string => {
if (!isString(userDataPath)) {
throw new TypeError("'userDataPath' must be a string");
}
return path.join(userDataPath, TEMP_PATH);
return join(userDataPath, TEMP_PATH);
};
// getDraftPath :: AbsolutePath -> AbsolutePath
exports.getDraftPath = userDataPath => {
export const getDraftPath = (userDataPath: string): string => {
if (!isString(userDataPath)) {
throw new TypeError("'userDataPath' must be a string");
}
return path.join(userDataPath, DRAFT_PATH);
return join(userDataPath, DRAFT_PATH);
};
// clearTempPath :: AbsolutePath -> AbsolutePath
exports.clearTempPath = userDataPath => {
const tempPath = exports.getTempPath(userDataPath);
export const clearTempPath = (userDataPath: string): Promise<void> => {
const tempPath = getTempPath(userDataPath);
return fse.emptyDir(tempPath);
};
// createReader :: AttachmentsPath ->
// RelativePath ->
// IO (Promise ArrayBuffer)
exports.createReader = root => {
export const createReader = (
root: string
): ((relativePath: string) => Promise<ArrayBuffer>) => {
if (!isString(root)) {
throw new TypeError("'root' must be a path");
}
return async relativePath => {
return async (relativePath: string): Promise<ArrayBuffer> => {
if (!isString(relativePath)) {
throw new TypeError("'relativePath' must be a string");
}
const absolutePath = path.join(root, relativePath);
const normalized = path.normalize(absolutePath);
const absolutePath = join(root, relativePath);
const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) {
throw new Error('Invalid relative path');
}
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)) {
throw new TypeError("'root' must be a path");
}
return async relativePath => {
return async (relativePath: string): Promise<boolean> => {
if (!isString(relativePath)) {
throw new TypeError("'relativePath' must be a string");
}
const absolutePath = path.join(root, relativePath);
const normalized = path.normalize(absolutePath);
const absolutePath = join(root, relativePath);
const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) {
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)) {
throw new TypeError("'root' must be a path");
}
const userDataPath = getApp().getPath('userData');
return async sourcePath => {
return async (sourcePath: string): Promise<string> => {
if (!isString(sourcePath)) {
throw new TypeError('sourcePath must be a string');
}
@ -173,10 +181,10 @@ exports.copyIntoAttachmentsDirectory = root => {
);
}
const name = exports.createName();
const relativePath = exports.getRelativePath(name);
const absolutePath = path.join(root, relativePath);
const normalized = path.normalize(absolutePath);
const name = createName();
const relativePath = getRelativePath(name);
const absolutePath = join(root, relativePath);
const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) {
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 downloadsPath =
appToUse.getPath('downloads') || appToUse.getPath('home');
const sanitized = sanitizeFilename(name);
const extension = path.extname(sanitized);
const basename = path.basename(sanitized, extension);
const getCandidateName = count => `${basename} (${count})${extension}`;
const extension = extname(sanitized);
const fileBasename = basename(sanitized, extension);
const getCandidateName = (count: number) =>
`${fileBasename} (${count})${extension}`;
const existingFiles = await fse.readdir(downloadsPath);
let candidateName = sanitized;
@ -205,13 +220,13 @@ exports.writeToDownloads = async ({ data, name }) => {
candidateName = getCandidateName(count);
}
const target = path.join(downloadsPath, candidateName);
const normalized = path.normalize(target);
const target = join(downloadsPath, candidateName);
const normalized = normalize(target);
if (!isPathInside(normalized, downloadsPath)) {
throw new Error('Invalid filename!');
}
await writeWithAttributes(normalized, Buffer.from(data));
await writeWithAttributes(normalized, data);
return {
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));
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 appToUse = getApp();
const downloadsPath =
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)) {
throw new Error('Invalid filename!');
}
@ -262,7 +280,13 @@ exports.openFileInDownloads = async name => {
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 browserWindow = remote.getCurrentWindow();
@ -273,57 +297,61 @@ exports.saveAttachmentToDisk = async ({ data, name }) => {
}
);
if (canceled) {
if (canceled || !filePath) {
return null;
}
await writeWithAttributes(filePath, Buffer.from(data));
await writeWithAttributes(filePath, data);
const basename = path.basename(filePath);
const fileBasename = basename(filePath);
return {
fullPath: filePath,
name: basename,
name: fileBasename,
};
};
exports.openFileInFolder = async target => {
export const openFileInFolder = async (target: string): Promise<void> => {
const shellToUse = shell || remote.shell;
shellToUse.showItemInFolder(target);
};
// createWriterForNew :: AttachmentsPath ->
// ArrayBuffer ->
// IO (Promise RelativePath)
exports.createWriterForNew = root => {
export const createWriterForNew = (
root: string
): ((arrayBuffer: ArrayBuffer) => Promise<string>) => {
if (!isString(root)) {
throw new TypeError("'root' must be a path");
}
return async arrayBuffer => {
return async (arrayBuffer: ArrayBuffer) => {
if (!isArrayBuffer(arrayBuffer)) {
throw new TypeError("'arrayBuffer' must be an array buffer");
}
const name = exports.createName();
const relativePath = exports.getRelativePath(name);
return exports.createWriterForExisting(root)({
const name = createName();
const relativePath = getRelativePath(name);
return createWriterForExisting(root)({
data: arrayBuffer,
path: relativePath,
});
};
};
// createWriter :: AttachmentsPath ->
// { data: ArrayBuffer, path: RelativePath } ->
// IO (Promise RelativePath)
exports.createWriterForExisting = root => {
export const createWriterForExisting = (
root: string
): ((options: { data: ArrayBuffer; path: string }) => Promise<string>) => {
if (!isString(root)) {
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)) {
throw new TypeError("'relativePath' must be a path");
}
@ -333,8 +361,8 @@ exports.createWriterForExisting = root => {
}
const buffer = Buffer.from(arrayBuffer);
const absolutePath = path.join(root, relativePath);
const normalized = path.normalize(absolutePath);
const absolutePath = join(root, relativePath);
const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) {
throw new Error('Invalid relative path');
}
@ -345,21 +373,20 @@ exports.createWriterForExisting = root => {
};
};
// createDeleter :: AttachmentsPath ->
// RelativePath ->
// IO Unit
exports.createDeleter = root => {
export const createDeleter = (
root: string
): ((relativePath: string) => Promise<void>) => {
if (!isString(root)) {
throw new TypeError("'root' must be a path");
}
return async relativePath => {
return async (relativePath: string): Promise<void> => {
if (!isString(relativePath)) {
throw new TypeError("'relativePath' must be a string");
}
const absolutePath = path.join(root, relativePath);
const normalized = path.normalize(absolutePath);
const absolutePath = join(root, relativePath);
const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) {
throw new Error('Invalid relative path');
}
@ -367,8 +394,14 @@ exports.createDeleter = root => {
};
};
exports.deleteAll = async ({ userDataPath, attachments }) => {
const deleteFromDisk = exports.createDeleter(exports.getPath(userDataPath));
export const deleteAll = async ({
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) {
const file = attachments[index];
@ -379,10 +412,14 @@ exports.deleteAll = async ({ userDataPath, attachments }) => {
console.log(`deleteAll: deleted ${attachments.length} files`);
};
exports.deleteAllStickers = async ({ userDataPath, stickers }) => {
const deleteFromDisk = exports.createDeleter(
exports.getStickersPath(userDataPath)
);
export const deleteAllStickers = async ({
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) {
const file = stickers[index];
@ -393,40 +430,43 @@ exports.deleteAllStickers = async ({ userDataPath, stickers }) => {
console.log(`deleteAllStickers: deleted ${stickers.length} files`);
};
exports.deleteAllDraftAttachments = async ({ userDataPath, stickers }) => {
const deleteFromDisk = exports.createDeleter(
exports.getDraftPath(userDataPath)
);
export const deleteAllDraftAttachments = async ({
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) {
const file = stickers[index];
for (let index = 0, max = attachments.length; index < max; index += 1) {
const file = attachments[index];
// eslint-disable-next-line no-await-in-loop
await deleteFromDisk(file);
}
console.log(`deleteAllDraftAttachments: deleted ${stickers.length} files`);
console.log(`deleteAllDraftAttachments: deleted ${attachments.length} files`);
};
// createName :: Unit -> IO String
exports.createName = () => {
const buffer = crypto.randomBytes(32);
export const createName = (): string => {
const buffer = randomBytes(32);
return buffer.toString('hex');
};
// getRelativePath :: String -> Path
exports.getRelativePath = name => {
export const getRelativePath = (name: string): string => {
if (!isString(name)) {
throw new TypeError("'name' must be a string");
}
const prefix = name.slice(0, 2);
return path.join(prefix, name);
return join(prefix, name);
};
// createAbsolutePathGetter :: RootPath -> RelativePath -> AbsolutePath
exports.createAbsolutePathGetter = rootPath => relativePath => {
const absolutePath = path.join(rootPath, relativePath);
const normalized = path.normalize(absolutePath);
export const createAbsolutePathGetter = (rootPath: string) => (
relativePath: string
): string => {
const absolutePath = join(rootPath, relativePath);
const normalized = normalize(absolutePath);
if (!isPathInside(normalized, rootPath)) {
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
// SPDX-License-Identifier: AGPL-3.0-only
const path = require('path');
const { app } = require('electron');
const {
import { join } from 'path';
import { app } from 'electron';
import {
Environment,
getEnvironment,
setEnvironment,
parseEnvironment,
} = require('../ts/environment');
} from '../ts/environment';
// In production mode, NODE_ENV cannot be customized by the user
if (app.isPackaged) {
@ -19,12 +20,12 @@ if (app.isPackaged) {
// Set environment vars to configure node-config before requiring it
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) {
// harden production config against the local env
process.env.NODE_CONFIG = '';
process.env.NODE_CONFIG_STRICT_MODE = true;
process.env.NODE_CONFIG_STRICT_MODE = 'true';
process.env.HOSTNAME = '';
process.env.NODE_APP_INSTANCE = '';
process.env.ALLOW_CONFIG_MUTATIONS = '';
@ -33,9 +34,18 @@ if (getEnvironment() === Environment.Production) {
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
// eslint-disable-next-line import/order
const config = require('config');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const config: ConfigType = require('config');
config.environment = getEnvironment();
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)}`);
});
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
// SPDX-License-Identifier: AGPL-3.0-only
const path = require('path');
const fs = require('fs');
const _ = require('lodash');
const { setup } = require('../js/modules/i18n');
import { join } from 'path';
import { readFileSync } from 'fs';
import { merge } from 'lodash';
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)) {
return 'en';
}
@ -14,10 +17,10 @@ function normalizeLocaleName(locale) {
return locale;
}
function getLocaleMessages(locale) {
function getLocaleMessages(locale: string): LocaleMessagesType {
const onDiskLocale = locale.replace('-', '_');
const targetFile = path.join(
const targetFile = join(
__dirname,
'..',
'_locales',
@ -25,10 +28,20 @@ function getLocaleMessages(locale) {
'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) {
throw new TypeError('`appLocale` is required');
}
@ -51,7 +64,7 @@ function load({ appLocale, logger } = {}) {
messages = getLocaleMessages(localeName);
// We start with english, then overwrite that with anything present in locale
messages = _.merge(english, messages);
messages = merge(english, messages);
} catch (e) {
logger.error(
`Problem loading messages for locale ${localeName} ${e.stack}`
@ -70,7 +83,3 @@ function load({ appLocale, logger } = {}) {
messages,
};
}
module.exports = {
load,
};

View File

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

View File

@ -4,7 +4,11 @@
// The list of permissions is here:
// 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
fullscreen: true, // required to show videos in full-screen
notifications: true, // required to show OS notifications for new messages
@ -19,47 +23,60 @@ const PERMISSIONS = {
pointerLock: false,
};
function _createPermissionHandler(userConfig) {
return (webContents, permission, callback, details) => {
function _createPermissionHandler(
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
// the microphone and camera.
if (permission === 'media') {
if (
details.mediaTypes.includes('audio') ||
details.mediaTypes.includes('video')
details.mediaTypes?.includes('audio') ||
details.mediaTypes?.includes('video')
) {
if (
details.mediaTypes.includes('audio') &&
details.mediaTypes?.includes('audio') &&
userConfig.get('mediaPermissions')
) {
return callback(true);
callback(true);
return;
}
if (
details.mediaTypes.includes('video') &&
details.mediaTypes?.includes('video') &&
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.
// TODO: DESKTOP-1611
return callback(true);
callback(true);
return;
}
if (PERMISSIONS[permission]) {
console.log(`Approving request for permission '${permission}'`);
return callback(true);
callback(true);
return;
}
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
// requested again. Without this, revoked permissions might still be available if
// they've already been used successfully.
@ -69,7 +86,3 @@ function installPermissionsHandler({ session, userConfig }) {
_createPermissionHandler(userConfig)
);
}
module.exports = {
installPermissionsHandler,
};

View File

@ -1,10 +1,21 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const path = require('path');
const fs = require('fs');
import {
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);
if (index < 0) {
return string;
@ -13,20 +24,38 @@ function _eliminateAllAfterCharacter(string, character) {
return string.slice(0, index);
}
function _urlToPath(targetUrl, options = {}) {
const { isWindows } = options;
export function _urlToPath(
targetUrl: string,
options?: { isWindows: boolean }
): string {
const decoded = decodeURIComponent(targetUrl);
const withoutScheme = decoded.slice(isWindows ? 8 : 7);
const withoutScheme = decoded.slice(options?.isWindows ? 8 : 7);
const withoutQuerystring = _eliminateAllAfterCharacter(withoutScheme, '?');
const withoutHash = _eliminateAllAfterCharacter(withoutQuerystring, '#');
return withoutHash;
}
function _createFileHandler({ userDataPath, installPath, isWindows }) {
return (request, callback) => {
function _createFileHandler({
userDataPath,
installPath,
isWindows,
}: {
userDataPath: string;
installPath: string;
isWindows: boolean;
}) {
return (request: ProtocolRequest, callback: CallbackType): void => {
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 {
targetPath = _urlToPath(request.url, { isWindows });
} catch (err) {
@ -38,24 +67,26 @@ function _createFileHandler({ userDataPath, installPath, isWindows }) {
`Warning: denying request because of an error: ${errorMessage}`
);
// 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
return callback({ error: -300 });
callback({ error: -300 });
return;
}
// 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
// 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
const properCasing = isWindows ? realPath.toLowerCase() : realPath;
if (!path.isAbsolute(realPath)) {
if (!isAbsolute(realPath)) {
console.log(
`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 (
@ -69,21 +100,27 @@ function _createFileHandler({ userDataPath, installPath, isWindows }) {
console.log(
`Warning: denying request to path '${realPath}' (userDataPath: '${userDataPath}', installPath: '${installPath}')`
);
return callback();
callback({ error: -10 });
return;
}
return callback({
callback({
path: realPath,
});
};
}
function installFileHandler({
export function installFileHandler({
protocol,
userDataPath,
installPath,
isWindows,
}) {
}: {
protocol: typeof ElectronProtocol;
userDataPath: string;
installPath: string;
isWindows: boolean;
}): void {
protocol.interceptFileProtocol(
'file',
_createFileHandler({ userDataPath, installPath, isWindows })
@ -91,11 +128,20 @@ function installFileHandler({
}
// Turn off browser URI scheme since we do all network requests via Node.js
function _disabledHandler(request, callback) {
return callback();
function _disabledHandler(
_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('content', _disabledHandler);
protocol.interceptFileProtocol('chrome', _disabledHandler);
@ -114,9 +160,3 @@ function installWebHandler({ protocol, enableHttp }) {
protocol.interceptFileProtocol('wss', _disabledHandler);
}
}
module.exports = {
_urlToPath,
installFileHandler,
installWebHandler,
};

View File

@ -3,13 +3,20 @@
/* eslint-disable strict */
const { Menu, clipboard, nativeImage } = require('electron');
const osLocale = require('os-locale');
const { uniq } = require('lodash');
const url = require('url');
const { maybeParseUrl } = require('../ts/util/url');
import { BrowserWindow, Menu, clipboard, nativeImage } from 'electron';
import { sync as osLocaleSync } from 'os-locale';
import { uniq } from 'lodash';
import { fileURLToPath } from '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];
// Attempt to find the exact locale
const candidateLocales = uniq([userLocale, baseLocale]).filter(l =>
@ -25,9 +32,12 @@ function getLanguages(userLocale, availableLocales) {
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 userLocale = osLocale.sync().replace(/_/g, '-');
const userLocale = osLocaleSync().replace(/_/g, '-');
const availableLocales = session.availableSpellCheckerLanguages;
const languages = getLanguages(userLocale, availableLocales);
console.log(`spellcheck: user locale: ${userLocale}`);
@ -49,7 +59,7 @@ exports.setup = (browserWindow, messages) => {
// Popup editor menu
if (showMenu) {
const template = [];
const template: MenuListType = [];
if (isMisspelled) {
if (params.dictionarySuggestions.length > 0) {
@ -104,7 +114,7 @@ exports.setup = (browserWindow, messages) => {
}
const image = nativeImage.createFromPath(
url.fileURLToPath(params.srcURL)
fileURLToPath(params.srcURL)
);
clipboard.writeImage(image);
};
@ -136,14 +146,14 @@ exports.setup = (browserWindow, messages) => {
if (editFlags.canSelectAll && params.isEditable) {
template.push({
label: messages.editMenuSelectAll.message,
role: 'selectall',
role: 'selectAll',
});
}
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
// SPDX-License-Identifier: AGPL-3.0-only
const electron = require('electron');
const { remove: removeUserConfig } = require('./user_config');
const { remove: removeEphemeralConfig } = require('./ephemeral_config');
import { ipcMain } from 'electron';
const { ipcMain } = electron;
import { remove as removeUserConfig } from './user_config';
import { remove as removeEphemeralConfig } from './ephemeral_config';
let sql;
module.exports = {
initialize,
type SQLType = {
sqlCall(callName: string, args: ReadonlyArray<unknown>): unknown;
};
let sql: SQLType | undefined;
let initialized = false;
const SQL_CHANNEL_KEY = 'sql-channel';
const ERASE_SQL_KEY = 'erase-sql-key';
function initialize(mainSQL) {
export function initialize(mainSQL: SQLType): void {
if (initialized) {
throw new Error('sqlChannels: already initialized!');
}
@ -28,6 +27,9 @@ function initialize(mainSQL) {
ipcMain.on(SQL_CHANNEL_KEY, async (event, jobId, callName, ...args) => {
try {
if (!sql) {
throw new Error(`${SQL_CHANNEL_KEY}: Not yet initialized!`);
}
const result = await sql.sqlCall(callName, args);
event.sender.send(`${SQL_CHANNEL_KEY}-done`, jobId, null, result);
} catch (error) {

View File

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

View File

@ -71,7 +71,7 @@ const startInTray = process.argv.some(arg => arg === '--start-in-tray');
const usingTrayIcon =
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
// userData directory.
@ -91,7 +91,7 @@ const attachments = require('./app/attachments');
const attachmentChannel = require('./app/attachment_channel');
const bounce = require('./ts/services/bounce');
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 ephemeralConfig = require('./app/ephemeral_config');
const logging = require('./ts/logging/main_process_logging');
@ -1353,7 +1353,7 @@ app.on('ready', async () => {
);
await attachments.deleteAllDraftAttachments({
userDataPath,
stickers: orphanedDraftAttachments,
attachments: orphanedDraftAttachments,
});
}

View File

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

View File

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

View File

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

View File

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

View File

@ -48,7 +48,7 @@
},
{
"label": "Paste and Match Style",
"role": "pasteandmatchstyle"
"role": "pasteAndMatchStyle"
},
{
"label": "Delete",
@ -56,7 +56,7 @@
},
{
"label": "Select All",
"role": "selectall"
"role": "selectAll"
}
]
},
@ -65,16 +65,16 @@
"submenu": [
{
"label": "Actual Size",
"role": "resetzoom"
"role": "resetZoom"
},
{
"accelerator": "Control+=",
"label": "Zoom In",
"role": "zoomin"
"role": "zoomIn"
},
{
"label": "Zoom Out",
"role": "zoomout"
"role": "zoomOut"
},
{
"type": "separator"
@ -95,7 +95,7 @@
},
{
"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;
};
export type LocalizerType = (
key: string,
placeholders: Array<string> | ReplacementValuesType<string>
) => string;
export type LocaleType = {
i18n: (
key: string,
placeholders: Array<string> | ReplacementValuesType<string>
) => string;
i18n: LocalizerType;
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(",
"path": "components/indexeddb-backbonejs-adapter/backbone-indexeddb.js",

View File

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

View File

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

View File

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