Get rid of `electron.remote`

This commit is contained in:
Fedor Indutny 2021-10-27 10:54:16 -07:00 committed by GitHub
parent 246583d274
commit 76d8b5e375
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 563 additions and 591 deletions

View File

@ -3299,30 +3299,6 @@ Signal Desktop makes use of the following open source projects.
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
## tmp
The MIT License (MIT)
Copyright (c) 2014 KARASZI István
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

@ -8,7 +8,7 @@ import {
getStickersPath,
getTempPath,
getDraftPath,
} from './attachments';
} from '../ts/util/attachments';
let initialized = false;

View File

@ -1,43 +1,24 @@
// Copyright 2018-2020 Signal Messenger, LLC
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { randomBytes } from 'crypto';
import { basename, join, normalize, relative } from 'path';
import { app, dialog, shell, remote } from 'electron';
import { join, relative } from 'path';
import fastGlob from 'fast-glob';
import glob from 'glob';
import pify from 'pify';
import fse from 'fs-extra';
import { map, isTypedArray, isString } from 'lodash';
import { map } from 'lodash';
import normalizePath from 'normalize-path';
import getGuid from 'uuid/v4';
import { isPathInside } from '../ts/util/isPathInside';
import { isWindows } from '../ts/OS';
import { writeWindowsZoneIdentifier } from '../ts/util/windowsZoneIdentifier';
import {
getPath,
getStickersPath,
getDraftPath,
getTempPath,
createDeleter,
} from '../ts/util/attachments';
type FSAttrType = {
set: (path: string, attribute: string, value: string) => Promise<void>;
};
let xattr: FSAttrType | undefined;
try {
// eslint-disable-next-line max-len
// eslint-disable-next-line global-require, import/no-extraneous-dependencies, import/no-unresolved
xattr = require('fs-xattr');
} catch (e) {
console.log('x-attr dependency did not load successfully');
}
const PATH = 'attachments.noindex';
const AVATAR_PATH = 'avatars.noindex';
const STICKER_PATH = 'stickers.noindex';
const TEMP_PATH = 'temp';
const DRAFT_PATH = 'drafts.noindex';
const getApp = () => app || remote.app;
export * from '../ts/util/attachments';
export const getAllAttachments = async (
userDataPath: string
@ -79,274 +60,11 @@ export const getBuiltInImages = async (): Promise<ReadonlyArray<string>> => {
return map(files, file => relative(dir, file));
};
const createPathGetter = (subpath: string) => (
userDataPath: string
): string => {
if (!isString(userDataPath)) {
throw new TypeError("'userDataPath' must be a string");
}
return join(userDataPath, subpath);
};
export const getAvatarsPath = createPathGetter(AVATAR_PATH);
export const getDraftPath = createPathGetter(DRAFT_PATH);
export const getPath = createPathGetter(PATH);
export const getStickersPath = createPathGetter(STICKER_PATH);
export const getTempPath = createPathGetter(TEMP_PATH);
export const clearTempPath = (userDataPath: string): Promise<void> => {
const tempPath = getTempPath(userDataPath);
return fse.emptyDir(tempPath);
};
export const createReader = (
root: string
): ((relativePath: string) => Promise<Uint8Array>) => {
if (!isString(root)) {
throw new TypeError("'root' must be a path");
}
return async (relativePath: string): Promise<Uint8Array> => {
if (!isString(relativePath)) {
throw new TypeError("'relativePath' must be a string");
}
const absolutePath = join(root, relativePath);
const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) {
throw new Error('Invalid relative path');
}
return fse.readFile(normalized);
};
};
export const createDoesExist = (
root: string
): ((relativePath: string) => Promise<boolean>) => {
if (!isString(root)) {
throw new TypeError("'root' must be a path");
}
return async (relativePath: string): Promise<boolean> => {
if (!isString(relativePath)) {
throw new TypeError("'relativePath' must be a string");
}
const absolutePath = join(root, relativePath);
const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) {
throw new Error('Invalid relative path');
}
try {
await fse.access(normalized, fse.constants.F_OK);
return true;
} catch (error) {
return false;
}
};
};
export const copyIntoAttachmentsDirectory = (
root: string
): ((sourcePath: string) => Promise<{ path: string; size: number }>) => {
if (!isString(root)) {
throw new TypeError("'root' must be a path");
}
const userDataPath = getApp().getPath('userData');
return async (
sourcePath: string
): Promise<{ path: string; size: number }> => {
if (!isString(sourcePath)) {
throw new TypeError('sourcePath must be a string');
}
if (!isPathInside(sourcePath, userDataPath)) {
throw new Error(
"'sourcePath' must be relative to the user config directory"
);
}
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');
}
await fse.ensureFile(normalized);
await fse.copy(sourcePath, normalized);
const { size } = await fse.stat(normalized);
return {
path: relativePath,
size,
};
};
};
async function writeWithAttributes(
target: string,
data: Uint8Array
): Promise<void> {
await fse.writeFile(target, Buffer.from(data));
if (process.platform === 'darwin' && xattr) {
// kLSQuarantineTypeInstantMessageAttachment
const type = '0003';
// Hexadecimal seconds since epoch
const timestamp = Math.trunc(Date.now() / 1000).toString(16);
const appName = 'Signal';
const guid = getGuid();
// https://ilostmynotes.blogspot.com/2012/06/gatekeeper-xprotect-and-quarantine.html
const attrValue = `${type};${timestamp};${appName};${guid}`;
await xattr.set(target, 'com.apple.quarantine', attrValue);
} else if (isWindows()) {
// This operation may fail (see the function's comments), which is not a show-stopper.
try {
await writeWindowsZoneIdentifier(target);
} catch (err) {
console.warn('Failed to write Windows Zone.Identifier file; continuing');
}
}
}
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 = join(downloadsPath, name);
const normalized = normalize(target);
if (!isPathInside(normalized, downloadsPath)) {
throw new Error('Invalid filename!');
}
shellToUse.showItemInFolder(normalized);
};
export const saveAttachmentToDisk = async ({
data,
name,
}: {
data: Uint8Array;
name: string;
}): Promise<null | { fullPath: string; name: string }> => {
const dialogToUse = dialog || remote.dialog;
const browserWindow = remote.getCurrentWindow();
const { canceled, filePath } = await dialogToUse.showSaveDialog(
browserWindow,
{
defaultPath: name,
}
);
if (canceled || !filePath) {
return null;
}
await writeWithAttributes(filePath, data);
const fileBasename = basename(filePath);
return {
fullPath: filePath,
name: fileBasename,
};
};
export const openFileInFolder = async (target: string): Promise<void> => {
const shellToUse = shell || remote.shell;
shellToUse.showItemInFolder(target);
};
export const createWriterForNew = (
root: string
): ((bytes: Uint8Array) => Promise<string>) => {
if (!isString(root)) {
throw new TypeError("'root' must be a path");
}
return async (bytes: Uint8Array) => {
if (!isTypedArray(bytes)) {
throw new TypeError("'bytes' must be a typed array");
}
const name = createName();
const relativePath = getRelativePath(name);
return createWriterForExisting(root)({
data: bytes,
path: relativePath,
});
};
};
export const createWriterForExisting = (
root: string
): ((options: { data: Uint8Array; path: string }) => Promise<string>) => {
if (!isString(root)) {
throw new TypeError("'root' must be a path");
}
return async ({
data: bytes,
path: relativePath,
}: {
data: Uint8Array;
path: string;
}): Promise<string> => {
if (!isString(relativePath)) {
throw new TypeError("'relativePath' must be a path");
}
if (!isTypedArray(bytes)) {
throw new TypeError("'arrayBuffer' must be an array buffer");
}
const buffer = Buffer.from(bytes);
const absolutePath = join(root, relativePath);
const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) {
throw new Error('Invalid relative path');
}
await fse.ensureFile(normalized);
await fse.writeFile(normalized, buffer);
return relativePath;
};
};
export const createDeleter = (
root: string
): ((relativePath: string) => Promise<void>) => {
if (!isString(root)) {
throw new TypeError("'root' must be a path");
}
return async (relativePath: string): Promise<void> => {
if (!isString(relativePath)) {
throw new TypeError("'relativePath' must be a string");
}
const absolutePath = join(root, relativePath);
const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) {
throw new Error('Invalid relative path');
}
await fse.remove(absolutePath);
};
};
export const deleteAll = async ({
userDataPath,
attachments,
@ -400,28 +118,3 @@ export const deleteAllDraftAttachments = async ({
console.log(`deleteAllDraftAttachments: deleted ${attachments.length} files`);
};
export const createName = (): string => {
const buffer = randomBytes(32);
return buffer.toString('hex');
};
export const getRelativePath = (name: string): string => {
if (!isString(name)) {
throw new TypeError("'name' must be a string");
}
const prefix = name.slice(0, 2);
return join(prefix, name);
};
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');
}
return normalized;
};

View File

@ -310,6 +310,9 @@ function prepareUrl(
serverPublicParams: config.get<string>('serverPublicParams'),
serverTrustRoot: config.get<string>('serverTrustRoot'),
appStartInitialSpellcheckSetting,
userDataPath: app.getPath('userData'),
downloadsPath: app.getPath('downloads'),
homePath: app.getPath('home'),
...moreKeys,
}).href;
}
@ -2063,3 +2066,31 @@ async function ensureFilePermissions(onlyFiles?: Array<string>) {
getLogger().info(`Finish ensuring permissions in ${Date.now() - start}ms`);
}
ipc.handle('get-auto-launch', async () => {
return app.getLoginItemSettings().openAtLogin;
});
ipc.handle('set-auto-launch', async (_event, value) => {
app.setLoginItemSettings({ openAtLogin: Boolean(value) });
});
ipc.on('show-message-box', (_event, { type, message }) => {
dialog.showMessageBox({ type, message });
});
ipc.on('show-item-in-folder', (_event, folder) => {
shell.showItemInFolder(folder);
});
ipc.handle('show-save-dialog', async (_event, { defaultPath }) => {
if (!mainWindow) {
getLogger().warn('show-save-dialog: no main window');
return { canceled: true };
}
return dialog.showSaveDialog(mainWindow, {
defaultPath,
});
});

View File

@ -17,7 +17,7 @@ const {
sample,
} = require('lodash');
const Attachments = require('../../app/attachments');
const Attachments = require('../../ts/windows/attachments');
const Message = require('./types/message');
const { sleep } = require('../../ts/util/sleep');

View File

@ -161,7 +161,6 @@
"sharp": "0.28.1",
"split2": "4.0.0",
"testcheck": "1.0.0-rc.2",
"tmp": "0.0.33",
"typeface-inter": "3.10.0",
"underscore": "1.12.1",
"uuid": "3.3.2",

View File

@ -21,9 +21,6 @@ try {
const { getEnvironment, Environment } = require('./ts/environment');
const ipc = electron.ipcRenderer;
const { remote } = electron;
const { app } = remote;
const config = require('url').parse(window.location.toString(), true).query;
const log = require('./ts/logging/log');
@ -72,9 +69,11 @@ try {
window.getServerPublicParams = () => config.serverPublicParams;
window.getSfuUrl = () => config.sfuUrl;
window.isBehindProxy = () => Boolean(config.proxyUrl);
window.getAutoLaunch = () => app.getLoginItemSettings().openAtLogin;
window.getAutoLaunch = () => {
return ipc.invoke('get-auto-launch');
};
window.setAutoLaunch = value => {
app.setLoginItemSettings({ openAtLogin: Boolean(value) });
return ipc.invoke('set-auto-launch', value);
};
window.isBeforeVersion = (toCheck, baseVersion) => {
@ -405,7 +404,7 @@ try {
window.PQueue = require('p-queue').default;
const Signal = require('./js/modules/signal');
const Attachments = require('./app/attachments');
const Attachments = require('./ts/windows/attachments');
const { locale } = config;
window.i18n = SignalContext.i18n;
@ -418,7 +417,7 @@ try {
});
window.moment.locale(locale);
const userDataPath = app.getPath('userData');
const userDataPath = SignalContext.getPath('userData');
window.baseAttachmentsPath = Attachments.getPath(userDataPath);
window.baseStickersPath = Attachments.getStickersPath(userDataPath);
window.baseTempPath = Attachments.getTempPath(userDataPath);

View File

@ -27,7 +27,6 @@ window.test = {
fastGlob,
normalizePath: require('normalize-path'),
fse: require('fs-extra'),
tmp: require('tmp'),
path: require('path'),
basePath: __dirname,
attachmentsPath: window.Signal.Migrations.attachmentsPath,

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
/* global window */
const { ipcRenderer: ipc, remote } = require('electron');
const { ipcRenderer: ipc } = require('electron');
const sharp = require('sharp');
const pify = require('pify');
const { readFile } = require('fs');
@ -23,8 +23,6 @@ const { SignalService: Proto } = require('../ts/protobuf');
const { getEnvironment } = require('../ts/environment');
const { createSetting } = require('../ts/util/preload');
const { dialog } = remote;
const STICKER_SIZE = 512;
const MIN_STICKER_DIMENSION = 10;
const MAX_STICKER_DIMENSION = STICKER_SIZE;
@ -171,7 +169,7 @@ window.encryptAndUpload = async (
'StickerCreator--Authentication--error'
];
dialog.showMessageBox({
ipc.send('show-message-box', {
type: 'warning',
message,
});

View File

@ -1,15 +1,13 @@
// Copyright 2018-2021 Signal Messenger, LLC
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const fs = require('fs');
const fse = require('fs-extra');
const path = require('path');
const tmp = require('tmp');
const { assert } = require('chai');
const { app } = require('electron');
const Attachments = require('../../app/attachments');
const Bytes = require('../../ts/Bytes');
import { assert } from 'chai';
import path from 'path';
import fs from 'fs';
import os from 'os';
import fse from 'fs-extra';
import * as Attachments from '../../windows/attachments';
import * as Bytes from '../../Bytes';
const PREFIX_LENGTH = 2;
const NUM_SEPARATORS = 1;
@ -17,45 +15,128 @@ const NAME_LENGTH = 64;
const PATH_LENGTH = PREFIX_LENGTH + NUM_SEPARATORS + NAME_LENGTH;
describe('Attachments', () => {
describe('createWriterForNew', () => {
let tempRootDirectory = null;
const USER_DATA = window.SignalContext.getPath('userData');
let tempRootDirectory: string;
before(() => {
tempRootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'Signal'));
});
after(async () => {
await fse.remove(tempRootDirectory);
});
describe('createReader', () => {
it('should read file from disk', async () => {
const tempDirectory = path.join(
tempRootDirectory,
'Attachments_createReader'
);
const relativePath = Attachments.getRelativePath(
Attachments.createName()
);
const fullPath = path.join(tempDirectory, relativePath);
const input = Bytes.fromString('test string');
const inputBuffer = Buffer.from(input);
await fse.ensureFile(fullPath);
await fse.writeFile(fullPath, inputBuffer);
const output = await Attachments.createReader(tempDirectory)(
relativePath
);
assert.deepEqual(input, output);
});
it('throws if relative path goes higher than root', async () => {
const tempDirectory = path.join(
tempRootDirectory,
'Attachments_createReader'
);
const relativePath = '../../parent';
await assert.isRejected(
Attachments.createReader(tempDirectory)(relativePath),
'Invalid relative path'
);
});
});
describe('copyIntoAttachmentsDirectory', () => {
let filesToRemove: Array<string>;
const getFakeAttachmentsDirectory = () => {
const result = path.join(
USER_DATA,
`fake-attachments-${Date.now()}-${Math.random()
.toString()
.substring(2)}`
);
filesToRemove.push(result);
return result;
};
// These tests use the `userData` path. In `electron-mocha`, these are temporary
// directories; no need to be concerned about messing with the "real" directory.
before(() => {
tempRootDirectory = tmp.dirSync().name;
filesToRemove = [];
});
after(async () => {
await fse.remove(tempRootDirectory);
await Promise.all(filesToRemove.map(toRemove => fse.remove(toRemove)));
filesToRemove = [];
});
it('should write file to disk and return path', async () => {
const input = Bytes.fromString('test string');
const tempDirectory = path.join(
tempRootDirectory,
'Attachments_createWriterForNew'
it('throws if passed a non-string', () => {
assert.throws(() => {
Attachments.copyIntoAttachmentsDirectory((1234 as unknown) as string);
}, TypeError);
assert.throws(() => {
Attachments.copyIntoAttachmentsDirectory((null as unknown) as string);
}, TypeError);
});
it('returns a function that rejects if the source path is not a string', async () => {
const copier = Attachments.copyIntoAttachmentsDirectory(
await getFakeAttachmentsDirectory()
);
await assert.isRejected(copier((123 as unknown) as string));
});
it('returns a function that rejects if the source path is not in the user config directory', async () => {
const copier = Attachments.copyIntoAttachmentsDirectory(
await getFakeAttachmentsDirectory()
);
await assert.isRejected(
copier(path.join(tempRootDirectory, 'hello.txt')),
"'sourcePath' must be relative to the user config directory"
);
});
it('returns a function that copies the source path into the attachments directory and returns its path and size', async () => {
const attachmentsPath = await getFakeAttachmentsDirectory();
const someOtherPath = path.join(USER_DATA, 'somethingElse');
await fse.outputFile(someOtherPath, 'hello world');
filesToRemove.push(someOtherPath);
const copier = Attachments.copyIntoAttachmentsDirectory(attachmentsPath);
const { path: relativePath, size } = await copier(someOtherPath);
const absolutePath = path.join(attachmentsPath, relativePath);
assert.notEqual(someOtherPath, absolutePath);
assert.strictEqual(
await fs.promises.readFile(absolutePath, 'utf8'),
'hello world'
);
const outputPath = await Attachments.createWriterForNew(tempDirectory)(
input
);
const output = await fse.readFile(path.join(tempDirectory, outputPath));
assert.lengthOf(outputPath, PATH_LENGTH);
const inputBuffer = Buffer.from(input);
assert.deepEqual(inputBuffer, output);
assert.strictEqual(size, 'hello world'.length);
});
});
describe('createWriterForExisting', () => {
let tempRootDirectory = null;
before(() => {
tempRootDirectory = tmp.dirSync().name;
});
after(async () => {
await fse.remove(tempRootDirectory);
});
it('should write file to disk on given path and return path', async () => {
const input = Bytes.fromString('test string');
const tempDirectory = path.join(
@ -104,205 +185,23 @@ describe('Attachments', () => {
});
});
describe('createReader', () => {
let tempRootDirectory = null;
before(() => {
tempRootDirectory = tmp.dirSync().name;
});
after(async () => {
await fse.remove(tempRootDirectory);
});
it('should read file from disk', async () => {
describe('createWriterForNew', () => {
it('should write file to disk and return path', async () => {
const input = Bytes.fromString('test string');
const tempDirectory = path.join(
tempRootDirectory,
'Attachments_createReader'
'Attachments_createWriterForNew'
);
const relativePath = Attachments.getRelativePath(
Attachments.createName()
const outputPath = await Attachments.createWriterForNew(tempDirectory)(
input
);
const fullPath = path.join(tempDirectory, relativePath);
const input = Bytes.fromString('test string');
const output = await fse.readFile(path.join(tempDirectory, outputPath));
assert.lengthOf(outputPath, PATH_LENGTH);
const inputBuffer = Buffer.from(input);
await fse.ensureFile(fullPath);
await fse.writeFile(fullPath, inputBuffer);
const output = await Attachments.createReader(tempDirectory)(
relativePath
);
assert.deepEqual(input, output);
});
it('throws if relative path goes higher than root', async () => {
const tempDirectory = path.join(
tempRootDirectory,
'Attachments_createReader'
);
const relativePath = '../../parent';
try {
await Attachments.createReader(tempDirectory)(relativePath);
} catch (error) {
assert.strictEqual(error.message, 'Invalid relative path');
return;
}
throw new Error('Expected an error');
});
});
describe('copyIntoAttachmentsDirectory', () => {
// These tests use the `userData` path. In `electron-mocha`, these are temporary
// directories; no need to be concerned about messing with the "real" directory.
before(function thisNeeded() {
this.filesToRemove = [];
this.getFakeAttachmentsDirectory = () => {
const result = path.join(
app.getPath('userData'),
`fake-attachments-${Date.now()}-${Math.random()
.toString()
.substring(2)}`
);
this.filesToRemove.push(result);
return result;
};
this.getTempFile = () => {
const result = tmp.fileSync().name;
this.filesToRemove.push(result);
return result;
};
});
after(async function thisNeeded() {
await Promise.all(
this.filesToRemove.map(toRemove => fse.remove(toRemove))
);
});
it('throws if passed a non-string', () => {
assert.throws(() => {
Attachments.copyIntoAttachmentsDirectory(1234);
}, TypeError);
assert.throws(() => {
Attachments.copyIntoAttachmentsDirectory(null);
}, TypeError);
});
it('returns a function that rejects if the source path is not a string', async function thisNeeded() {
const copier = Attachments.copyIntoAttachmentsDirectory(
await this.getFakeAttachmentsDirectory()
);
return copier(123)
.then(() => {
assert.fail('This should never be run');
})
.catch(err => {
assert.instanceOf(err, TypeError);
});
});
it('returns a function that rejects if the source path is not in the user config directory', async function thisNeeded() {
const copier = Attachments.copyIntoAttachmentsDirectory(
await this.getFakeAttachmentsDirectory()
);
return copier(this.getTempFile())
.then(() => {
assert.fail('This should never be run');
})
.catch(err => {
assert.instanceOf(err, Error);
assert.strictEqual(
err.message,
"'sourcePath' must be relative to the user config directory"
);
});
});
it('returns a function that copies the source path into the attachments directory and returns its path and size', async function thisNeeded() {
const attachmentsPath = await this.getFakeAttachmentsDirectory();
const someOtherPath = path.join(app.getPath('userData'), 'somethingElse');
await fse.outputFile(someOtherPath, 'hello world');
this.filesToRemove.push(someOtherPath);
const copier = Attachments.copyIntoAttachmentsDirectory(attachmentsPath);
const { path: relativePath, size } = await copier(someOtherPath);
const absolutePath = path.join(attachmentsPath, relativePath);
assert.notEqual(someOtherPath, absolutePath);
assert.strictEqual(
await fs.promises.readFile(absolutePath, 'utf8'),
'hello world'
);
assert.strictEqual(size, 'hello world'.length);
});
});
describe('createDeleter', () => {
let tempRootDirectory = null;
before(() => {
tempRootDirectory = tmp.dirSync().name;
});
after(async () => {
await fse.remove(tempRootDirectory);
});
it('should delete file from disk', async () => {
const tempDirectory = path.join(
tempRootDirectory,
'Attachments_createDeleter'
);
const relativePath = Attachments.getRelativePath(
Attachments.createName()
);
const fullPath = path.join(tempDirectory, relativePath);
const input = Bytes.fromString('test string');
const inputBuffer = Buffer.from(input);
await fse.ensureFile(fullPath);
await fse.writeFile(fullPath, inputBuffer);
await Attachments.createDeleter(tempDirectory)(relativePath);
const existsFile = await fse.exists(fullPath);
assert.isFalse(existsFile);
});
it('throws if relative path goes higher than root', async () => {
const tempDirectory = path.join(
tempRootDirectory,
'Attachments_createDeleter'
);
const relativePath = '../../parent';
try {
await Attachments.createDeleter(tempDirectory)(relativePath);
} catch (error) {
assert.strictEqual(error.message, 'Invalid relative path');
return;
}
throw new Error('Expected an error');
});
});
describe('createName', () => {
it('should return random file name with correct length', () => {
assert.lengthOf(Attachments.createName(), NAME_LENGTH);
});
});
describe('getRelativePath', () => {
it('should return correct path', () => {
const name =
'608ce3bc536edbf7637a6aeb6040bdfec49349140c0dd43e97c7ce263b15ff7e';
assert.lengthOf(Attachments.getRelativePath(name), PATH_LENGTH);
assert.deepEqual(inputBuffer, output);
});
});
@ -336,4 +235,59 @@ describe('Attachments', () => {
throw new Error('Expected an error');
});
});
describe('createName', () => {
it('should return random file name with correct length', () => {
assert.lengthOf(Attachments.createName(), NAME_LENGTH);
});
});
describe('getRelativePath', () => {
it('should return correct path', () => {
const name =
'608ce3bc536edbf7637a6aeb6040bdfec49349140c0dd43e97c7ce263b15ff7e';
assert.lengthOf(Attachments.getRelativePath(name), PATH_LENGTH);
});
});
describe('createDeleter', () => {
it('should delete file from disk', async () => {
const tempDirectory = path.join(
tempRootDirectory,
'Attachments_createDeleter'
);
const relativePath = Attachments.getRelativePath(
Attachments.createName()
);
const fullPath = path.join(tempDirectory, relativePath);
const input = Bytes.fromString('test string');
const inputBuffer = Buffer.from(input);
await fse.ensureFile(fullPath);
await fse.writeFile(fullPath, inputBuffer);
await Attachments.createDeleter(tempDirectory)(relativePath);
const existsFile = await fse.pathExists(fullPath);
assert.isFalse(existsFile);
});
it('throws if relative path goes higher than root', async () => {
const tempDirectory = path.join(
tempRootDirectory,
'Attachments_createDeleter'
);
const relativePath = '../../parent';
try {
await Attachments.createDeleter(tempDirectory)(relativePath);
} catch (error) {
assert.strictEqual(error.message, 'Invalid relative path');
return;
}
throw new Error('Expected an error');
});
});
});

View File

@ -26,7 +26,7 @@ import rimraf from 'rimraf';
import type { BrowserWindow } from 'electron';
import { app, ipcMain } from 'electron';
import { getTempPath } from '../../app/attachments';
import { getTempPath } from '../util/attachments';
import { DialogType } from '../types/Dialogs';
import { getUserAgent } from '../util/getUserAgent';
import { isAlpha, isBeta } from '../util/version';

50
ts/util/attachments.ts Normal file
View File

@ -0,0 +1,50 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isString } from 'lodash';
import { join, normalize } from 'path';
import fse from 'fs-extra';
import { isPathInside } from './isPathInside';
const PATH = 'attachments.noindex';
const AVATAR_PATH = 'avatars.noindex';
const STICKER_PATH = 'stickers.noindex';
const TEMP_PATH = 'temp';
const DRAFT_PATH = 'drafts.noindex';
const createPathGetter = (subpath: string) => (
userDataPath: string
): string => {
if (!isString(userDataPath)) {
throw new TypeError("'userDataPath' must be a string");
}
return join(userDataPath, subpath);
};
export const getAvatarsPath = createPathGetter(AVATAR_PATH);
export const getDraftPath = createPathGetter(DRAFT_PATH);
export const getPath = createPathGetter(PATH);
export const getStickersPath = createPathGetter(STICKER_PATH);
export const getTempPath = createPathGetter(TEMP_PATH);
export const createDeleter = (
root: string
): ((relativePath: string) => Promise<void>) => {
if (!isString(root)) {
throw new TypeError("'root' must be a path");
}
return async (relativePath: string): Promise<void> => {
if (!isString(relativePath)) {
throw new TypeError("'relativePath' must be a string");
}
const absolutePath = join(root, relativePath);
const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) {
throw new Error('Invalid relative path');
}
await fse.remove(absolutePath);
};
};

View File

@ -114,7 +114,7 @@ export type IPCEventsCallbacksType = {
type ValuesWithGetters = Omit<
IPCEventsValuesType,
// Optional
'mediaPermissions' | 'mediaCameraPermissions'
'mediaPermissions' | 'mediaCameraPermissions' | 'autoLaunch'
>;
type ValuesWithSetters = Omit<
@ -146,6 +146,7 @@ export type IPCEventsGettersType = {
} & {
getMediaPermissions?: () => Promise<boolean>;
getMediaCameraPermissions?: () => Promise<boolean>;
getAutoLaunch?: () => Promise<boolean>;
};
export type IPCEventsSettersType = {
@ -330,7 +331,7 @@ export function createIPCEvents(
getAutoLaunch: () => window.getAutoLaunch(),
setAutoLaunch: async (value: boolean) => {
window.setAutoLaunch(value);
return window.setAutoLaunch(value);
},
isPhoneNumberSharingEnabled: () => isPhoneNumberSharingEnabled(),

4
ts/window.d.ts vendored
View File

@ -170,8 +170,8 @@ declare global {
imageToBlurHash: typeof imageToBlurHash;
loadImage: any;
isBehindProxy: () => boolean;
getAutoLaunch: () => boolean;
setAutoLaunch: (value: boolean) => void;
getAutoLaunch: () => Promise<boolean>;
setAutoLaunch: (value: boolean) => Promise<void>;
PQueue: typeof PQueue;
PQueueType: PQueue;

268
ts/windows/attachments.ts Normal file
View File

@ -0,0 +1,268 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ipcRenderer } from 'electron';
import { isString, isTypedArray } from 'lodash';
import { join, normalize, basename } from 'path';
import fse from 'fs-extra';
import getGuid from 'uuid/v4';
import { getRandomBytes } from '../Crypto';
import * as Bytes from '../Bytes';
import { isPathInside } from '../util/isPathInside';
import { writeWindowsZoneIdentifier } from '../util/windowsZoneIdentifier';
import { isWindows } from '../OS';
export * from '../util/attachments';
type FSAttrType = {
set: (path: string, attribute: string, value: string) => Promise<void>;
};
let xattr: FSAttrType | undefined;
try {
// eslint-disable-next-line max-len
// eslint-disable-next-line global-require, import/no-extraneous-dependencies, import/no-unresolved
xattr = require('fs-xattr');
} catch (e) {
window.SignalContext.log?.info('x-attr dependency did not load successfully');
}
export const createReader = (
root: string
): ((relativePath: string) => Promise<Uint8Array>) => {
if (!isString(root)) {
throw new TypeError("'root' must be a path");
}
return async (relativePath: string): Promise<Uint8Array> => {
if (!isString(relativePath)) {
throw new TypeError("'relativePath' must be a string");
}
const absolutePath = join(root, relativePath);
const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) {
throw new Error('Invalid relative path');
}
return fse.readFile(normalized);
};
};
export const getRelativePath = (name: string): string => {
if (!isString(name)) {
throw new TypeError("'name' must be a string");
}
const prefix = name.slice(0, 2);
return join(prefix, name);
};
export const createName = (): string => {
const buffer = getRandomBytes(32);
return Bytes.toHex(buffer);
};
export const copyIntoAttachmentsDirectory = (
root: string
): ((sourcePath: string) => Promise<{ path: string; size: number }>) => {
if (!isString(root)) {
throw new TypeError("'root' must be a path");
}
const userDataPath = window.SignalContext.getPath('userData');
return async (
sourcePath: string
): Promise<{ path: string; size: number }> => {
if (!isString(sourcePath)) {
throw new TypeError('sourcePath must be a string');
}
if (!isPathInside(sourcePath, userDataPath)) {
throw new Error(
"'sourcePath' must be relative to the user config directory"
);
}
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');
}
await fse.ensureFile(normalized);
await fse.copy(sourcePath, normalized);
const { size } = await fse.stat(normalized);
return {
path: relativePath,
size,
};
};
};
export const createWriterForNew = (
root: string
): ((bytes: Uint8Array) => Promise<string>) => {
if (!isString(root)) {
throw new TypeError("'root' must be a path");
}
return async (bytes: Uint8Array) => {
if (!isTypedArray(bytes)) {
throw new TypeError("'bytes' must be a typed array");
}
const name = createName();
const relativePath = getRelativePath(name);
return createWriterForExisting(root)({
data: bytes,
path: relativePath,
});
};
};
export const createWriterForExisting = (
root: string
): ((options: { data: Uint8Array; path: string }) => Promise<string>) => {
if (!isString(root)) {
throw new TypeError("'root' must be a path");
}
return async ({
data: bytes,
path: relativePath,
}: {
data: Uint8Array;
path: string;
}): Promise<string> => {
if (!isString(relativePath)) {
throw new TypeError("'relativePath' must be a path");
}
if (!isTypedArray(bytes)) {
throw new TypeError("'arrayBuffer' must be an array buffer");
}
const buffer = Buffer.from(bytes);
const absolutePath = join(root, relativePath);
const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) {
throw new Error('Invalid relative path');
}
await fse.ensureFile(normalized);
await fse.writeFile(normalized, buffer);
return relativePath;
};
};
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');
}
return normalized;
};
export const createDoesExist = (
root: string
): ((relativePath: string) => Promise<boolean>) => {
if (!isString(root)) {
throw new TypeError("'root' must be a path");
}
return async (relativePath: string): Promise<boolean> => {
if (!isString(relativePath)) {
throw new TypeError("'relativePath' must be a string");
}
const absolutePath = join(root, relativePath);
const normalized = normalize(absolutePath);
if (!isPathInside(normalized, root)) {
throw new Error('Invalid relative path');
}
try {
await fse.access(normalized, fse.constants.F_OK);
return true;
} catch (error) {
return false;
}
};
};
export const openFileInFolder = async (target: string): Promise<void> => {
ipcRenderer.send('show-item-in-folder', target);
};
const showSaveDialog = (
defaultPath: string
): Promise<{
canceled: boolean;
filePath?: string;
}> => {
return ipcRenderer.invoke('show-save-dialog', { defaultPath });
};
async function writeWithAttributes(
target: string,
data: Uint8Array
): Promise<void> {
await fse.writeFile(target, Buffer.from(data));
if (process.platform === 'darwin' && xattr) {
// kLSQuarantineTypeInstantMessageAttachment
const type = '0003';
// Hexadecimal seconds since epoch
const timestamp = Math.trunc(Date.now() / 1000).toString(16);
const appName = 'Signal';
const guid = getGuid();
// https://ilostmynotes.blogspot.com/2012/06/gatekeeper-xprotect-and-quarantine.html
const attrValue = `${type};${timestamp};${appName};${guid}`;
await xattr.set(target, 'com.apple.quarantine', attrValue);
} else if (isWindows()) {
// This operation may fail (see the function's comments), which is not a show-stopper.
try {
await writeWindowsZoneIdentifier(target);
} catch (err) {
window.SignalContext.log?.warn(
'Failed to write Windows Zone.Identifier file; continuing'
);
}
}
}
export const saveAttachmentToDisk = async ({
data,
name,
}: {
data: Uint8Array;
name: string;
}): Promise<null | { fullPath: string; name: string }> => {
const { canceled, filePath } = await showSaveDialog(name);
if (canceled || !filePath) {
return null;
}
await writeWithAttributes(filePath, data);
const fileBasename = basename(filePath);
return {
fullPath: filePath,
name: fileBasename,
};
};

View File

@ -53,6 +53,7 @@ export type SignalContextType = {
getEnvironment: () => string;
getNodeVersion: () => string;
getVersion: () => string;
getPath: (name: 'userData' | 'home' | 'downloads') => string;
i18n: LocalizerType;
log: LoggerType;
renderWindow?: () => void;
@ -71,6 +72,9 @@ export const SignalContext: SignalContextType = {
getEnvironment,
getNodeVersion: (): string => String(config.node_version),
getVersion: (): string => String(config.version),
getPath: (name: 'userData' | 'home' | 'downloads'): string => {
return String(config[`${name}Path`]);
},
i18n: setupI18n(locale, localeMessages),
log: window.SignalContext.log,
nativeThemeListener: createNativeThemeListener(ipcRenderer, window),