Move all files under /app to typescript
This commit is contained in:
parent
7bb6ad534f
commit
24960d481e
40 changed files with 745 additions and 620 deletions
|
@ -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
|
||||
|
|
|
@ -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
1
.gitignore
vendored
|
@ -26,6 +26,7 @@ stylesheets/*.css
|
|||
test/test.js
|
||||
|
||||
# React / TypeScript
|
||||
app/*.js
|
||||
ts/**/*.js
|
||||
ts/protobuf/*.d.ts
|
||||
sticker-creator/**/*.js
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
# supports `.gitignore`: https://github.com/prettier/prettier/issues/2294
|
||||
|
||||
# Generated files
|
||||
app/**/*.js
|
||||
config/local-*.json
|
||||
config/local.json
|
||||
dist/**
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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 {
|
4
app/attachments.d.ts
vendored
4
app/attachments.d.ts
vendored
|
@ -1,4 +0,0 @@
|
|||
// Copyright 2019-2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
export function getTempPath(userDataPath: string): string;
|
|
@ -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');
|
||||
}
|
|
@ -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
71
app/base_config.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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;
|
|
@ -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
19
app/ephemeral_config.ts
Normal 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);
|
|
@ -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
64
app/global_errors.ts
Normal 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));
|
||||
});
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
|
@ -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) {
|
|
@ -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;
|
4
app/user_config.d.ts
vendored
4
app/user_config.d.ts
vendored
|
@ -1,4 +0,0 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
export function remove(): void;
|
|
@ -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);
|
5
app/window_state.d.ts
vendored
5
app/window_state.d.ts
vendored
|
@ -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;
|
|
@ -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,
|
||||
};
|
6
main.js
6
main.js
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -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
10
ts/os-locale.d.ts
vendored
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -57,6 +57,7 @@
|
|||
},
|
||||
"include": [
|
||||
"ts/**/*",
|
||||
"app/*",
|
||||
"node_modules/zkgroup/zkgroup/modules/*",
|
||||
"package.json"
|
||||
]
|
||||
|
|
|
@ -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=
|
||||
|
|
Loading…
Reference in a new issue