Initial commit 🎉
fbshipit-source-id: b6fc29740c6875d2e78953b8a7123890a67930f2 Co-authored-by: Sebastian McKenzie <sebmck@fb.com> Co-authored-by: John Knox <jknox@fb.com> Co-authored-by: Emil Sjölander <emilsj@fb.com> Co-authored-by: Pritesh Nandgaonkar <prit91@fb.com>
This commit is contained in:
387
src/utils/CertificateProvider.js
Normal file
387
src/utils/CertificateProvider.js
Normal file
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
import LogManager from '../fb-stubs/Logger';
|
||||
const fs = require('fs');
|
||||
const adb = require('adbkit-fb');
|
||||
import {openssl} from './openssl-wrapper-with-promises';
|
||||
const path = require('path');
|
||||
|
||||
// Desktop file paths
|
||||
const os = require('os');
|
||||
const caKey = getFilePath('ca.key');
|
||||
const caCert = getFilePath('ca.crt');
|
||||
const serverKey = getFilePath('server.key');
|
||||
const serverCsr = getFilePath('server.csr');
|
||||
const serverCert = getFilePath('server.crt');
|
||||
|
||||
// Device file paths
|
||||
const csrFileName = 'app.csr';
|
||||
const deviceCAcertFile = 'sonarCA.crt';
|
||||
const deviceClientCertFile = 'device.crt';
|
||||
|
||||
const caSubject = '/C=US/ST=CA/L=Menlo Park/O=Sonar/CN=SonarCA';
|
||||
const serverSubject = '/C=US/ST=CA/L=Menlo Park/O=Sonar/CN=localhost';
|
||||
const minCertExpiryWindowSeconds = 24 * 60 * 60;
|
||||
const appNotDebuggableRegex = /debuggable/;
|
||||
const allowedAppNameRegex = /^[a-zA-Z0-9.\-]+$/;
|
||||
const allowedAppDirectoryRegex = /^\/[ a-zA-Z0-9.\-\/]+$/;
|
||||
|
||||
export type SecureServerConfig = {|
|
||||
key: Buffer,
|
||||
cert: Buffer,
|
||||
ca: Buffer,
|
||||
requestCert: boolean,
|
||||
rejectUnauthorized: boolean,
|
||||
|};
|
||||
|
||||
/*
|
||||
* This class is responsible for generating and deploying server and client
|
||||
* certificates to allow for secure communication between sonar and apps.
|
||||
* It takes a Certificate Signing Request which was generated by the app,
|
||||
* using the app's public/private keypair.
|
||||
* With this CSR it uses the sonar CA to sign a client certificate which it
|
||||
* deploys securely to the app.
|
||||
* It also deploys the sonar CA cert to the app.
|
||||
* The app can trust a server if and only if it has a certificate signed by the
|
||||
* sonar CA.
|
||||
*/
|
||||
export default class CertificateProvider {
|
||||
logger: LogManager;
|
||||
adb: any;
|
||||
certificateSetup: Promise<void>;
|
||||
server: Server;
|
||||
|
||||
constructor(server: Server, logger: LogManager) {
|
||||
this.logger = logger;
|
||||
this.adb = adb.createClient();
|
||||
this.certificateSetup = this.ensureServerCertExists();
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
processCertificateSigningRequest(
|
||||
csr: string,
|
||||
os: string,
|
||||
appDirectory: string,
|
||||
): Promise<void> {
|
||||
if (!appDirectory.match(allowedAppDirectoryRegex)) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
`Invalid appDirectory recieved from ${os} device: ${appDirectory}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return this.certificateSetup
|
||||
.then(_ => this.getCACertificate())
|
||||
.then(caCert =>
|
||||
this.deployFileToMobileApp(
|
||||
appDirectory,
|
||||
deviceCAcertFile,
|
||||
caCert,
|
||||
csr,
|
||||
os,
|
||||
),
|
||||
)
|
||||
.then(_ => this.generateClientCertificate(csr))
|
||||
.then(clientCert =>
|
||||
this.deployFileToMobileApp(
|
||||
appDirectory,
|
||||
deviceClientCertFile,
|
||||
clientCert,
|
||||
csr,
|
||||
os,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
getCACertificate(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readFile(caCert, (err, data) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(data.toString());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
generateClientCertificate(csr: string): Promise<string> {
|
||||
this.logger.warn('Creating new client cert', 'CertificateProvider');
|
||||
const csrFile = this.writeToTempFile(csr);
|
||||
// Create a certificate for the client, using the details in the CSR.
|
||||
return openssl('x509', {
|
||||
req: true,
|
||||
in: csrFile,
|
||||
CA: caCert,
|
||||
CAkey: caKey,
|
||||
CAcreateserial: true,
|
||||
}).then(cert => {
|
||||
fs.unlink(csrFile);
|
||||
return cert;
|
||||
});
|
||||
}
|
||||
|
||||
deployFileToMobileApp(
|
||||
destination: string,
|
||||
filename: string,
|
||||
contents: string,
|
||||
csr: string,
|
||||
os: string,
|
||||
) {
|
||||
if (os === 'Android') {
|
||||
this.extractAppNameFromCSR(csr).then(app => {
|
||||
const client = adb.createClient();
|
||||
client.listDevices().then((devices: Array<{id: string}>) => {
|
||||
devices.forEach(d =>
|
||||
// To find out which device requested the cert, search them
|
||||
// all for a matching csr file.
|
||||
// It's not important to keep these secret from other apps.
|
||||
// Just need to make sure each app can find it's own one.
|
||||
this.androidDeviceHasMatchingCSR(destination, d.id, app, csr)
|
||||
.catch(e =>
|
||||
this.logger.error(
|
||||
`Unable to check for matching CSR in ${d.id}:${app}`,
|
||||
'CertificateProvider',
|
||||
),
|
||||
)
|
||||
.then(isMatch => {
|
||||
if (isMatch) {
|
||||
this.pushFileToAndroidDevice(
|
||||
d.id,
|
||||
app,
|
||||
destination + filename,
|
||||
contents,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (os === 'iOS') {
|
||||
fs.writeFileSync(destination + filename, contents);
|
||||
}
|
||||
}
|
||||
|
||||
androidDeviceHasMatchingCSR(
|
||||
directory: string,
|
||||
deviceId: string,
|
||||
processName: string,
|
||||
csr: string,
|
||||
): Promise<boolean> {
|
||||
return this.executeCommandOnAndroid(
|
||||
deviceId,
|
||||
processName,
|
||||
`cat ${directory + csrFileName}`,
|
||||
).then(deviceCsr => {
|
||||
return (
|
||||
deviceCsr
|
||||
.toString()
|
||||
.replace(/\r/g, '')
|
||||
.trim() === csr.replace(/\r/g, '').trim()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pushFileToAndroidDevice(
|
||||
deviceId: string,
|
||||
app: string,
|
||||
filename: string,
|
||||
contents: string,
|
||||
): Promise<void> {
|
||||
this.logger.warn(
|
||||
`Deploying sonar certificate to ${deviceId}:${app}`,
|
||||
'CertificateProvider',
|
||||
);
|
||||
return this.executeCommandOnAndroid(
|
||||
deviceId,
|
||||
app,
|
||||
`echo "${contents}" > ${filename} && chmod 600 ${filename}`,
|
||||
).then(output => undefined);
|
||||
}
|
||||
|
||||
executeCommandOnAndroid(
|
||||
deviceId: string,
|
||||
user: string,
|
||||
command: string,
|
||||
): Promise<string> {
|
||||
if (!user.match(allowedAppNameRegex)) {
|
||||
return Promise.reject(new Error(`Disallowed run-as user: ${user}`));
|
||||
}
|
||||
if (command.match(/[']/)) {
|
||||
return Promise.reject(
|
||||
new Error(`Disallowed escaping command: ${command}`),
|
||||
);
|
||||
}
|
||||
return this.adb
|
||||
.shell(deviceId, `echo '${command}' | run-as '${user}'`)
|
||||
.then(adb.util.readAll)
|
||||
.then(buffer => buffer.toString())
|
||||
.then(output => {
|
||||
const matches = output.match(appNotDebuggableRegex);
|
||||
if (matches) {
|
||||
const e = new Error(
|
||||
`Android app ${user} is not debuggable. To use it with sonar, add android:debuggable="true" to the application section of AndroidManifest.xml`,
|
||||
);
|
||||
this.server.emit('error', e);
|
||||
throw e;
|
||||
}
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
extractAppNameFromCSR(csr: string): Promise<string> {
|
||||
const csrFile = this.writeToTempFile(csr);
|
||||
return openssl('req', {in: csrFile, noout: true, subject: true})
|
||||
.then(subject => {
|
||||
fs.unlink(csrFile);
|
||||
return subject;
|
||||
})
|
||||
.then(subject => {
|
||||
return subject
|
||||
.split('/')
|
||||
.filter(part => {
|
||||
return part.startsWith('CN=');
|
||||
})
|
||||
.map(part => {
|
||||
return part.split('=')[1].trim();
|
||||
})[0];
|
||||
})
|
||||
.then(appName => {
|
||||
if (!appName.match(allowedAppNameRegex)) {
|
||||
throw new Error(
|
||||
`Disallowed app name in CSR: ${appName}. Only alphanumeric characters and '.' allowed.`,
|
||||
);
|
||||
}
|
||||
return appName;
|
||||
});
|
||||
}
|
||||
|
||||
loadSecureServerConfig(): Promise<SecureServerConfig> {
|
||||
return this.certificateSetup.then(() => {
|
||||
return {
|
||||
key: fs.readFileSync(serverKey),
|
||||
cert: fs.readFileSync(serverCert),
|
||||
ca: fs.readFileSync(caCert),
|
||||
requestCert: true,
|
||||
rejectUnauthorized: true, // can be false if necessary as we don't strictly need to verify the client
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
ensureCertificateAuthorityExists(): Promise<void> {
|
||||
if (!fs.existsSync(caKey)) {
|
||||
return this.generateCertificateAuthority();
|
||||
}
|
||||
return this.checkCertIsValid(caCert).catch(e =>
|
||||
this.generateCertificateAuthority(),
|
||||
);
|
||||
}
|
||||
|
||||
checkCertIsValid(filename: string): Promise<void> {
|
||||
if (!fs.existsSync(filename)) {
|
||||
return Promise.reject();
|
||||
}
|
||||
return openssl('x509', {
|
||||
checkend: minCertExpiryWindowSeconds,
|
||||
in: filename,
|
||||
})
|
||||
.then(output => undefined)
|
||||
.catch(e => {
|
||||
this.logger.warn(
|
||||
`Certificate will expire soon: ${filename}`,
|
||||
'CertificateProvider',
|
||||
);
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
verifyServerCertWasIssuedByCA() {
|
||||
const options = {CAfile: caCert};
|
||||
options[serverCert] = false;
|
||||
return openssl('verify', options).then(output => {
|
||||
const verified = output.match(/[^:]+: OK/);
|
||||
if (!verified) {
|
||||
// This should never happen, but if it does, we need to notice so we can
|
||||
// generate a valid one, or no clients will trust our server.
|
||||
throw new Error('Current server cert was not issued by current CA');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
generateCertificateAuthority(): Promise<void> {
|
||||
if (!fs.existsSync(getFilePath(''))) {
|
||||
fs.mkdirSync(getFilePath(''));
|
||||
}
|
||||
this.logger.info('Generating new CA', 'CertificateProvider');
|
||||
return openssl('genrsa', {out: caKey, '2048': false})
|
||||
.then(_ =>
|
||||
openssl('req', {
|
||||
new: true,
|
||||
x509: true,
|
||||
subj: caSubject,
|
||||
key: caKey,
|
||||
out: caCert,
|
||||
}),
|
||||
)
|
||||
.then(_ => undefined);
|
||||
}
|
||||
|
||||
ensureServerCertExists(): Promise<void> {
|
||||
if (
|
||||
!(
|
||||
fs.existsSync(serverKey) &&
|
||||
fs.existsSync(serverCert) &&
|
||||
fs.existsSync(caCert)
|
||||
)
|
||||
) {
|
||||
return this.generateServerCertificate();
|
||||
}
|
||||
|
||||
return this.checkCertIsValid(serverCert)
|
||||
.then(_ => this.verifyServerCertWasIssuedByCA())
|
||||
.catch(e => this.generateServerCertificate());
|
||||
}
|
||||
|
||||
generateServerCertificate(): Promise<void> {
|
||||
return this.ensureCertificateAuthorityExists()
|
||||
.then(_ => {
|
||||
this.logger.warn('Creating new server cert', 'CertificateProvider');
|
||||
})
|
||||
.then(_ => openssl('genrsa', {out: serverKey, '2048': false}))
|
||||
.then(_ =>
|
||||
openssl('req', {
|
||||
new: true,
|
||||
key: serverKey,
|
||||
out: serverCsr,
|
||||
subj: serverSubject,
|
||||
}),
|
||||
)
|
||||
.then(_ =>
|
||||
openssl('x509', {
|
||||
req: true,
|
||||
in: serverCsr,
|
||||
CA: caCert,
|
||||
CAkey: caKey,
|
||||
CAcreateserial: true,
|
||||
out: serverCert,
|
||||
}),
|
||||
)
|
||||
.then(_ => undefined);
|
||||
}
|
||||
|
||||
writeToTempFile(content: string): string {
|
||||
const fileName = getFilePath(`deviceCSR-${Math.random() * 1000000}`);
|
||||
fs.writeFileSync(fileName, content);
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
|
||||
function getFilePath(fileName: string): string {
|
||||
return path.resolve(os.homedir(), '.sonar', 'certs', fileName);
|
||||
}
|
||||
40
src/utils/InteractionTracker.js
Normal file
40
src/utils/InteractionTracker.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
import Logger from '../fb-stubs/Logger.js';
|
||||
|
||||
export function reportInteraction(
|
||||
componentType: string,
|
||||
componentIdentifier: string,
|
||||
) {
|
||||
const tracker = new InteractionTracker(componentType, componentIdentifier);
|
||||
return tracker.interaction.bind(tracker);
|
||||
}
|
||||
|
||||
class InteractionTracker {
|
||||
static logger = new Logger();
|
||||
static numberOfInteractions = 0;
|
||||
|
||||
type: string;
|
||||
id: string;
|
||||
interaction: (name: string, data: any) => void;
|
||||
|
||||
constructor(componentType: string, componentIdentifier: string) {
|
||||
this.type = componentType;
|
||||
this.id = componentIdentifier;
|
||||
}
|
||||
|
||||
interaction = (name: string, data: any) => {
|
||||
InteractionTracker.logger.track('usage', 'interaction', {
|
||||
interaction: InteractionTracker.numberOfInteractions++,
|
||||
type: this.type,
|
||||
id: this.id,
|
||||
name,
|
||||
data,
|
||||
});
|
||||
};
|
||||
}
|
||||
172
src/utils/LRUCache.js
Normal file
172
src/utils/LRUCache.js
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
class ListElement<K, V> {
|
||||
constructor(cache: LRUCache<K, V>, key: K, value: V) {
|
||||
this.cache = cache;
|
||||
this.before = undefined;
|
||||
this.next = undefined;
|
||||
this.set(key, value);
|
||||
}
|
||||
|
||||
creationTime: number;
|
||||
cache: LRUCache<K, V>;
|
||||
before: ?ListElement<K, V>;
|
||||
next: ?ListElement<K, V>;
|
||||
key: K;
|
||||
value: V;
|
||||
|
||||
set(key: K, value: V) {
|
||||
this.key = key;
|
||||
this.value = value;
|
||||
this.creationTime = Date.now();
|
||||
}
|
||||
|
||||
hit() {
|
||||
this.detach();
|
||||
this.attach();
|
||||
}
|
||||
|
||||
attach() {
|
||||
this.before = undefined;
|
||||
this.next = this.cache.head;
|
||||
this.cache.head = this;
|
||||
|
||||
const {next} = this;
|
||||
if (next) {
|
||||
next.before = this;
|
||||
} else {
|
||||
this.cache.tail = this;
|
||||
}
|
||||
|
||||
this.cache.size++;
|
||||
}
|
||||
|
||||
detach() {
|
||||
const {before, next} = this;
|
||||
|
||||
if (before) {
|
||||
before.next = next;
|
||||
} else {
|
||||
this.cache.head = next;
|
||||
}
|
||||
|
||||
if (next) {
|
||||
next.before = before;
|
||||
} else {
|
||||
this.cache.tail = before;
|
||||
}
|
||||
|
||||
this.cache.size--;
|
||||
}
|
||||
}
|
||||
|
||||
type LRUCacheOptions = {|
|
||||
maxSize: number,
|
||||
maxAge?: number,
|
||||
|};
|
||||
|
||||
export default class LRUCache<K, V> {
|
||||
constructor(options: LRUCacheOptions) {
|
||||
this.maxSize = options.maxSize;
|
||||
this.maxAge = options.maxAge;
|
||||
this.clear();
|
||||
}
|
||||
|
||||
maxSize: number;
|
||||
maxAge: ?number;
|
||||
size: number;
|
||||
data: Map<K, ListElement<K, V>>;
|
||||
tail: ?ListElement<K, V>;
|
||||
head: ?ListElement<K, V>;
|
||||
|
||||
clear() {
|
||||
this.size = 0;
|
||||
this.data = new Map();
|
||||
this.tail = undefined;
|
||||
this.head = undefined;
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
return this.data.has(key);
|
||||
}
|
||||
|
||||
get(key: K, hit?: boolean = true): ?V {
|
||||
const cacheVal = this.data.get(key);
|
||||
if (!cacheVal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {maxAge} = this;
|
||||
if (maxAge != null) {
|
||||
const timeNow = Date.now();
|
||||
const timeSinceCreation = timeNow - cacheVal.creationTime;
|
||||
if (timeSinceCreation > maxAge) {
|
||||
this.delete(key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (hit) {
|
||||
cacheVal.hit();
|
||||
}
|
||||
|
||||
return cacheVal.value;
|
||||
}
|
||||
|
||||
pop(): ?ListElement<K, V> {
|
||||
const {tail} = this;
|
||||
if (!tail) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.delete(tail.key);
|
||||
|
||||
tail.next = undefined;
|
||||
tail.before = undefined;
|
||||
|
||||
return tail;
|
||||
}
|
||||
|
||||
set(key: K, val: V, hit?: boolean = true) {
|
||||
const actual = this.data.get(key);
|
||||
|
||||
if (actual) {
|
||||
actual.value = val;
|
||||
if (hit) {
|
||||
actual.hit();
|
||||
}
|
||||
} else {
|
||||
let cacheVal: ?ListElement<K, V>;
|
||||
|
||||
if (this.size >= this.maxSize) {
|
||||
cacheVal = this.pop();
|
||||
|
||||
if (cacheVal) {
|
||||
cacheVal.set(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
if (!cacheVal) {
|
||||
cacheVal = new ListElement(this, key, val);
|
||||
}
|
||||
|
||||
this.data.set(key, cacheVal);
|
||||
cacheVal.attach();
|
||||
}
|
||||
}
|
||||
|
||||
delete(key: K) {
|
||||
const val = this.data.get(key);
|
||||
if (!val) {
|
||||
return;
|
||||
}
|
||||
|
||||
val.detach();
|
||||
this.data.delete(key);
|
||||
}
|
||||
}
|
||||
51
src/utils/LowPassFilter.js
Normal file
51
src/utils/LowPassFilter.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
export default class LowPassFilter {
|
||||
constructor(smoothing?: number = 0.9) {
|
||||
this.smoothing = smoothing;
|
||||
this.buffer = [];
|
||||
this.bufferMaxSize = 5;
|
||||
}
|
||||
|
||||
bufferMaxSize: number;
|
||||
smoothing: number;
|
||||
buffer: Array<number>;
|
||||
|
||||
hasFullBuffer(): boolean {
|
||||
return this.buffer.length === this.bufferMaxSize;
|
||||
}
|
||||
|
||||
push(value: number): number {
|
||||
let removed: number = 0;
|
||||
|
||||
if (this.hasFullBuffer()) {
|
||||
removed = this.buffer.shift();
|
||||
}
|
||||
|
||||
this.buffer.push(value);
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
next(nextValue: number): number {
|
||||
// push new value to the end, and remove oldest one
|
||||
const removed = this.push(nextValue);
|
||||
|
||||
// smooth value using all values from buffer
|
||||
const result = this.buffer.reduce(this._nextReduce, removed);
|
||||
|
||||
// replace smoothed value
|
||||
this.buffer[this.buffer.length - 1] = result;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
_nextReduce = (last: number, current: number): number => {
|
||||
return this.smoothing * current + (1 - this.smoothing) * last;
|
||||
};
|
||||
}
|
||||
38
src/utils/assignDeep.js
Normal file
38
src/utils/assignDeep.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
function isObject(val: mixed): boolean {
|
||||
return (
|
||||
Boolean(val) &&
|
||||
typeof val === 'object' &&
|
||||
Object.prototype.toString.call(val) === '[object Object]'
|
||||
);
|
||||
}
|
||||
|
||||
export default function assignDeep<T: Object>(
|
||||
base: T,
|
||||
...reduces: Array<Object>
|
||||
): T {
|
||||
base = Object.assign({}, base);
|
||||
|
||||
for (const reduce of reduces) {
|
||||
for (const key in reduce) {
|
||||
const baseVal = base[key];
|
||||
const val = reduce[key];
|
||||
|
||||
if (isObject(val) && isObject(baseVal)) {
|
||||
base[key] = assignDeep(baseVal, val);
|
||||
} else if (typeof val === 'undefined') {
|
||||
delete base[key];
|
||||
} else {
|
||||
base[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
70
src/utils/createPaste.js
Normal file
70
src/utils/createPaste.js
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
import child_process from 'child_process';
|
||||
import {clipboard, shell} from 'electron';
|
||||
|
||||
type PasteResponse =
|
||||
| {
|
||||
id: number,
|
||||
objectName: string,
|
||||
phid: string,
|
||||
authorPHID: string,
|
||||
filePHID: string,
|
||||
title: string,
|
||||
dateCreated: number,
|
||||
language: string,
|
||||
uri: string,
|
||||
parentPHID: ?number,
|
||||
content: string,
|
||||
}
|
||||
| {
|
||||
type: 'error',
|
||||
message: string,
|
||||
};
|
||||
|
||||
export default function createPaste(input: string): Promise<?string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const arc = '/opt/facebook/bin/arc';
|
||||
const child = child_process.spawn(arc, [
|
||||
'--conduit-uri=https://phabricator.intern.facebook.com/api/',
|
||||
'paste',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
child.stdin.write(input);
|
||||
child.stdin.end();
|
||||
let response = '';
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
response += data.toString();
|
||||
});
|
||||
child.stdout.on('end', (data: Buffer) => {
|
||||
const result: PasteResponse = JSON.parse(response || 'null');
|
||||
|
||||
if (!result) {
|
||||
new window.Notification('Failed to create paste', {
|
||||
body: `Does ${arc} exist and is executable?`,
|
||||
});
|
||||
} else if (result.type === 'error') {
|
||||
new window.Notification('Failed to create paste', {
|
||||
body: result.message != null ? result.message : '',
|
||||
});
|
||||
reject(result);
|
||||
} else {
|
||||
clipboard.writeText(result.uri);
|
||||
const notification = new window.Notification(
|
||||
`Paste ${result.objectName} created`,
|
||||
{
|
||||
body: 'URL copied to clipboard',
|
||||
},
|
||||
);
|
||||
notification.onclick = () => shell.openExternal(result.uri);
|
||||
resolve(result.uri);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
47
src/utils/dynamicPluginLoading.js
Normal file
47
src/utils/dynamicPluginLoading.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
import electron from 'electron';
|
||||
|
||||
const DP_ARG = '--dynamicPlugins=';
|
||||
/* Sometimes remote objects are missing intermittently. To reduce the chance of
|
||||
* this being a problem while in use. Only read it once at startup.
|
||||
* https://github.com/electron/electron/issues/8205 */
|
||||
const _argv = electron.remote.process.argv;
|
||||
const _loadsDynamicPlugins =
|
||||
_argv.findIndex(arg => arg.startsWith(DP_ARG)) > -1;
|
||||
const _isProduction = !/node_modules[\\/]electron[\\/]/.test(
|
||||
electron.remote.process.execPath,
|
||||
);
|
||||
|
||||
export function isProduction(): boolean {
|
||||
return _isProduction;
|
||||
}
|
||||
|
||||
export function loadsDynamicPlugins(): boolean {
|
||||
return _loadsDynamicPlugins;
|
||||
}
|
||||
|
||||
export function toggleDynamicPluginLoading() {
|
||||
const args = _argv.filter(arg => !arg.startsWith(DP_ARG));
|
||||
if (!loadsDynamicPlugins()) {
|
||||
args.push(DP_ARG + '~/fbsource/xplat/sonar/src/plugins');
|
||||
}
|
||||
const {app} = electron.remote;
|
||||
app.relaunch({args: args.slice(1).concat(['--relaunch'])});
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
export function dynamicPluginPath(): ?string {
|
||||
const index = _argv.findIndex(arg => arg.startsWith(DP_ARG));
|
||||
|
||||
if (index > -1) {
|
||||
return _argv[index].replace(DP_ARG, '');
|
||||
} else {
|
||||
null;
|
||||
}
|
||||
}
|
||||
43
src/utils/geometry.js
Normal file
43
src/utils/geometry.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
export type Rect = {
|
||||
top: number,
|
||||
left: number,
|
||||
height: number,
|
||||
width: number,
|
||||
};
|
||||
|
||||
export function isOverlappedRect(a: Rect, b: Rect): boolean {
|
||||
const aRight = a.left + a.width;
|
||||
const bRight = b.left + b.width;
|
||||
const aBottom = a.top + a.height;
|
||||
const bBottom = b.top + b.height;
|
||||
return (
|
||||
a.left < bRight && b.left < aRight && a.top < bBottom && b.top < aBottom
|
||||
);
|
||||
}
|
||||
|
||||
export function getDistanceRect(a: Rect, b: Rect): number {
|
||||
const mostLeft = a.left < b.left ? a : b;
|
||||
const mostRight = b.left < a.left ? a : b;
|
||||
|
||||
let xDifference =
|
||||
mostLeft.left === mostRight.left
|
||||
? 0
|
||||
: mostRight.left - (mostLeft.left + mostLeft.width);
|
||||
xDifference = Math.max(0, xDifference);
|
||||
|
||||
const upper = a.top < b.top ? a : b;
|
||||
const lower = b.top < a.top ? a : b;
|
||||
|
||||
let yDifference =
|
||||
upper.top === lower.top ? 0 : lower.top - (upper.top + upper.height);
|
||||
yDifference = Math.max(0, yDifference);
|
||||
|
||||
return Math.min(xDifference, yDifference);
|
||||
}
|
||||
117
src/utils/icons.js
Normal file
117
src/utils/icons.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
// list of icons that are prefetched in the service worker when launching the app
|
||||
export const precachedIcons = [
|
||||
{
|
||||
name: 'arrow-right',
|
||||
size: 12,
|
||||
},
|
||||
{
|
||||
name: 'caution-octagon',
|
||||
},
|
||||
{
|
||||
name: 'caution-triangle',
|
||||
},
|
||||
{
|
||||
name: 'info-circle',
|
||||
},
|
||||
{
|
||||
name: 'magic-wand',
|
||||
size: 20,
|
||||
},
|
||||
{
|
||||
name: 'magnifying-glass',
|
||||
},
|
||||
{
|
||||
name: 'minus-circle',
|
||||
size: 12,
|
||||
},
|
||||
{
|
||||
name: 'mobile',
|
||||
size: 12,
|
||||
},
|
||||
{
|
||||
name: 'bug',
|
||||
size: 12,
|
||||
},
|
||||
{
|
||||
name: 'posts',
|
||||
size: 20,
|
||||
},
|
||||
{
|
||||
name: 'rocket',
|
||||
size: 20,
|
||||
},
|
||||
{
|
||||
name: 'tools',
|
||||
size: 20,
|
||||
},
|
||||
{
|
||||
name: 'triangle-down',
|
||||
size: 12,
|
||||
},
|
||||
{
|
||||
name: 'triangle-right',
|
||||
size: 12,
|
||||
},
|
||||
{
|
||||
name: 'chevron-right',
|
||||
size: 8,
|
||||
},
|
||||
{
|
||||
name: 'chevron-down',
|
||||
size: 8,
|
||||
},
|
||||
].map(icon => getIconUrl(icon.name, icon.size || undefined));
|
||||
|
||||
export function getIconUrl(
|
||||
name: string,
|
||||
size?: number = 16,
|
||||
variant?: 'filled' | 'outline' = 'filled',
|
||||
): string {
|
||||
if (name.indexOf('/') > -1) {
|
||||
return name;
|
||||
}
|
||||
|
||||
const AVAILABLE_SIZES = [8, 10, 12, 16, 18, 20, 24, 32];
|
||||
const SCALE = [1, 1.5, 2, 3, 4];
|
||||
|
||||
let requestedSize: number = size;
|
||||
if (!AVAILABLE_SIZES.includes(size)) {
|
||||
// find the next largest size
|
||||
const possibleSize: ?number = AVAILABLE_SIZES.find(size => {
|
||||
return size > requestedSize;
|
||||
});
|
||||
|
||||
// set to largest size if the real size is larger than what we have
|
||||
if (possibleSize == null) {
|
||||
requestedSize = Math.max(...AVAILABLE_SIZES);
|
||||
} else {
|
||||
requestedSize = possibleSize;
|
||||
}
|
||||
}
|
||||
|
||||
let requestedScale: number = window.devicePixelRatio;
|
||||
if (!SCALE.includes(requestedScale)) {
|
||||
// find the next largest size
|
||||
const possibleScale: ?number = SCALE.find(scale => {
|
||||
return scale > requestedScale;
|
||||
});
|
||||
|
||||
// set to largest size if the real size is larger than what we have
|
||||
if (possibleScale == null) {
|
||||
requestedScale = Math.max(...SCALE);
|
||||
} else {
|
||||
requestedScale = possibleScale;
|
||||
}
|
||||
}
|
||||
|
||||
return `https://external.xx.fbcdn.net/assets/?name=${name}&variant=filled&size=${requestedSize}&set=facebook_icons&density=${requestedScale}x${
|
||||
variant == 'outline' ? '&variant=outline' : ''
|
||||
}`;
|
||||
}
|
||||
9
src/utils/index.js
Normal file
9
src/utils/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
export {default as textContent} from './textContent.js';
|
||||
export {default as createPaste} from './createPaste.js';
|
||||
49
src/utils/info.js
Normal file
49
src/utils/info.js
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
const os = require('os');
|
||||
|
||||
export type Info = {
|
||||
arch: string,
|
||||
platform: string,
|
||||
unixname: string,
|
||||
versions: {
|
||||
[key: string]: ?string,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* This method builds up some metadata about the users environment that we send
|
||||
* on bug reports, analytic events, errors etc.
|
||||
*/
|
||||
export function getInfo(): Info {
|
||||
return {
|
||||
arch: process.arch,
|
||||
platform: process.platform,
|
||||
unixname: os.userInfo().username,
|
||||
versions: {
|
||||
electron: process.versions['atom-shell'],
|
||||
node: process.versions.node,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function stringifyInfo(): string {
|
||||
const info = getInfo();
|
||||
|
||||
const lines = [
|
||||
`Platform: ${info.platform} ${info.arch}`,
|
||||
`Unixname: ${info.unixname}`,
|
||||
`Versions:`,
|
||||
];
|
||||
|
||||
for (const key in info.versions) {
|
||||
lines.push(` ${key}: ${String(info.versions[key])}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
18
src/utils/openssl-wrapper-with-promises.js
Normal file
18
src/utils/openssl-wrapper-with-promises.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright 2004-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
import {exec as opensslWithCallback} from 'openssl-wrapper';
|
||||
|
||||
export function openssl(action: string, options: {}): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
opensslWithCallback(action, options, (err, buffer) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
resolve(buffer.toString());
|
||||
});
|
||||
});
|
||||
}
|
||||
24
src/utils/performance.js
Normal file
24
src/utils/performance.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
let debugId = 0;
|
||||
|
||||
export function mark(): string {
|
||||
const id = String(debugId++);
|
||||
if (typeof performance === 'object') {
|
||||
performance.mark(id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function measure(id: string, name: string) {
|
||||
if (typeof performance === 'object') {
|
||||
performance.measure(name, id);
|
||||
performance.clearMeasures(id);
|
||||
performance.clearMarks(id);
|
||||
}
|
||||
}
|
||||
159
src/utils/snap.js
Normal file
159
src/utils/snap.js
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
import type {Rect} from './geometry.js';
|
||||
|
||||
export const SNAP_SIZE = 16;
|
||||
|
||||
export function snapGrid(val: number): number {
|
||||
return val - val % SNAP_SIZE;
|
||||
}
|
||||
|
||||
export function getPossibleSnappedPosition(
|
||||
windows: Array<Rect>,
|
||||
{
|
||||
getGap,
|
||||
getNew,
|
||||
}: {
|
||||
getNew: (win: Rect) => number,
|
||||
getGap: (win: Rect) => number,
|
||||
},
|
||||
): ?number {
|
||||
for (const win of windows) {
|
||||
const gap = Math.abs(getGap(win));
|
||||
if (gap >= 0 && gap < SNAP_SIZE) {
|
||||
return getNew(win);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getDistanceTo(props: Rect, win: Rect): number {
|
||||
const x1 = win.left;
|
||||
const y1 = win.top;
|
||||
const x1b = win.left + win.width;
|
||||
const y1b = win.top + win.height;
|
||||
|
||||
const x2 = props.left;
|
||||
const y2 = props.top;
|
||||
const x2b = props.left + props.width;
|
||||
const y2b = props.top + props.height;
|
||||
|
||||
const left = x2b < x1;
|
||||
const right = x1b < x2;
|
||||
const bottom = y2b < y1;
|
||||
const top = y1b < y2;
|
||||
|
||||
if (top && left) {
|
||||
return distance(x1, y1b, x2b, y2);
|
||||
} else if (left && bottom) {
|
||||
return distance(x1, y1, x2b, y2b);
|
||||
} else if (bottom && right) {
|
||||
return distance(x1b, y1, x2, y2b);
|
||||
} else if (right && top) {
|
||||
return distance(x1b, y1b, x2, y2);
|
||||
} else if (left) {
|
||||
return x1 - x2b;
|
||||
} else if (right) {
|
||||
return x2 - x1b;
|
||||
} else if (bottom) {
|
||||
return y1 - y2b;
|
||||
} else if (top) {
|
||||
return y2 - y1b;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function distance(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
): number {
|
||||
return Math.abs(x1 - x2) + Math.abs(y1 - y2);
|
||||
}
|
||||
|
||||
export function maybeSnapLeft(
|
||||
props: Rect,
|
||||
windows: Array<Rect>,
|
||||
left: number,
|
||||
): number {
|
||||
// snap right side to left
|
||||
// ┌─┬─┐
|
||||
// │A│B│
|
||||
// └─┴─┘
|
||||
const snapRight = getPossibleSnappedPosition(windows, {
|
||||
debug: true,
|
||||
getGap: win => win.left - (props.width + left),
|
||||
getNew: win => win.left - props.width,
|
||||
});
|
||||
if (snapRight != null) {
|
||||
return snapRight;
|
||||
}
|
||||
|
||||
// snap left side to right
|
||||
// ┌─┬─┐
|
||||
// │B│A│
|
||||
// └─┴─┘
|
||||
const snapLeft = getPossibleSnappedPosition(windows, {
|
||||
getGap: win => left - (win.left + win.width),
|
||||
getNew: win => win.left + win.width,
|
||||
});
|
||||
if (snapLeft != null) {
|
||||
return snapLeft;
|
||||
}
|
||||
|
||||
return snapGrid(left);
|
||||
}
|
||||
|
||||
export function maybeSnapTop(
|
||||
props: Rect,
|
||||
windows: Array<Rect>,
|
||||
top: number,
|
||||
): number {
|
||||
// snap bottom to bottom
|
||||
// ┌─┐
|
||||
// │A├─┐
|
||||
// │ │B│
|
||||
// └─┴─┘
|
||||
const snapBottom2 = getPossibleSnappedPosition(windows, {
|
||||
getGap: win => top - win.top - win.height,
|
||||
getNew: win => win.top + win.height,
|
||||
});
|
||||
if (snapBottom2 != null) {
|
||||
return snapBottom2;
|
||||
}
|
||||
|
||||
// snap top to bottom
|
||||
// ┌─┐
|
||||
// │B│
|
||||
// ├─┤
|
||||
// │A│
|
||||
// └─┘
|
||||
const snapBottom = getPossibleSnappedPosition(windows, {
|
||||
getGap: win => top - win.top - win.height,
|
||||
getNew: win => win.top + win.height,
|
||||
});
|
||||
if (snapBottom != null) {
|
||||
return snapBottom;
|
||||
}
|
||||
|
||||
// snap top to top
|
||||
// ┌─┬─┐
|
||||
// │A│B│
|
||||
// │ ├─┘
|
||||
// └─┘
|
||||
const snapTop = getPossibleSnappedPosition(windows, {
|
||||
getGap: win => top - win.top,
|
||||
getNew: win => win.top,
|
||||
});
|
||||
if (snapTop != null) {
|
||||
return snapTop;
|
||||
}
|
||||
|
||||
return snapGrid(top);
|
||||
}
|
||||
46
src/utils/textContent.js
Normal file
46
src/utils/textContent.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
import type {Node} from 'react';
|
||||
|
||||
function isReactElement(object: any) {
|
||||
return (
|
||||
typeof object === 'object' &&
|
||||
object !== null &&
|
||||
object.$$typeof === Symbol.for('react.element')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walks through all children of a React element and returns
|
||||
* the string representation of the leafs concatenated.
|
||||
*/
|
||||
export default (node: Node): string => {
|
||||
let res = '';
|
||||
const traverse = (node: Node) => {
|
||||
if (typeof node === 'string' || typeof node === 'number') {
|
||||
// this is a leaf, add it to the result string
|
||||
res += node;
|
||||
} else if (Array.isArray(node)) {
|
||||
// traverse all array members and recursively stringify them
|
||||
node.forEach(traverse);
|
||||
} else if (isReactElement(node)) {
|
||||
// node is a react element access its children an recursively stringify them
|
||||
// $FlowFixMe
|
||||
const {children} = node.props;
|
||||
if (Array.isArray(children)) {
|
||||
children.forEach(traverse);
|
||||
} else {
|
||||
traverse(children);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
traverse(node);
|
||||
|
||||
return res;
|
||||
};
|
||||
Reference in New Issue
Block a user