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

@ -0,0 +1,293 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
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;
const NAME_LENGTH = 64;
const PATH_LENGTH = PREFIX_LENGTH + NUM_SEPARATORS + NAME_LENGTH;
describe('Attachments', () => {
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(() => {
filesToRemove = [];
});
after(async () => {
await Promise.all(filesToRemove.map(toRemove => fse.remove(toRemove)));
filesToRemove = [];
});
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'
);
assert.strictEqual(size, 'hello world'.length);
});
});
describe('createWriterForExisting', () => {
it('should write file to disk on given path and return path', async () => {
const input = Bytes.fromString('test string');
const tempDirectory = path.join(
tempRootDirectory,
'Attachments_createWriterForExisting'
);
const relativePath = Attachments.getRelativePath(
Attachments.createName()
);
const attachment = {
path: relativePath,
data: input,
};
const outputPath = await Attachments.createWriterForExisting(
tempDirectory
)(attachment);
const output = await fse.readFile(path.join(tempDirectory, outputPath));
assert.equal(outputPath, relativePath);
const inputBuffer = Buffer.from(input);
assert.deepEqual(inputBuffer, output);
});
it('throws if relative path goes higher than root', async () => {
const input = Bytes.fromString('test string');
const tempDirectory = path.join(
tempRootDirectory,
'Attachments_createWriterForExisting'
);
const relativePath = '../../parent';
const attachment = {
path: relativePath,
data: input,
};
try {
await Attachments.createWriterForExisting(tempDirectory)(attachment);
} catch (error) {
assert.strictEqual(error.message, 'Invalid relative path');
return;
}
throw new Error('Expected an error');
});
});
describe('createWriterForNew', () => {
it('should write file to disk and return path', async () => {
const input = Bytes.fromString('test string');
const tempDirectory = path.join(
tempRootDirectory,
'Attachments_createWriterForNew'
);
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);
});
});
describe('createAbsolutePathGetter', () => {
const isWindows = process.platform === 'win32';
it('combines root and relative path', () => {
const root = isWindows ? 'C:\\temp' : '/tmp';
const relative = 'ab/abcdef';
const pathGetter = Attachments.createAbsolutePathGetter(root);
const absolutePath = pathGetter(relative);
assert.strictEqual(
absolutePath,
isWindows ? 'C:\\temp\\ab\\abcdef' : '/tmp/ab/abcdef'
);
});
it('throws if relative path goes higher than root', () => {
const root = isWindows ? 'C:\\temp' : 'tmp';
const relative = '../../ab/abcdef';
const pathGetter = Attachments.createAbsolutePathGetter(root);
try {
pathGetter(relative);
} 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);
});
});
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),