Merge pull request #1054 from loki-project/clearnet

Clearnet --> Master for v1.0.6 #2
pull/1390/head v1.0.6
Mikunj Varsani 5 years ago committed by GitHub
commit 85e5a067c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -14,7 +14,7 @@ Remember, you can preview this before saving it.
### Contributor checklist:
* [ ] My commits are in nice logical chunks with [good commit messages](http://chris.beams.io/posts/git-commit/)
* [ ] My changes are [rebased](https://medium.freecodecamp.org/git-rebase-and-the-golden-rule-explained-70715eccc372) on the latest [`clearnet`](https://github.com/loki-project/loki-messenger/tree/development) branch
* [ ] My changes are [rebased](https://blog.axosoft.com/golden-rule-of-rebasing-in-git/) on the latest [`clearnet`](https://github.com/loki-project/loki-messenger/tree/clearnet) branch
* [ ] A `yarn ready` run passes successfully ([more about tests here](https://github.com/loki-project/loki-messenger/blob/master/CONTRIBUTING.md#tests))
* [ ] My changes are ready to be shipped to users

@ -46,6 +46,7 @@ jobs:
run: yarn generate
- name: Lint Files
if: runner.os != 'Windows'
run: yarn lint-full
- name: Build windows production binaries

@ -47,8 +47,9 @@ jobs:
run: yarn generate
- name: Lint Files
if: runner.os != 'Windows'
run: |
yarn format-full --list-different
yarn format-full
yarn eslint
yarn tslint

@ -43,6 +43,7 @@ jobs:
run: yarn generate
- name: Lint Files
if: runner.os != 'Windows'
run: yarn lint-full
- name: Build windows production binaries

@ -1,15 +1,17 @@
# Building
Building session binaries is done using github actions. Windows and linux binaries will build right out of the box but there are some extra steps needed for Mac OS
## Automated
## Mac OS
Automatic building of session binaries is done using github actions. Windows and linux binaries will build right out of the box but there are some extra steps needed for Mac OS
### Mac OS
The build script for Mac OS requires you to have a valid `Developer ID Application` certificate. Without this the build script cannot sign and notarize the mac binary which is needed for Catalina 10.15 and above.
If you would like to disable this then comment out `"afterSign": "build/notarize.js",` in package.json.
You will also need an [App-specific password](https://support.apple.com/en-al/HT204397) for the apple account you wish to notarize with
### Setup
#### Setup
Once you have your `Developer ID Application` you need to export it into a `.p12` file. Keep a note of the password used to encrypt this file as it will be needed later.
@ -40,3 +42,56 @@ base64 -i certificate.p12 -o encoded.txt
5. Team ID (Optional)
* Name: `SIGNING_TEAM_ID`
* Value: The apple team id if you're sigining the application for a team
## Manual
### Node version
You will need node `10.13.0`.
This can be done by using [nvm](https://github.com/nvm-sh/nvm) and running `nvm use` or you can install it manually.
### Prerequisites
<details>
<summary>Windows</summary>
Building on windows should work straight out of the box, but if it fails then you will need to run the following:
```
npm install --global --production windows-build-tools@4.0.0
npm install --global node-gyp@latest
npm config set python python2.7
npm config set msvs_version 2015
```
</details>
<details>
<summary>Mac</summary>
If you are going to distribute the binary then make sure you have a `Developer ID Application` certificate in your keychain.
You will then need to generate an [app specific password](https://support.apple.com/HT204397) for your Apple ID.
Then run the following to export the variables
```
export SIGNING_APPLE_ID=<your apple id>
export SIGNING_APP_PASSWORD=<your app specific password>
export SIGNING_TEAM_ID=<your team id if applicable>
```
</details>
### Commands
Run the following to build the binaries for your specific system OS.
```
npm install yarn --no-save
yarn install --frozen-lockfile
yarn generate
yarn build-release
```
The binaries will be placed inside the `release/` folder.

@ -82,40 +82,46 @@ while you make changes:
yarn grunt dev # runs until you stop it, re-generating built assets on file changes
```
## Additional storage profiles
## Multiple instances
Since there is no registration for Session, you can create as many accounts as you
can public keys. To test the P2P functionality on the same machine, however, requries
that each client binds their message server to a different port.
can public keys. Each client however has a dedicated storage profile which is determined by the environment and instance variables.
You can use the following command to start a client bound to a different port.
This profile will change [userData](https://electron.atom.io/docs/all/#appgetpathname)
directory from `%appData%/Session` to `%appData%/Session-{environment}-{instance}`.
There are a few scripts which you can use:
```
yarn start-multi
yarn start - Start development
yarn start-multi - Start second instance of development
yarn start-prod - Start production but in development mode
yarn start-prod-multi - Start another instance of production
```
For more than 2 clients, you can setup additional storage profiles and switch
between them using the `NODE_APP_INSTANCE` environment variable and specifying a
new localServerPort in the config.
For example, to create an 'alice' profile, put a file called `local-alice.json` in the
`config` directory:
For more than 2 clients, you may run the above command with `NODE_APP_INSTANCE` set before them.
For example, running:
```
{
"storageProfile": "aliceProfile",
"localServerPort": "8082",
}
NODE_APP_INSTANCE=alice yarn start
```
Then you can start up the application a little differently to load the profile:
Will run the development environment with the `alice` instance and thus create a seperate storage profile.
If a fixed profile is needed (in the case of tests), you can specify it using `storageProfile` in the config file. If the change is local then put it in `local-{instance}.json` otherwise put it in `default-{instance}.json` or `{env}-{instance}.json`.
Local config files will be ignored by default in git.
For example, to create an 'alice' profile locally, put a file called `local-alice.json` in the
`config` directory:
```
NODE_APP_INSTANCE=alice yarn run start
{
"storageProfile": "alice-profile",
}
```
This changes the [userData](https://electron.atom.io/docs/all/#appgetpathname)
directory from `%appData%/Session` to `%appData%/Session-aliceProfile`.
This will then set the `userData` directory to `%appData%/Session-alice-profile` when running the `alice` instance.
# Making changes
@ -187,26 +193,11 @@ Above all, spend some time with the repository. Follow the pull request template
your pull request description automatically. Take a look at recent approved pull requests,
see how they did things.
## Testing Production Builds
## Production Builds
To test changes to the build system, build a release using
You can build a production binary by running the following:
```
yarn generate
yarn build-release
```
Then, run the tests using `grunt test-release:osx --dir=release`, replacing `osx` with `linux` or `win` depending on your platform.
<!-- TODO:
## Translations
To pull the latest translations, follow these steps:
1. Download Transifex client:
https://docs.transifex.com/client/installing-the-client
2. Create Transifex account: https://transifex.com
3. Generate API token: https://www.transifex.com/user/settings/api/
4. Create `~/.transifexrc` configuration:
https://docs.transifex.com/client/client-configuration#-transifexrc
5. Run `yarn grunt tx`. -->

@ -313,6 +313,11 @@ module.exports = grunt => {
NODE_ENV: environment,
},
requireName: 'unused',
chromeDriverArgs: [
`remote-debugging-port=${Math.floor(
Math.random() * (9999 - 9000) + 9000
)}`,
],
});
function getMochaResults() {
@ -368,11 +373,18 @@ module.exports = grunt => {
logs.forEach(log => {
console.log(log);
});
return app.stop();
try {
return app.stop();
} catch (err) {
return Promise.resolve();
}
});
}
return app.stop();
try {
return app.stop();
} catch (err) {
return Promise.resolve();
}
})
.then(() => {
if (failure) {
@ -465,6 +477,11 @@ module.exports = grunt => {
const app = new Application({
path: [dir, config.exe].join('/'),
requireName: 'unused',
chromeDriverArgs: [
`remote-debugging-port=${Math.floor(
Math.random() * (9999 - 9000) + 9000
)}`,
],
});
app

@ -166,6 +166,11 @@
"description":
"Only available on development modes, menu option to open up the standalone device setup sequence"
},
"contextMenuNoSuggestions": {
"message": "No Suggestions",
"description":
"Shown in the context menu for a misspelled word to indicate that there are no suggestions to replace the misspelled word"
},
"connectingLoad": {
"message": "Connecting To Server",
"description":
@ -982,10 +987,17 @@
"message": "Pair New Device"
},
"devicePairingAccepted": {
"message": "Device Pairing Accepted"
"message": "Device Linking Accepted"
},
"devicePairingReceived": {
"message": "Device Pairing Received"
"message": "Device Linking Received"
},
"devicePairingRequestReceivedNoListenerTitle": {
"message": "Device linking request received."
},
"devicePairingRequestReceivedNoListenerDescription": {
"message":
"Device linking request received but you are not on the device linking screen. \nFirst go to Settings -> Device -> Link New Device."
},
"waitingForDeviceToRegister": {
"message": "Waiting for device to register..."
@ -994,34 +1006,37 @@
"message": "Scan the QR Code on your secondary device"
},
"pairedDevices": {
"message": "Paired Devices"
"message": "Linked Devices"
},
"noPairedDevices": {
"message": "No paired devices"
"message": "No linked devices"
},
"deviceIsSecondaryNoPairing": {
"message": "This device is a secondary device and so cannot be linked."
},
"allowPairing": {
"message": "Allow Pairing"
"message": "Allow Linking"
},
"allowPairingWithDevice": {
"message": "Allow pairing with this device?"
"message": "Allow linking with this device?"
},
"provideDeviceAlias": {
"message": "Please provide an alias for this paired device"
"message": "Please provide an alias for this linked device"
},
"showPairingWordsTitle": {
"message": "Pairing Secret Words"
"message": "Linking Secret Words"
},
"secretPrompt": {
"message": "Here is your secret"
},
"confirmUnpairingTitle": {
"message": "Please confirm you want to unpair the following device:"
"message": "Please confirm you want to unlink the following device:"
},
"unpairDevice": {
"message": "Unpair Device"
"message": "Unlink Device"
},
"deviceUnpaired": {
"message": "Device Unpaired"
"message": "Device Unlinked"
},
"clear": {
"message": "Clear"
@ -1404,6 +1419,11 @@
"message": "Enable spell check of text entered in message composition box",
"description": "Description of the media permission description"
},
"spellCheckDirty": {
"message": "You must restart Session to apply your new settings",
"description":
"Shown when the user changes their spellcheck setting to indicate that they must restart Signal."
},
"clearDataHeader": {
"message": "Clear All Local Data",
"description":
@ -2289,6 +2309,12 @@
"confirmPassword": {
"message": "Confirm password"
},
"pasteLongPasswordToastTitle": {
"message":
"The clipboard content exceeds the maximum password length of $max_pwd_len$ characters.",
"description":
"Shown when user pastes a password which is longer than MAX_PASSWORD_LEN"
},
"showSeedPasswordRequest": {
"message": "Please enter your password",
"description": "Request for user to enter password to show seed."
@ -2502,7 +2528,7 @@
"description": "Indicates that a friend request is pending"
},
"notFriends": {
"message": "not friends",
"message": "Not Friends",
"description": "Indicates that a conversation is not friends with us"
},
"emptyGroupNameError": {
@ -2814,7 +2840,7 @@
"message": "Filter received requests"
},
"secretWords": {
"message": "Secret words:"
"message": "Secret words"
},
"pairingDevice": {
"message": "Pairing Device"

@ -1,12 +1,13 @@
const path = require('path');
const electronIsDev = require('electron-is-dev');
const isDevelopment = require('electron-is-dev');
let environment;
// In production mode, NODE_ENV cannot be customized by the user
if (electronIsDev) {
if (isDevelopment) {
environment = process.env.NODE_ENV || 'development';
process.env.LOKI_DEV = 1;
} else {
environment = 'production';
}
@ -18,12 +19,14 @@ process.env.NODE_CONFIG_DIR = path.join(__dirname, '..', 'config');
if (environment === 'production') {
// harden production config against the local env
process.env.NODE_CONFIG = '';
process.env.NODE_CONFIG_STRICT_MODE = true;
process.env.NODE_CONFIG_STRICT_MODE = !isDevelopment;
process.env.HOSTNAME = '';
process.env.ALLOW_CONFIG_MUTATIONS = '';
process.env.SUPPRESS_NO_CONFIG_WARNING = '';
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '';
if (!process.env.LOKI_DEV) {
// We could be running againt production but still be in dev mode, we need to handle that
if (!isDevelopment) {
process.env.NODE_APP_INSTANCE = '';
}
}
@ -34,16 +37,10 @@ const config = require('config');
config.environment = environment;
// Log resulting env vars in use by config
[
'NODE_ENV',
'NODE_CONFIG_DIR',
'NODE_CONFIG',
'ALLOW_CONFIG_MUTATIONS',
'HOSTNAME',
'NODE_APP_INSTANCE',
'SUPPRESS_NO_CONFIG_WARNING',
].forEach(s => {
console.log(`${s} ${config.util.getEnv(s)}`);
});
['NODE_ENV', 'NODE_APP_INSTANCE', 'NODE_CONFIG_DIR', 'NODE_CONFIG'].forEach(
s => {
console.log(`${s} ${config.util.getEnv(s)}`);
}
);
module.exports = config;

@ -0,0 +1,88 @@
/* global exports, require */
/* eslint-disable strict */
const { Menu } = require('electron');
const osLocale = require('os-locale');
exports.setup = (browserWindow, messages) => {
const { session } = browserWindow.webContents;
const userLocale = osLocale.sync().replace(/_/g, '-');
const userLocales = [userLocale, userLocale.split('-')[0]];
const available = session.availableSpellCheckerLanguages;
const languages = userLocales.filter(l => available.includes(l));
console.log(`spellcheck: user locale: ${userLocale}`);
console.log('spellcheck: available spellchecker languages: ', available);
console.log('spellcheck: setting languages to: ', languages);
session.setSpellCheckerLanguages(languages);
browserWindow.webContents.on('context-menu', (_event, params) => {
const { editFlags } = params;
const isMisspelled = Boolean(params.misspelledWord);
const showMenu = params.isEditable || editFlags.canCopy;
// Popup editor menu
if (showMenu) {
const template = [];
if (isMisspelled) {
if (params.dictionarySuggestions.length > 0) {
template.push(
...params.dictionarySuggestions.map(label => ({
label,
click: () => {
browserWindow.webContents.replaceMisspelling(label);
},
}))
);
} else {
template.push({
label: messages.contextMenuNoSuggestions.message,
enabled: false,
});
}
template.push({ type: 'separator' });
}
if (params.isEditable) {
if (editFlags.canUndo) {
template.push({ label: messages.editMenuUndo.message, role: 'undo' });
}
// This is only ever `true` if undo was triggered via the context menu
// (not ctrl/cmd+z)
if (editFlags.canRedo) {
template.push({ label: messages.editMenuRedo.message, role: 'redo' });
}
if (editFlags.canUndo || editFlags.canRedo) {
template.push({ type: 'separator' });
}
if (editFlags.canCut) {
template.push({ label: messages.editMenuCut.message, role: 'cut' });
}
}
if (editFlags.canPaste) {
template.push({ label: messages.editMenuPaste.message, role: 'paste' });
}
if (editFlags.canPaste) {
template.push({
label: messages.editMenuPasteAndMatchStyle.message,
role: 'pasteAndMatchStyle',
});
}
// Only enable select all in editors because select all in non-editors
// results in all the UI being selected
if (editFlags.canSelectAll && params.isEditable) {
template.push({
label: messages.editMenuSelectAll.message,
role: 'selectall',
});
}
const menu = Menu.buildFromTemplate(template);
menu.popup(browserWindow);
}
});
};

@ -98,6 +98,8 @@ module.exports = {
getAllSessions,
getSwarmNodesByPubkey,
getGuardNodes,
updateGuardNodes,
getConversationCount,
saveConversation,
@ -807,6 +809,7 @@ async function updateSchema(instance) {
const LOKI_SCHEMA_VERSIONS = [
updateToLokiSchemaVersion1,
updateToLokiSchemaVersion2,
updateToLokiSchemaVersion3,
];
async function updateToLokiSchemaVersion1(currentVersion, instance) {
@ -975,6 +978,33 @@ async function updateToLokiSchemaVersion2(currentVersion, instance) {
console.log('updateToLokiSchemaVersion2: success!');
}
async function updateToLokiSchemaVersion3(currentVersion, instance) {
if (currentVersion >= 3) {
return;
}
await instance.run(
`CREATE TABLE ${GUARD_NODE_TABLE}(
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
ed25519PubKey VARCHAR(64)
);`
);
console.log('updateToLokiSchemaVersion3: starting...');
await instance.run('BEGIN TRANSACTION;');
await instance.run(
`INSERT INTO loki_schema (
version
) values (
3
);`
);
await instance.run('COMMIT TRANSACTION;');
console.log('updateToLokiSchemaVersion3: success!');
}
async function updateLokiSchema(instance) {
const result = await instance.get(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name='loki_schema';"
@ -1400,6 +1430,9 @@ async function removeAllSignedPreKeys() {
}
const PAIRING_AUTHORISATIONS_TABLE = 'pairingAuthorisations';
const GUARD_NODE_TABLE = 'guardNodes';
async function getAuthorisationForSecondaryPubKey(pubKey, options) {
const granted = options && options.granted;
let filter = '';
@ -1470,6 +1503,37 @@ async function getSecondaryDevicesFor(primaryDevicePubKey) {
return map(authorisations, row => row.secondaryDevicePubKey);
}
async function getGuardNodes() {
const nodes = await db.all(`SELECT ed25519PubKey FROM ${GUARD_NODE_TABLE};`);
if (!nodes) {
return null;
}
return nodes;
}
async function updateGuardNodes(nodes) {
await db.run('BEGIN TRANSACTION;');
await db.run(`DELETE FROM ${GUARD_NODE_TABLE}`);
await Promise.all(
nodes.map(edkey =>
db.run(
`INSERT INTO ${GUARD_NODE_TABLE} (
ed25519PubKey
) values ($ed25519PubKey)`,
{
$ed25519PubKey: edkey,
}
)
)
);
await db.run('END TRANSACTION;');
}
async function getPrimaryDeviceFor(secondaryDevicePubKey) {
const row = await db.get(
`SELECT primaryDevicePubKey FROM ${PAIRING_AUTHORISATIONS_TABLE} WHERE secondaryDevicePubKey = $secondaryDevicePubKey AND isGranted = 1;`,

@ -36,7 +36,10 @@ function initialize() {
console.log(
`sql channel error with call ${callName}: ${errorForDisplay}`
);
event.sender.send(`${SQL_CHANNEL_KEY}-done`, jobId, errorForDisplay);
// FIXME this line cause the test-integration to fail and we probably don't need it during test
if (!process.env.NODE_ENV.includes('test-integration')) {
event.sender.send(`${SQL_CHANNEL_KEY}-done`, jobId, errorForDisplay);
}
}
});

@ -1,15 +1,35 @@
const path = require('path');
const process = require('process');
const { app } = require('electron');
const { start } = require('./base_config');
const config = require('./config');
// Use separate data directory for development
if (config.has('storageProfile')) {
let storageProfile;
// Node makes sure all environment variables are strings
const { NODE_ENV: environment, NODE_APP_INSTANCE: instance } = process.env;
// We need to make sure instance is not empty
const isValidInstance = typeof instance === 'string' && instance.length > 0;
const isProduction = environment === 'production' && !isValidInstance;
// Use seperate data directories for each different environment and app instances
// We should prioritise config values first
if (config.has(storageProfile)) {
storageProfile = config.get('storageProfile');
} else if (!isProduction) {
storageProfile = environment;
if (isValidInstance) {
storageProfile = storageProfile.concat(`-${instance}`);
}
}
if (storageProfile) {
const userData = path.join(
app.getPath('appData'),
`Loki-Messenger-${config.get('storageProfile')}`
`Session-${storageProfile}`
);
app.setPath('userData', userData);

@ -0,0 +1,531 @@
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<meta content='width=device-width, user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0' name='viewport'>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none';
child-src 'self';
connect-src 'self' https: wss:;
font-src 'self';
form-action 'self';
frame-src 'none';
img-src 'self' blob: data:;
media-src 'self' blob:;
object-src 'none';
script-src 'self' 'unsafe-eval';
style-src 'self' 'unsafe-inline';"
>
<title>Session</title>
<link href='images/loki/loki_icon_128.png' rel='shortcut icon'>
<link href="stylesheets/manifest.css" rel="stylesheet" type="text/css" />
<!--
When making changes to these templates, be sure to update test/index.html as well
-->
<script type='text/x-tmpl-mustache' id='app-loading-screen'>
<div class='content'>
<img src='images/session/full-logo.svg' class='session-full-logo' />
</div>
</script>
<script type='text/x-tmpl-mustache' id='conversation-loading-screen'>
<div class='content'>
<img src='images/session/full-logo.svg' class='session-full-logo' />
</div>
</script>
<script type='text/x-tmpl-mustache' id='two-column'>
<div class='gutter'>
<div class='network-status-container'></div>
<div class='left-pane-placeholder'></div>
</div>
<div id='main-view'>
<div class='conversation-stack'>
<div class='conversation placeholder'>
<div class='conversation-header'></div>
<div class='container'>
<div class='content'>
<img src='images/session/full-logo.svg' class='session-full-logo' />
</div>
</div>
</div>
</div>
</div>
<div class='lightbox-container'></div>
</script>
<script type='text/x-tmpl-mustache' id='scroll-down-button-view'>
<button class='text module-scroll-down__button {{ buttonClass }}' alt='{{ moreBelow }}'>
<div class='module-scroll-down__icon'></div>
</button>
</script>
<script type='text/x-tmpl-mustache' id='last-seen-indicator-view'>
<div class='module-last-seen-indicator__bar'/>
<div class='module-last-seen-indicator__text'>
{{ unreadMessages }}
</div>
</script>
<script type='text/x-tmpl-mustache' id='expired_alert'>
<a target='_blank' href='https://getsession.org/'>
<button class='upgrade'>{{ upgrade }}</button>
</a>
<span>{{ expiredWarning }}</span>
<br clear="both">
</script>
<script type='text/x-tmpl-mustache' id='banner'>
<div class='body'>
<span class='icon warning'></span>
{{ message }}
<span class='icon dismiss'></span>
</div>
</script>
<script type='text/x-tmpl-mustache' id='conversation'>
<div class='conversation-content'>
<div class='conversation-content-left'>
<div class='conversation-header'></div>
<div class='main panel'>
<div class='discussion-container'>
<div class='bar-container hide'>
<div class='bar active progress-bar-striped progress-bar'></div>
</div>
</div>
<div class='bottom-bar' id='footer'>
<div class='emoji-panel-container'></div>
<div class='member-list-container'></div>
<div id='bulk-edit-view'></div>
<div class='attachment-list'></div>
<div class='compose'>
<form class='send clearfix file-input'>
<div class='flex'>
<div id='choose-file' class='choose-file'>
<button class='paperclip thumbnail' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></button>
<input type='file' class='file-input' multiple='multiple'>
</div>
<div class='capture-audio'>
<button class='microphone' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></button>
</div>
<div class='send-message-container'>
<textarea maxlength='2000' class='send-message' placeholder='{{ send-message }}' rows='1' dir='auto' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></textarea>
</div>
<button class='emoji' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></button>
</div>
</form>
</div>
</div>
</div>
</div>
<div class='conversation-content-right'>
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='message-list'>
<div class='messages'></div>
<div class='typing-container'></div>
</script>
<script type='text/x-tmpl-mustache' id='recorder'>
<button class='finish'><span class='icon'></span></button>
<span class='time'>0:00</span>
<button class='close'><span class='icon'></span></button>
</script>
<script type='text/x-tmpl-mustache' id='password-dialog'>
<div class="content">
{{ #title }}
<h4>{{ title }}</h4>
{{ /title }}
<input type='password' id='password' placeholder='Password' autofocus>
<input type='password' id='password-confirmation' placeholder='Type in your password again' autofocus>
<div class='error'></div>
<div class='buttons'>
<button class='cancel' tabindex='2'>{{ cancel }}</button>
<button class='ok' tabindex='1'>{{ ok }}</button>
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='identicon-svg'>
<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'>
<circle cx='50' cy='50' r='40' fill='{{ color }}' />
<text text-anchor='middle' fill='white' font-family='sans-serif' font-size='24px' x='50' y='50' baseline-shift='-8px'>
{{ content }}
</text>
</svg>
</script>
<script type='text/x-tmpl-mustache' id='phone-number'>
<div class='phone-input-form'>
<div class='number-container'>
<input type='tel' class='number' placeholder="Phone Number" />
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='file-size-modal'>
{{ file-size-warning }}
({{ limit }}{{ units }})
</script>
<script type='text/x-tmpl-mustache' id='attachment-type-modal'>
Sorry, your attachment has a type, {{type}}, that is not currently supported.
</script>
<script type='text/x-tmpl-mustache' id='group-member-list'>
<div class='container'>
{{ #summary }} <div class='summary'>{{ summary }}</div>{{ /summary }}
</div>
</script>
<script type='text/x-tmpl-mustache' id='key-verification'>
<div class='container'>
{{ ^hasTheirKey }}
<div class='placeholder'>{{ theirKeyUnknown }}</div>
{{ /hasTheirKey }}
{{ #hasTheirKey }}
<label> {{ yourSafetyNumberWith }} </label>
<!--<div class='qr'></div>-->
<div class='key'>
{{ #chunks }} <span>{{ . }}</span> {{ /chunks }}
</div>
{{ /hasTheirKey }}
{{ verifyHelp }}
<p> {{> link_to_support }} </p>
<div class='summary'>
{{ #isVerified }}
<span class='icon verified'></span>
{{ /isVerified }}
{{ ^isVerified }}
<span class='icon shield'></span>
{{ /isVerified }}
{{ verifiedStatus }}
</div>
<div class='verify'>
<button class='verify grey'>
{{ verifyButton }}
</button>
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='clear-data'>
{{#isStep1}}
<div class='step'>
<div class='inner'>
<div class='step-body'>
<span class='banner-icon alert-outline-red'></span>
<div class='header'>{{ header }}</div>
<div class='body-text-wide'>{{ body }}</div>
</div>
<div class='nav'>
<div>
<a class='button neutral cancel'>{{ cancelButton }}</a>
<a class='button destructive delete-all-data'>{{ deleteButton }}</a>
</div>
</div>
</div>
</div>
{{/isStep1}}
{{#isStep2}}
<div id='step3' class='step'>
<div class='inner'>
<div class='step-body'>
<span class='banner-icon delete'></span>
<div class='header'>{{ deleting }}</div>
</div>
<div class='progress'>
<div class='bar-container'>
<div class='bar progress-bar progress-bar-striped active'></div>
</div>
</div>
</div>
</div>
{{/isStep2}}
</script>
<script type='text/x-tmpl-mustache' id='networkStatus'>
<div class='network-status-message'>
<h3>{{ message }}</h3>
<span>{{ instructions }}</span>
</div>
{{ #reconnectDurationAsSeconds }}
<div class="network-status-message">
{{ attemptingReconnectionMessage }}
</div>
{{/reconnectDurationAsSeconds }}
{{ #action }}
<div class="action">
<button class='small blue {{ buttonClass }}'>{{ action }}</button>
</div>
{{/action }}
</script>
<script type='text/x-tmpl-mustache' id='import-flow-template'>
{{#isStep2}}
<div id='step2' class='step'>
<div class='inner'>
<div class='step-body'>
<span class='banner-icon folder-outline'></span>
<div class='header'>{{ chooseHeader }}</div>
<div class='body-text'>{{ choose }}</div>
</div>
<div class='nav'>
<div>
<a class='button choose'>{{ chooseButton }}</a>
</div>
</div>
</div>
</div>
{{/isStep2}}
{{#isStep3}}
<div id='step3' class='step'>
<div class='inner'>
<div class='step-body'>
<span class='banner-icon import'></span>
<div class='header'>{{ importingHeader }}</div>
</div>
<div class='progress'>
<div class='bar-container'>
<div class='bar progress-bar progress-bar-striped active'></div>
</div>
</div>
</div>
</div>
{{/isStep3}}
{{#isStep4}}
<div id='step4' class='step'>
<div class='inner'>
<div class='step-body'>
<span class='banner-icon check-circle-outline'></span>
<div class='header'>{{ completeHeader }}</div>
</div>
<div class='nav'>
{{#restartButton}}
<div>
<a class='button restart'>{{ restartButton }}</a>
</div>
{{/restartButton}}
{{#registerButton}}
<div>
<a class='button register'>{{ registerButton }}</a>
</div>
{{/registerButton}}
</div>
</div>
</div>
{{/isStep4}}
{{#isError}}
<div id='error' class='step'>
<div class='inner error-dialog clearfix'>
<div class='step-body'>
<span class='banner-icon alert-outline'></span>
<div class='header'>{{ errorHeader }}</div>
<div class='body-text-wide'>
{{ errorMessageFirst }}
<p>{{ errorMessageSecond }}</p>
</div>
</div>
<div class='nav'>
<div>
<a class='button choose'>{{ chooseButton }}</a>
</div>
</div>
</div>
</div>
{{/isError}}
</script>
<script type='text/x-tmpl-mustache' id='link-flow-template'>
{{#isStep3}}
<div id='step3' class='step'>
<div class='inner'>
<div class='step-body'>
<div class='header'>{{ linkYourPhone }}</div>
<div id="qr">
<div class='container'>
<span class='dot'></span>
<span class='dot'></span>
<span class='dot'></span>
</div>
</div>
</div>
<div class='nav'>
<div class='instructions'>
<div class='android'>
<div class='label'>
<span class='os-icon android'></span>
</div>
<div class='body'>
<div>→ {{ signalSettings }}</div>
<div>→ {{ linkedDevices }}</div>
<div>→ {{ androidFinalStep }}</div>
</div>
</div>
<div class='apple'>
<div class='label'>
<span class='os-icon apple'></span>
</div>
<div class='body'>
<div>→ {{ signalSettings }}</div>
<div>→ {{ linkedDevices }}</div>
<div>→ {{ appleFinalStep }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
{{/isStep3}}
{{#isStep4}}
<form id='link-phone'>
<div id='step4' class='step'>
<div class='inner'>
<div class='step-body'>
<span class='banner-icon lead-pencil'></span>
<div class='header'>{{ chooseName }}</div>
<div>
<input type='text' class='device-name' spellcheck='false' maxlength='50' />
</div>
</div>
<div class='nav'>
<div>
<a class='button finish'>{{ finishLinkingPhoneButton }}</a>
</div>
</div>
</div>
</div>
</form>
{{/isStep4}}
{{#isStep5}}
<div id='step5' class='step'>
<div class='inner'>
<div class='step-body'>
<span class='banner-icon sync'></span>
<div class='header'>{{ syncing }}</div>
</div>
<div class='progress'>
<div class='bar-container'>
<div class='bar progress-bar progress-bar-striped active'></div>
</div>
</div>
</div>
</div>
{{/isStep5}}
{{#isError}}
<div id='error' class='step'>
<div class='inner'>
<div class='step-body'>
<span class='banner-icon alert-outline'></span>
<div class='header'>{{ errorHeader }}</div>
<div class='body'>{{ errorMessage }}</div>
</div>
<div class='nav'>
<a class='button try-again'>{{ errorButton }}</a>
</div>
</div>
</div>
{{/isError}}
</script>
<script type='text/javascript' src='js/components.js'></script>
<script type='text/javascript' src='js/reliable_trigger.js'></script>
<script type='text/javascript' src='js/database.js'></script>
<script type='text/javascript' src='js/storage.js'></script>
<script type='text/javascript' src='js/legacy_storage.js'></script>
<script type='text/javascript' src='js/signal_protocol_store.js'></script>
<script type='text/javascript' src='js/libtextsecure.js'></script>
<script type='text/javascript' src='js/libloki.js'></script>
<script type='text/javascript' src='js/focus_listener.js'></script>
<script type='text/javascript' src='js/notifications.js'></script>
<script type='text/javascript' src='js/delivery_receipts.js'></script>
<script type='text/javascript' src='js/read_receipts.js'></script>
<script type='text/javascript' src='js/read_syncs.js'></script>
<script type='text/javascript' src='js/libphonenumber-util.js'></script>
<script type='text/javascript' src='js/models/messages.js'></script>
<script type='text/javascript' src='js/models/conversations.js'></script>
<script type='text/javascript' src='js/models/blockedNumbers.js'></script>
<script type='text/javascript' src='js/expiring_messages.js'></script>
<script type='text/javascript' src='js/chromium.js'></script>
<script type='text/javascript' src='js/registration.js'></script>
<script type='text/javascript' src='js/expire.js'></script>
<script type='text/javascript' src='js/conversation_controller.js'></script>
<script type='text/javascript' src='js/blocked_number_controller.js'></script>
<script type='text/javascript' src='js/message_controller.js'></script>
<script type='text/javascript' src='js/views/react_wrapper_view.js'></script>
<script type='text/javascript' src='js/views/whisper_view.js'></script>
<script type='text/javascript' src='js/views/last_seen_indicator_view.js'></script>
<script type='text/javascript' src='js/views/scroll_down_button_view.js'></script>
<script type='text/javascript' src='js/views/toast_view.js'></script>
<script type='text/javascript' src='js/views/session_toast_view.js'></script>
<script type='text/javascript' src='js/views/conversation_loading_view.js'></script>
<script type='text/javascript' src='js/views/session_toggle_view.js'></script>
<script type='text/javascript' src='js/views/session_modal_view.js'></script>
<script type='text/javascript' src='js/views/session_dropdown_view.js'></script>
<script type='text/javascript' src='js/views/session_confirm_view.js'></script>
<script type='text/javascript' src='js/views/file_input_view.js'></script>
<script type='text/javascript' src='js/views/list_view.js'></script>
<script type='text/javascript' src='js/views/contact_list_view.js'></script>
<script type='text/javascript' src='js/views/message_view.js'></script>
<script type='text/javascript' src='js/views/key_verification_view.js'></script>
<script type='text/javascript' src='js/views/message_list_view.js'></script>
<script type='text/javascript' src='js/views/member_list_view.js'></script>
<script type='text/javascript' src='js/views/bulk_edit_view.js'></script>
<script type='text/javascript' src='js/views/group_member_list_view.js'></script>
<script type='text/javascript' src='js/views/recorder_view.js'></script>
<script type='text/javascript' src='js/views/conversation_view.js'></script>
<script type='text/javascript' src='js/views/inbox_view.js'></script>
<script type='text/javascript' src='js/views/network_status_view.js'></script>
<script type='text/javascript' src='js/views/confirmation_dialog_view.js'></script>
<script type='text/javascript' src='js/views/nickname_dialog_view.js'></script>
<script type='text/javascript' src='js/views/password_dialog_view.js'></script>
<script type='text/javascript' src='js/views/seed_dialog_view.js'></script>
<script type='text/javascript' src='js/views/qr_dialog_view.js'></script>
<script type='text/javascript' src='js/views/connecting_to_server_dialog_view.js'></script>
<script type='text/javascript' src='js/views/beta_release_disclaimer_view.js'></script>
<script type='text/javascript' src='js/views/identicon_svg_view.js'></script>
<script type='text/javascript' src='js/views/install_view.js'></script>
<script type='text/javascript' src='js/views/banner_view.js'></script>
<script type="text/javascript" src="js/views/phone-input-view.js"></script>
<script type='text/javascript' src='js/views/session_registration_view.js'></script>
<script type='text/javascript' src='js/views/app_view.js'></script>
<script type='text/javascript' src='js/views/import_view.js'></script>
<script type='text/javascript' src='js/views/device_pairing_dialog_view.js'></script>
<script type='text/javascript' src='js/views/device_pairing_words_dialog_view.js'></script>
<script type='text/javascript' src='js/views/create_group_dialog_view.js'></script>
<script type='text/javascript' src='js/views/confirm_session_reset_view.js'></script>
<script type='text/javascript' src='js/views/edit_profile_dialog_view.js'></script>
<script type='text/javascript' src='js/views/invite_friends_dialog_view.js'></script>
<script type='text/javascript' src='js/views/moderators_add_dialog_view.js'></script>
<script type='text/javascript' src='js/views/moderators_remove_dialog_view.js'></script>
<script type='text/javascript' src='js/views/user_details_dialog_view.js'></script>
<script type='text/javascript' src='js/wall_clock_listener.js'></script>
<script type='text/javascript' src='js/rotate_signed_prekey_listener.js'></script>
<script type='text/javascript' src='js/keychange_listener.js'></script>
</head>
<body>
<div class='app-loading-screen'>
<div class='content'>
<img src='images/session/full-logo.svg' class='session-full-logo' />
</div>
</div>
<script type='text/javascript' src='js/background.js'></script>
</body>
</html>

@ -3,7 +3,6 @@
"localUrl": "localhost.loki",
"cdnUrl": "random.snode",
"contentProxyUrl": "",
"localServerPort": "8081",
"defaultPoWDifficulty": "1",
"seedNodeList": [
{

@ -1,16 +0,0 @@
{
"storageProfile": "development1",
"localServerPort": "8082",
"seedNodeList": [
{
"ip": "public.loki.foundation",
"port": "38157"
},
{
"ip": "storage.testnetseed1.loki.network",
"port": "38157"
}
],
"openDevTools": true,
"defaultPublicChatServer": "https://chat-dev.lokinet.org/"
}

@ -1,16 +0,0 @@
{
"storageProfile": "development2",
"localServerPort": "8083",
"seedNodeList": [
{
"ip": "public.loki.foundation",
"port": "38157"
},
{
"ip": "storage.testnetseed1.loki.network",
"port": "38157"
}
],
"openDevTools": true,
"defaultPublicChatServer": "https://chat-dev.lokinet.org/"
}

@ -1,5 +1,4 @@
{
"storageProfile": "development",
"seedNodeList": [
{
"ip": "public.loki.foundation",

@ -1,6 +0,0 @@
{
"storageProfile": "devprod1Profile",
"localServerPort": "8082",
"openDevTools": true,
"updatesEnabled": false
}

@ -1,5 +1,4 @@
{
"storageProfile": "devprodProfile",
"openDevTools": true,
"updatesEnabled": false
}

@ -0,0 +1,4 @@
{
"openDevTools": true,
"updatesEnabled": false
}

@ -1,4 +0,0 @@
{
"storageProfile": "staging",
"openDevTools": true
}

@ -1,5 +1,4 @@
{
"storageProfile": "swarm-testing",
"seedNodeList": [
{
"ip": "localhost",

@ -1,11 +0,0 @@
{
"storageProfile": "swarm-testing2",
"seedNodeList": [
{
"ip": "localhost",
"port": "22129"
}
],
"openDevTools": true,
"defaultPublicChatServer": "https://team-chat.lokinet.org/"
}

@ -0,0 +1,4 @@
{
"openDevTools": false,
"updatesEnabled": false
}

@ -0,0 +1,119 @@
/* eslint-disable func-names */
/* eslint-disable import/no-extraneous-dependencies */
const common = require('./common');
const { afterEach, beforeEach, describe, it } = require('mocha');
const ConversationPage = require('./page-objects/conversation.page');
describe('Add friends', function() {
let app;
let app2;
this.timeout(60000);
this.slow(15000);
beforeEach(async () => {
await common.killallElectron();
await common.stopStubSnodeServer();
const app1Props = {
mnemonic: common.TEST_MNEMONIC1,
displayName: common.TEST_DISPLAY_NAME1,
stubSnode: true,
};
const app2Props = {
mnemonic: common.TEST_MNEMONIC2,
displayName: common.TEST_DISPLAY_NAME2,
stubSnode: true,
};
[app, app2] = await Promise.all([
common.startAndStub(app1Props),
common.startAndStub2(app2Props),
]);
});
afterEach(async () => {
await common.stopApp(app);
await common.killallElectron();
await common.stopStubSnodeServer();
});
it('addFriends: can add a friend by sessionID', async () => {
const textMessage = common.generateSendMessageText();
await app.client.element(ConversationPage.contactsButtonSection).click();
await app.client.element(ConversationPage.addContactButton).click();
await app.client.isExisting(ConversationPage.leftPaneOverlay).should
.eventually.be.true;
await common.setValueWrapper(
app,
ConversationPage.sessionIDInput,
common.TEST_PUBKEY2
);
await app.client
.element(ConversationPage.sessionIDInput)
.getValue()
.should.eventually.equal(common.TEST_PUBKEY2);
await app.client.element(ConversationPage.nextButton).click();
await app.client.waitForExist(
ConversationPage.sendFriendRequestTextarea,
1000
);
// send a text message to that user (will be a friend request)
await app.client
.element(ConversationPage.sendFriendRequestTextarea)
.setValue(textMessage);
await app.client.keys('Enter');
await app.client.waitForExist(
ConversationPage.existingFriendRequestText(textMessage),
1000
);
// assure friend request message has been sent
await common.timeout(3000);
await app.client.isExisting(ConversationPage.retrySendButton).should
.eventually.be.false;
// wait for left notification Friend Request count to go to 1 and click it
await app2.client.waitForExist(
ConversationPage.oneNotificationFriendRequestLeft,
5000
);
await app2.client
.element(ConversationPage.oneNotificationFriendRequestLeft)
.click();
// open the dropdown from the top friend request count
await app2.client.isExisting(
ConversationPage.oneNotificationFriendRequestTop
).should.eventually.be.true;
await app2.client
.element(ConversationPage.oneNotificationFriendRequestTop)
.click();
// we should have our app1 friend request here
await app2.client.isExisting(
ConversationPage.friendRequestFromUser(
common.TEST_DISPLAY_NAME1,
common.TEST_PUBKEY1
)
).should.eventually.be.true;
await app2.client.isExisting(ConversationPage.acceptFriendRequestButton)
.should.eventually.be.true;
// accept the friend request and validate that on both side the "accepted FR" message is shown
await app2.client
.element(ConversationPage.acceptFriendRequestButton)
.click();
await app2.client.waitForExist(
ConversationPage.acceptedFriendRequestMessage,
1000
);
await app.client.waitForExist(
ConversationPage.acceptedFriendRequestMessage,
5000
);
});
});

@ -0,0 +1,129 @@
/* eslint-disable func-names */
/* eslint-disable import/no-extraneous-dependencies */
const common = require('./common');
const { afterEach, beforeEach, describe, it } = require('mocha');
const ConversationPage = require('./page-objects/conversation.page');
describe('Closed groups', function() {
let app;
let app2;
this.timeout(60000);
this.slow(15000);
beforeEach(async () => {
await common.killallElectron();
await common.stopStubSnodeServer();
[app, app2] = await common.startAppsAsFriends();
});
afterEach(async () => {
await common.stopApp(app);
await common.killallElectron();
await common.stopStubSnodeServer();
});
it('closedGroup: can create a closed group with a friend and send/receive a message', async () => {
await app.client.element(ConversationPage.globeButtonSection).click();
await app.client.element(ConversationPage.createClosedGroupButton).click();
// fill the groupname
await common.setValueWrapper(
app,
ConversationPage.closedGroupNameTextarea,
common.VALID_CLOSED_GROUP_NAME1
);
await app.client
.element(ConversationPage.closedGroupNameTextarea)
.getValue()
.should.eventually.equal(common.VALID_CLOSED_GROUP_NAME1);
await app.client
.element(ConversationPage.createClosedGroupMemberItem)
.isVisible();
// select the first friend as a member of the groups being created
await app.client
.element(ConversationPage.createClosedGroupMemberItem)
.click();
await app.client
.element(ConversationPage.createClosedGroupMemberItemSelected)
.isVisible();
// trigger the creation of the group
await app.client
.element(ConversationPage.validateCreationClosedGroupButton)
.click();
await app.client.waitForExist(
ConversationPage.sessionToastGroupCreatedSuccess,
1000
);
await app.client.isExisting(
ConversationPage.headerTitleGroupName(common.VALID_CLOSED_GROUP_NAME1)
).should.eventually.be.true;
await app.client
.element(ConversationPage.headerTitleMembers(2))
.isVisible();
// validate overlay is closed
await app.client.isExisting(ConversationPage.leftPaneOverlay).should
.eventually.be.false;
// move back to the conversation section
await app.client
.element(ConversationPage.conversationButtonSection)
.click();
// validate open chat has been added
await app.client.isExisting(
ConversationPage.rowOpenGroupConversationName(
common.VALID_CLOSED_GROUP_NAME1
)
).should.eventually.be.true;
// next check app2 has been invited and has the group in its conversations
await app2.client.waitForExist(
ConversationPage.rowOpenGroupConversationName(
common.VALID_CLOSED_GROUP_NAME1
),
6000
);
// open the closed group conversation on app2
await app2.client
.element(ConversationPage.conversationButtonSection)
.click();
await common.timeout(500);
await app2.client
.element(
ConversationPage.rowOpenGroupConversationName(
common.VALID_CLOSED_GROUP_NAME1
)
)
.click();
// send a message from app and validate it is received on app2
const textMessage = common.generateSendMessageText();
await app.client
.element(ConversationPage.sendMessageTextarea)
.setValue(textMessage);
await app.client
.element(ConversationPage.sendMessageTextarea)
.getValue()
.should.eventually.equal(textMessage);
// send the message
await app.client.keys('Enter');
// validate that the message has been added to the message list view
await app.client.waitForExist(
ConversationPage.existingSendMessageText(textMessage),
2000
);
// validate that the message has been added to the message list view
await app2.client.waitForExist(
ConversationPage.existingReceivedMessageText(textMessage),
5000
);
});
});

@ -0,0 +1,498 @@
/* eslint-disable no-console */
/* eslint-disable import/no-extraneous-dependencies */
/* eslint-disable prefer-destructuring */
const { Application } = require('spectron');
const path = require('path');
const chai = require('chai');
const chaiAsPromised = require('chai-as-promised');
const RegistrationPage = require('./page-objects/registration.page');
const ConversationPage = require('./page-objects/conversation.page');
const { exec } = require('child_process');
const url = require('url');
const http = require('http');
const fse = require('fs-extra');
chai.should();
chai.use(chaiAsPromised);
chai.config.includeStack = true;
const STUB_SNODE_SERVER_PORT = 3000;
const ENABLE_LOG = false;
module.exports = {
/* ************** USERS ****************** */
TEST_MNEMONIC1:
'faxed mechanic mocked agony unrest loincloth pencil eccentric boyfriend oasis speedy ribbon faxed',
TEST_PUBKEY1:
'0552b85a43fb992f6bdb122a5a379505a0b99a16f0628ab8840249e2a60e12a413',
TEST_DISPLAY_NAME1: 'integration_tester_1',
TEST_MNEMONIC2:
'guide inbound jerseys bays nouns basin sulking awkward stockpile ostrich ascend pylons ascend',
TEST_PUBKEY2:
'054e1ca8681082dbd9aad1cf6fc89a32254e15cba50c75b5a73ac10a0b96bcbd2a',
TEST_DISPLAY_NAME2: 'integration_tester_2',
/* ************** OPEN GROUPS ****************** */
VALID_GROUP_URL: 'https://chat.getsession.org',
VALID_GROUP_URL2: 'https://chat-dev.lokinet.org',
VALID_GROUP_NAME: 'Session Public Chat',
VALID_GROUP_NAME2: 'Loki Dev Chat',
/* ************** CLOSED GROUPS ****************** */
VALID_CLOSED_GROUP_NAME1: 'Closed Group 1',
USER_DATA_ROOT_FOLDER: '',
async timeout(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
},
// a wrapper to work around electron/spectron bug
async setValueWrapper(app, selector, value) {
await app.client.element(selector).click();
// keys, setValue and addValue hang on certain platforms
// could put a branch here to use one of those
// if we know what platforms are good and which ones are broken
await app.client.execute(
(slctr, val) => {
// eslint-disable-next-line no-undef
const iter = document.evaluate(
slctr,
// eslint-disable-next-line no-undef
document,
null,
// eslint-disable-next-line no-undef
XPathResult.UNORDERED_NODE_ITERATOR_TYPE,
null
);
const elem = iter.iterateNext();
if (elem) {
elem.value = val;
} else {
console.error('Cant find', slctr, elem, iter);
}
},
selector,
value
);
// let session js detect the text change
await app.client.element(selector).click();
},
async startApp(environment = 'test-integration-session') {
const env = environment.startsWith('test-integration')
? 'test-integration'
: environment;
const instance = environment.replace('test-integration-', '');
const app1 = new Application({
path: path.join(__dirname, '..', 'node_modules', '.bin', 'electron'),
args: ['.'],
env: {
NODE_ENV: env,
NODE_APP_INSTANCE: instance,
USE_STUBBED_NETWORK: true,
ELECTRON_ENABLE_LOGGING: true,
ELECTRON_ENABLE_STACK_DUMPING: true,
ELECTRON_DISABLE_SANDBOX: 1,
},
requireName: 'electronRequire',
// chromeDriverLogPath: '../chromedriverlog.txt',
chromeDriverArgs: [
`remote-debugging-port=${Math.floor(
Math.random() * (9999 - 9000) + 9000
)}`,
],
});
chaiAsPromised.transferPromiseness = app1.transferPromiseness;
await app1.start();
await app1.client.waitUntilWindowLoaded();
return app1;
},
async startApp2() {
const app2 = await this.startApp('test-integration-session-2');
return app2;
},
async stopApp(app1) {
if (app1 && app1.isRunning()) {
await app1.stop();
}
},
async killallElectron() {
// rtharp - my 2nd client on MacOs needs: pkill -f "node_modules/.bin/electron"
// node_modules/electron/dist/electron is node_modules/electron/dist/Electron.app on MacOS
const killStr =
process.platform === 'win32'
? 'taskkill /im electron.exe /t /f'
: 'pkill -f "node_modules/electron/dist/electron" | pkill -f "node_modules/.bin/electron"';
return new Promise(resolve => {
exec(killStr, (err, stdout, stderr) => {
if (err) {
resolve({ stdout, stderr });
} else {
resolve({ stdout, stderr });
}
});
});
},
async rmFolder(folder) {
await fse.remove(folder);
},
async startAndAssureCleanedApp2() {
const app2 = await this.startAndAssureCleanedApp(
'test-integration-session-2'
);
return app2;
},
async startAndAssureCleanedApp(env = 'test-integration-session') {
const userData = path.join(this.USER_DATA_ROOT_FOLDER, `Session-${env}`);
await this.rmFolder(userData);
const app1 = await this.startApp(env);
await app1.client.waitForExist(
RegistrationPage.registrationTabSignIn,
4000
);
return app1;
},
async startAndStub({
mnemonic,
displayName,
stubSnode = false,
stubOpenGroups = false,
env = 'test-integration-session',
}) {
const app = await this.startAndAssureCleanedApp(env);
if (stubSnode) {
await this.startStubSnodeServer();
this.stubSnodeCalls(app);
}
if (stubOpenGroups) {
this.stubOpenGroupsCalls(app);
}
if (mnemonic && displayName) {
await this.restoreFromMnemonic(app, mnemonic, displayName);
// not sure we need this - rtharp.
await this.timeout(2000);
}
return app;
},
async startAndStub2(props) {
const app2 = await this.startAndStub({
env: 'test-integration-session-2',
...props,
});
return app2;
},
async restoreFromMnemonic(app, mnemonic, displayName) {
await app.client.element(RegistrationPage.registrationTabSignIn).click();
await app.client.element(RegistrationPage.restoreFromSeedMode).click();
await this.setValueWrapper(
app,
RegistrationPage.recoveryPhraseInput,
mnemonic
);
await this.setValueWrapper(
app,
RegistrationPage.displayNameInput,
displayName
);
await app.client.element(RegistrationPage.continueSessionButton).click();
await app.client.waitForExist(
RegistrationPage.conversationListContainer,
4000
);
},
async startAppsAsFriends() {
const app1Props = {
mnemonic: this.TEST_MNEMONIC1,
displayName: this.TEST_DISPLAY_NAME1,
stubSnode: true,
};
const app2Props = {
mnemonic: this.TEST_MNEMONIC2,
displayName: this.TEST_DISPLAY_NAME2,
stubSnode: true,
};
const [app1, app2] = await Promise.all([
this.startAndStub(app1Props),
this.startAndStub2(app2Props),
]);
/** add each other as friends */
const textMessage = this.generateSendMessageText();
await app1.client.element(ConversationPage.contactsButtonSection).click();
await app1.client.element(ConversationPage.addContactButton).click();
await this.setValueWrapper(
app1,
ConversationPage.sessionIDInput,
this.TEST_PUBKEY2
);
await app1.client.element(ConversationPage.nextButton).click();
await app1.client.waitForExist(
ConversationPage.sendFriendRequestTextarea,
1000
);
// send a text message to that user (will be a friend request)
await this.setValueWrapper(
app1,
ConversationPage.sendFriendRequestTextarea,
textMessage
);
await app1.client.keys('Enter');
await app1.client.waitForExist(
ConversationPage.existingFriendRequestText(textMessage),
1000
);
// wait for left notification Friend Request count to go to 1 and click it
await app2.client.waitForExist(
ConversationPage.oneNotificationFriendRequestLeft,
5000
);
await app2.client
.element(ConversationPage.oneNotificationFriendRequestLeft)
.click();
// open the dropdown from the top friend request count
await app2.client.isExisting(
ConversationPage.oneNotificationFriendRequestTop
).should.eventually.be.true;
await app2.client
.element(ConversationPage.oneNotificationFriendRequestTop)
.click();
// accept the friend request and validate that on both side the "accepted FR" message is shown
await app2.client
.element(ConversationPage.acceptFriendRequestButton)
.click();
await app2.client.waitForExist(
ConversationPage.acceptedFriendRequestMessage,
1000
);
await app1.client.waitForExist(
ConversationPage.acceptedFriendRequestMessage,
5000
);
return [app1, app2];
},
async linkApp2ToApp(app1, app2) {
// app needs to be logged in as user1 and app2 needs to be logged out
// start the pairing dialog for the first app
await app1.client.element(ConversationPage.settingsButtonSection).click();
await app1.client.element(ConversationPage.deviceSettingsRow).click();
await app1.client.isVisible(ConversationPage.noPairedDeviceMessage);
// we should not find the linkDeviceButtonDisabled button (as DISABLED)
await app1.client.isExisting(ConversationPage.linkDeviceButtonDisabled)
.should.eventually.be.false;
await app1.client.element(ConversationPage.linkDeviceButton).click();
// validate device pairing dialog is shown and has a qrcode
await app1.client.isVisible(ConversationPage.devicePairingDialog);
await app1.client.isVisible(ConversationPage.qrImageDiv);
// next trigger the link request from the app2 with the app1 pubkey
await app2.client.element(RegistrationPage.registrationTabSignIn).click();
await app2.client.element(RegistrationPage.linkDeviceMode).click();
await this.setValueWrapper(
app2,
RegistrationPage.textareaLinkDevicePubkey,
this.TEST_PUBKEY1
);
await app2.client.element(RegistrationPage.linkDeviceTriggerButton).click();
await app1.client.waitForExist(RegistrationPage.toastWrapper, 7000);
let secretWordsapp1 = await app1.client
.element(RegistrationPage.secretToastDescription)
.getText();
secretWordsapp1 = secretWordsapp1.split(': ')[1];
await app2.client.waitForExist(RegistrationPage.toastWrapper, 6000);
await app2.client
.element(RegistrationPage.secretToastDescription)
.getText()
.should.eventually.be.equal(secretWordsapp1);
await app1.client.element(ConversationPage.allowPairingButton).click();
await app1.client.element(ConversationPage.okButton).click();
// validate device paired in settings list with correct secrets
await app1.client.waitForExist(
ConversationPage.devicePairedDescription(secretWordsapp1),
2000
);
await app1.client.isExisting(ConversationPage.unpairDeviceButton).should
.eventually.be.true;
await app1.client.isExisting(ConversationPage.linkDeviceButtonDisabled)
.should.eventually.be.true;
// validate app2 (secondary device) is linked successfully
await app2.client.waitForExist(
RegistrationPage.conversationListContainer,
4000
);
// validate primary pubkey of app2 is the same that in app1
await app2.webContents
.executeJavaScript("window.storage.get('primaryDevicePubKey')")
.should.eventually.be.equal(this.TEST_PUBKEY1);
},
async triggerUnlinkApp2FromApp(app1, app2) {
// check app2 is loggedin
await app2.client.isExisting(RegistrationPage.conversationListContainer)
.should.eventually.be.true;
await app1.client.element(ConversationPage.settingsButtonSection).click();
await app1.client.element(ConversationPage.deviceSettingsRow).click();
await app1.client.isExisting(ConversationPage.linkDeviceButtonDisabled)
.should.eventually.be.true;
// click the unlink button
await app1.client.element(ConversationPage.unpairDeviceButton).click();
await app1.client.element(ConversationPage.validateUnpairDevice).click();
await app1.client.waitForExist(
ConversationPage.noPairedDeviceMessage,
2000
);
await app1.client.element(ConversationPage.linkDeviceButton).isEnabled()
.should.eventually.be.true;
// let time to app2 to catch the event and restart dropping its data
await this.timeout(5000);
// check that the app restarted
// (did not find a better way than checking the app no longer being accessible)
let isApp2Joinable = true;
try {
await app2.client.isExisting(RegistrationPage.registrationTabSignIn)
.should.eventually.be.true;
} catch (err) {
// if we get an error here, it means Spectron is lost.
// this is a good thing because it means app2 restarted
isApp2Joinable = false;
}
if (isApp2Joinable) {
throw new Error(
'app2 is still joinable so it did not restart, so it did not unlink correctly'
);
}
},
generateSendMessageText: () =>
`Test message from integration tests ${Date.now()}`,
stubOpenGroupsCalls: app1 => {
app1.webContents.executeJavaScript(
'window.LokiAppDotNetServerAPI = window.StubAppDotNetAPI;'
);
},
stubSnodeCalls(app1) {
app1.webContents.executeJavaScript(
'window.LokiMessageAPI = window.StubMessageAPI;'
);
},
logsContainsString: async (app1, str) => {
const logs = JSON.stringify(await app1.client.getRenderProcessLogs());
return logs.includes(str);
},
async startStubSnodeServer() {
if (!this.stubSnode) {
this.messages = {};
this.stubSnode = http.createServer((request, response) => {
const { query } = url.parse(request.url, true);
const { pubkey, data, timestamp } = query;
if (pubkey) {
if (request.method === 'POST') {
if (ENABLE_LOG) {
console.warn('POST', [data, timestamp]);
}
let ori = this.messages[pubkey];
if (!this.messages[pubkey]) {
ori = [];
}
this.messages[pubkey] = [...ori, { data, timestamp }];
response.writeHead(200, { 'Content-Type': 'text/html' });
response.end();
} else {
const retrievedMessages = { messages: this.messages[pubkey] };
if (ENABLE_LOG) {
console.warn('GET', pubkey, retrievedMessages);
}
if (this.messages[pubkey]) {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.write(JSON.stringify(retrievedMessages));
this.messages[pubkey] = [];
}
response.end();
}
}
response.end();
});
this.stubSnode.listen(STUB_SNODE_SERVER_PORT);
} else {
this.messages = {};
}
},
async stopStubSnodeServer() {
if (this.stubSnode) {
this.stubSnode.close();
this.stubSnode = null;
}
},
// async killStubSnodeServer() {
// return new Promise(resolve => {
// exec(
// `lsof -ti:${STUB_SNODE_SERVER_PORT} |xargs kill -9`,
// (err, stdout, stderr) => {
// if (err) {
// resolve({ stdout, stderr });
// } else {
// resolve({ stdout, stderr });
// }
// }
// );
// });
// },
};

@ -0,0 +1,23 @@
/* eslint-disable no-console */
/* eslint-disable more/no-then */
/* eslint-disable global-require */
/* eslint-disable import/no-extraneous-dependencies */
const { before } = require('mocha');
const common = require('./common');
require('./registration_test');
require('./open_group_test');
require('./add_friends_test');
require('./link_device_test');
require('./closed_group_test');
before(async () => {
// start the app once before all tests to get the platform-dependent
// path of user data and store it to common.USER_DATA_ROOT_FOLDER
const app1 = await common.startApp();
common.USER_DATA_ROOT_FOLDER = await app1.electron.remote.app.getPath(
'appData'
);
await common.stopApp(app1);
});

@ -0,0 +1,48 @@
/* eslint-disable prefer-destructuring */
/* eslint-disable more/no-then */
/* eslint-disable func-names */
/* eslint-disable import/no-extraneous-dependencies */
const common = require('./common');
const { afterEach, beforeEach, describe, it } = require('mocha');
describe('Link Device', function() {
let app;
let app2;
this.timeout(60000);
this.slow(15000);
beforeEach(async () => {
await common.killallElectron();
await common.stopStubSnodeServer();
const app1Props = {
mnemonic: common.TEST_MNEMONIC1,
displayName: common.TEST_DISPLAY_NAME1,
stubSnode: true,
};
const app2Props = {
stubSnode: true,
};
[app, app2] = await Promise.all([
common.startAndStub(app1Props),
common.startAndStub2(app2Props),
]);
});
afterEach(async () => {
await common.killallElectron();
await common.stopStubSnodeServer();
});
it('linkDevice: link two desktop devices', async () => {
await common.linkApp2ToApp(app, app2);
});
it('linkDevice: unlink two devices', async () => {
await common.linkApp2ToApp(app, app2);
await common.timeout(1000);
await common.triggerUnlinkApp2FromApp(app, app2);
});
});

@ -0,0 +1,151 @@
/* eslint-disable func-names */
/* eslint-disable import/no-extraneous-dependencies */
const common = require('./common');
const { afterEach, beforeEach, describe, it } = require('mocha');
const ConversationPage = require('./page-objects/conversation.page');
describe('Open groups', function() {
let app;
this.timeout(30000);
this.slow(15000);
beforeEach(async () => {
await common.killallElectron();
const login = {
mnemonic: common.TEST_MNEMONIC1,
displayName: common.TEST_DISPLAY_NAME1,
stubOpenGroups: true,
};
app = await common.startAndStub(login);
});
afterEach(async () => {
await common.killallElectron();
});
// reduce code duplication to get the initial join
async function joinOpenGroup(url, name) {
await app.client.element(ConversationPage.globeButtonSection).click();
await app.client.element(ConversationPage.joinOpenGroupButton).click();
await common.setValueWrapper(app, ConversationPage.openGroupInputUrl, url);
await app.client
.element(ConversationPage.openGroupInputUrl)
.getValue()
.should.eventually.equal(url);
await app.client.element(ConversationPage.joinOpenGroupButton).click();
// validate session loader is shown
await app.client.isExisting(ConversationPage.sessionLoader).should
.eventually.be.true;
// account for slow home internet connection delays...
await app.client.waitForExist(
ConversationPage.sessionToastJoinOpenGroupSuccess,
60 * 1000
);
// validate overlay is closed
await app.client.isExisting(ConversationPage.leftPaneOverlay).should
.eventually.be.false;
// validate open chat has been added
await app.client.waitForExist(
ConversationPage.rowOpenGroupConversationName(name),
4000
);
}
it('openGroup: works with valid open group url', async () => {
await joinOpenGroup(common.VALID_GROUP_URL, common.VALID_GROUP_NAME);
});
it('openGroup: cannot join two times the same open group', async () => {
await joinOpenGroup(common.VALID_GROUP_URL2, common.VALID_GROUP_NAME2);
// adding a second time the same open group
await app.client.element(ConversationPage.globeButtonSection).click();
await app.client.element(ConversationPage.joinOpenGroupButton).click();
await common.setValueWrapper(
app,
ConversationPage.openGroupInputUrl,
common.VALID_GROUP_URL2
);
await app.client.element(ConversationPage.joinOpenGroupButton).click();
// validate session loader is not shown
await app.client.isExisting(ConversationPage.sessionLoader).should
.eventually.be.false;
await app.client.waitForExist(
ConversationPage.sessionToastJoinOpenGroupAlreadyExist,
1 * 1000
);
// validate overlay is still opened
await app.client.isExisting(ConversationPage.leftPaneOverlay).should
.eventually.be.true;
});
it('openGroup: can send message to open group', async () => {
// join dev-chat group
await app.client.element(ConversationPage.globeButtonSection).click();
await app.client.element(ConversationPage.joinOpenGroupButton).click();
await common.setValueWrapper(
app,
ConversationPage.openGroupInputUrl,
common.VALID_GROUP_URL2
);
await app.client.element(ConversationPage.joinOpenGroupButton).click();
// wait for toast to appear
await app.client.waitForExist(
ConversationPage.sessionToastJoinOpenGroupSuccess,
30 * 1000
);
await common.timeout(5 * 1000); // wait for toast to clear
await app.client.waitForExist(
ConversationPage.rowOpenGroupConversationName(common.VALID_GROUP_NAME2),
10 * 1000
);
// generate a message containing the current timestamp so we can find it in the list of messages
const textMessage = common.generateSendMessageText();
await app.client
.element(ConversationPage.conversationButtonSection)
.click();
await app.client.isExisting(
ConversationPage.rowOpenGroupConversationName(common.VALID_GROUP_NAME2)
);
await app.client
.element(
ConversationPage.rowOpenGroupConversationName(common.VALID_GROUP_NAME2)
)
.click();
await common.setValueWrapper(
app,
ConversationPage.sendMessageTextarea,
textMessage
);
await app.client
.element(ConversationPage.sendMessageTextarea)
.getValue()
.should.eventually.equal(textMessage);
// allow some time to fetch some messages
await common.timeout(3000);
// send the message
await app.client.keys('Enter');
await common.timeout(5000);
// validate that the message has been added to the message list view
await app.client.waitForExist(
ConversationPage.existingSendMessageText(textMessage),
3 * 1000
);
// we should validate that the message has been added effectively sent
// (checking the check icon on the metadata part of the message?)
});
});

@ -0,0 +1,24 @@
module.exports = {
// generics
objWithClassAndText: (obj, classname, text) =>
`//${obj}[contains(string(), "${text}")][contains(@class, "${classname}")]`,
divRoleButtonWithText: text =>
`//div[contains(string(), "${text}")][contains(@role, "button")]`,
divRoleButtonWithTextDisabled: text =>
`//div[contains(string(), "${text}")][contains(@role, "button")][contains(@class, "disabled")]`,
divRoleButtonDangerWithText: text =>
`${module.exports.divRoleButtonWithText(text)}[contains(@class, "danger")]`,
inputWithPlaceholder: placeholder =>
`//input[contains(@placeholder, "${placeholder}")]`,
textAreaWithPlaceholder: placeholder =>
`//textarea[contains(@placeholder, "${placeholder}")]`,
byId: id => `//*[@id="${id}"]`,
divWithClass: classname => `//div[contains(@class, "${classname}")]`,
divWithClassAndText: (classname, text) =>
module.exports.objWithClassAndText('div', classname, text),
spanWithClassAndText: (classname, text) =>
module.exports.objWithClassAndText('span', classname, text),
toastWithText: text =>
module.exports.divWithClassAndText('session-toast-wrapper', text),
};

@ -0,0 +1,121 @@
const commonPage = require('./common.page');
module.exports = {
// conversation view
sessionLoader: commonPage.divWithClass('session-loader'),
leftPaneOverlay: commonPage.divWithClass('module-left-pane-overlay'),
sendMessageTextarea: commonPage.textAreaWithPlaceholder('Type your message'),
sendFriendRequestTextarea: commonPage.textAreaWithPlaceholder(
'Send your first message'
),
existingSendMessageText: textMessage =>
`//*[contains(@class, "module-message__text--outgoing")and .//span[contains(@class, "text-selectable")][contains(string(), '${textMessage}')]]`,
existingFriendRequestText: textMessage =>
`//*[contains(@class, "module-message-friend-request__container")and .//span[contains(@class, "text-selectable")][contains(string(), '${textMessage}')]]`,
existingReceivedMessageText: textMessage =>
`//*[contains(@class, "module-message__text--incoming")and .//span[contains(@class, "text-selectable")][contains(string(), '${textMessage}')]]`,
// conversations
conversationButtonSection:
'//*[contains(@class,"session-icon-button") and .//*[contains(@class, "chatBubble")]]',
retrySendButton: commonPage.divWithClassAndText(
'module-friend-request__buttonContainer--outgoing',
'Retry Send'
),
headerTitleMembers: number =>
commonPage.spanWithClassAndText(
'module-conversation-header__title-text',
`${number} members`
),
// channels
globeButtonSection:
'//*[contains(@class,"session-icon-button") and .//*[contains(@class, "globe")]]',
joinOpenGroupButton: commonPage.divRoleButtonWithText('Join Open Group'),
openGroupInputUrl: commonPage.textAreaWithPlaceholder('chat.getsession.org'),
sessionToastJoinOpenGroupSuccess: commonPage.toastWithText(
'Successfully connected to new open group server'
),
sessionToastJoinOpenGroupAlreadyExist: commonPage.toastWithText(
'You are already connected to this public channel'
),
rowOpenGroupConversationName: groupName =>
commonPage.spanWithClassAndText(
'module-conversation__user__profile-number',
groupName
),
// closed group
createClosedGroupButton: commonPage.divRoleButtonWithText(
'Create Closed Group'
),
closedGroupNameTextarea: commonPage.textAreaWithPlaceholder(
'Enter a group name'
),
createClosedGroupMemberItem: commonPage.divWithClass('session-member-item'),
createClosedGroupMemberItemSelected: commonPage.divWithClass(
'session-member-item selected'
),
validateCreationClosedGroupButton: commonPage.divRoleButtonWithText(
'Create Closed Group'
),
sessionToastGroupCreatedSuccess: commonPage.toastWithText(
'Group created successfully'
),
headerTitleGroupName: groupname =>
commonPage.spanWithClassAndText(
'module-contact-name__profile-name',
groupname
),
// contacts
contactsButtonSection:
'//*[contains(@class,"session-icon-button") and .//*[contains(@class, "users")]]',
addContactButton: commonPage.divRoleButtonWithText('Add Contact'),
sessionIDInput: commonPage.textAreaWithPlaceholder('Enter a Session ID'),
nextButton: commonPage.divRoleButtonWithText('Next'),
oneNotificationFriendRequestLeft:
'//*[contains(@class,"session-icon-button") and .//*[contains(@class, "users")] and .//*[contains(@class, "notification-count") and contains(string(), "1")] ]',
oneNotificationFriendRequestTop:
'//*[contains(@class,"notification-count hover") and contains(string(), "1")]',
friendRequestFromUser: (displayName, pubkey) =>
`//*[contains(@class,"module-left-pane__list-popup") and .//*[contains(@class, "module-conversation__user") and .//*[contains(string(), "${displayName}")] and .//*[contains(string(), "(...${pubkey.substring(
60
)})")]]]`,
acceptFriendRequestButton:
'//*[contains(@role, "button")][contains(@class, "session-button")][contains(string(), "Accept")]',
acceptedFriendRequestMessage:
'//*[contains(@class, "module-friend-request__title")][contains(string(), "Friend request accepted")]',
// settings
settingsButtonSection:
'//*[contains(@class,"session-icon-button") and .//*[contains(@class, "gear")]]',
deviceSettingsRow:
'//*[contains(@class, "left-pane-setting-category-list-item")][contains(string(), "Devices")]',
descriptionDeleteAccount: commonPage.spanWithClassAndText(
'session-confirm-main-message',
'Are you sure you want to delete your account?'
),
validateDeleteAccount: commonPage.divRoleButtonDangerWithText('OK'),
// device linking
noPairedDeviceMessage:
'//*[contains(@class, "session-settings-item__title")][contains(string(), "No linked devices")]',
linkDeviceButton: commonPage.divRoleButtonWithText('Link New Device'),
linkDeviceButtonDisabled: commonPage.divRoleButtonWithTextDisabled(
'Link New Device'
),
devicePairingDialog: '//*[contains(@class,"device-pairing-dialog")]',
qrImageDiv: commonPage.divWithClass('qr-image'),
allowPairingButton: commonPage.divRoleButtonWithText('Allow Linking'),
okButton: commonPage.divRoleButtonWithText('OK'),
devicePairedDescription: secretWords =>
commonPage.divWithClassAndText(
'session-settings-item__description',
secretWords
),
unpairDeviceButton: commonPage.divRoleButtonDangerWithText('Unlink Device'),
deleteAccountButton: commonPage.divRoleButtonDangerWithText('Delete Account'),
validateUnpairDevice: commonPage.divRoleButtonDangerWithText('Unlink'),
};

@ -0,0 +1,40 @@
const commonPage = require('./common.page');
module.exports = {
registrationTabSignIn:
'//div[contains(string(), "Sign In")][contains(@class, "session-registration__tab")][contains(@role, "tab")]',
// create new account
createSessionIDButton: commonPage.divRoleButtonWithText('Create Session ID'),
continueButton: commonPage.divRoleButtonWithText('Continue'),
textareaGeneratedPubkey:
'//textarea[contains(@class, "session-id-editable-textarea")]',
getStartedButton: commonPage.divRoleButtonWithText('Get started'),
// restore from seed
restoreFromSeedMode: commonPage.divRoleButtonWithText(
'Restore From Recovery'
),
recoveryPhraseInput: commonPage.inputWithPlaceholder('Enter Recovery Phrase'),
displayNameInput: commonPage.inputWithPlaceholder('Enter a display name'),
passwordInput: commonPage.inputWithPlaceholder('Enter password (optional)'),
continueSessionButton: commonPage.divRoleButtonWithText(
'Continue Your Session'
),
conversationListContainer: commonPage.divWithClass(
'module-conversations-list-content'
),
// device linking
linkDeviceMode: commonPage.divRoleButtonWithText(
'Link Device to Existing Session ID'
),
textareaLinkDevicePubkey: commonPage.textAreaWithPlaceholder(
'Enter other devices Session ID here'
),
linkDeviceTriggerButton: commonPage.divRoleButtonWithText('Link Device'),
toastWrapper: '//*[contains(@class,"session-toast-wrapper")]',
secretToastDescription: '//p[contains(@class, "description")]',
};

@ -0,0 +1,143 @@
/* eslint-disable prefer-arrow-callback */
/* eslint-disable func-names */
/* eslint-disable import/no-extraneous-dependencies */
const common = require('./common');
const { afterEach, beforeEach, describe, it } = require('mocha');
const RegistrationPage = require('./page-objects/registration.page');
const ConversationPage = require('./page-objects/conversation.page');
describe('Window Test and Login', function() {
let app;
this.timeout(20000);
this.slow(15000);
beforeEach(async () => {
await common.killallElectron();
});
afterEach(async () => {
await common.stopApp(app);
await common.killallElectron();
});
it('registration: opens one window', async () => {
app = await common.startAndAssureCleanedApp();
app.client.getWindowCount().should.eventually.be.equal(1);
});
it('registration: window title is correct', async () => {
app = await common.startAndAssureCleanedApp();
app.client
.getTitle()
.should.eventually.be.equal('Session - test-integration-session');
});
it('registration: can restore from seed', async () => {
app = await common.startAndAssureCleanedApp();
await app.client.element(RegistrationPage.registrationTabSignIn).click();
await app.client.element(RegistrationPage.restoreFromSeedMode).click();
await app.client
.element(RegistrationPage.recoveryPhraseInput)
.setValue(common.TEST_MNEMONIC1);
await app.client
.element(RegistrationPage.displayNameInput)
.setValue(common.TEST_DISPLAY_NAME1);
// validate fields are filled
await app.client
.element(RegistrationPage.recoveryPhraseInput)
.getValue()
.should.eventually.equal(common.TEST_MNEMONIC1);
await app.client
.element(RegistrationPage.displayNameInput)
.getValue()
.should.eventually.equal(common.TEST_DISPLAY_NAME1);
// trigger login
await app.client.element(RegistrationPage.continueSessionButton).click();
await app.client.waitForExist(
RegistrationPage.conversationListContainer,
4000
);
await common.timeout(2000);
await app.webContents
.executeJavaScript("window.storage.get('primaryDevicePubKey')")
.should.eventually.be.equal(common.TEST_PUBKEY1);
});
it('registration: can create new account', async () => {
app = await common.startAndAssureCleanedApp();
await app.client.element(RegistrationPage.createSessionIDButton).click();
// wait for the animation of generated pubkey to finish
await common.timeout(2000);
const pubkeyGenerated = await app.client
.element(RegistrationPage.textareaGeneratedPubkey)
.getValue();
// validate generated pubkey
pubkeyGenerated.should.have.lengthOf(66);
pubkeyGenerated.substr(0, 2).should.be.equal('05');
await app.client.element(RegistrationPage.continueButton).click();
await app.client.isExisting(RegistrationPage.displayNameInput).should
.eventually.be.true;
await app.client
.element(RegistrationPage.displayNameInput)
.setValue(common.TEST_DISPLAY_NAME1);
await app.client.element(RegistrationPage.getStartedButton).click();
await app.client.waitForExist(
ConversationPage.conversationButtonSection,
5000
);
await app.webContents
.executeJavaScript("window.storage.get('primaryDevicePubKey')")
.should.eventually.be.equal(pubkeyGenerated);
});
it('registration: can delete account when logged in', async () => {
// login as user1
const login = {
mnemonic: common.TEST_MNEMONIC1,
displayName: common.TEST_DISPLAY_NAME1,
stubOpenGroups: true,
};
app = await common.startAndStub(login);
await app.client.waitForExist(
RegistrationPage.conversationListContainer,
4000
);
await app.webContents
.executeJavaScript("window.storage.get('primaryDevicePubKey')")
.should.eventually.be.equal(common.TEST_PUBKEY1);
// delete account
await app.client.element(ConversationPage.settingsButtonSection).click();
await app.client.element(ConversationPage.deleteAccountButton).click();
await app.client.isExisting(ConversationPage.descriptionDeleteAccount)
.should.eventually.be.true;
// click on the modal OK button to delete the account
await app.client.element(ConversationPage.validateDeleteAccount).click();
// wait for the app restart
await common.timeout(2000);
// Spectron will loose the connection with the app during the app restart.
// We have to restart the app without altering the logged in user or anything here, just to get a valid new ref to the app.
await common.stopApp(app);
app = await common.startApp();
// validate that on app start, the registration sign in is shown
await app.client.waitForExist(RegistrationPage.registrationTabSignIn, 3000);
// validate that no pubkey are set in storage
await app.webContents
.executeJavaScript("window.storage.get('primaryDevicePubKey')")
.should.eventually.be.equal(null);
// and that the conversation list is not shown
await app.client.isExisting(RegistrationPage.conversationListContainer)
.should.eventually.be.false;
});
});

@ -0,0 +1,166 @@
/* global clearTimeout, Buffer, TextDecoder, process */
const OriginalAppDotNetApi = require('../../js/modules/loki_app_dot_net_api.js');
const sampleFeed =
'<?xml version="1.0" encoding="windows-1252"?><rss version="2.0"><channel> <title>FeedForAll Sample Feed</title></channel></rss>';
const samplesGetMessages = {
meta: { code: 200 },
data: [
{
channel_id: 1,
created_at: '2020-03-18T04:48:44.000Z',
entities: {
mentions: [],
hashtags: [],
links: [],
},
id: 3662,
machine_only: false,
num_replies: 0,
source: {},
thread_id: 3662,
reply_to: null,
text: 'hgt',
html: '<span itemscope="https://app.net/schemas/Post">hgt</span>',
annotations: [
{
type: 'network.loki.messenger.publicChat',
value: {
timestamp: 1584506921361,
sig:
'262ab113810564d7ff6474dea264e10e2143d91c004903d06d8d9fddb5b74b2c6245865544d5cf76ee16a3fca045bc028a48c51f8a290508a29b6013d014dc83',
sigver: 1,
},
},
],
user: {
id: 2448,
username:
'050cd79763303bcc251bd489a6f7da823a2b8555402b01a7959ebca550d048600f',
created_at: '2020-03-18T02:42:05.000Z',
canonical_url: null,
type: null,
timezone: null,
locale: null,
avatar_image: {
url: null,
width: null,
height: null,
is_default: false,
},
cover_image: {
url: null,
width: null,
height: null,
is_default: false,
},
counts: {
following: 0,
posts: 0,
followers: 0,
stars: 0,
},
name: 'asdf',
annotations: [],
},
},
],
};
class StubAppDotNetAPI extends OriginalAppDotNetApi {
// make a request to the server
async serverRequest(endpoint, options = {}) {
const { method } = options;
// console.warn('STUBBED ', method, ':', endpoint);
if (
endpoint === 'loki/v1/rss/messenger' ||
endpoint === 'loki/v1/rss/loki'
) {
return {
statusCode: 200,
response: {
data: sampleFeed,
},
};
}
if (endpoint === 'channels/1/messages') {
if (!method) {
return {
statusCode: 200,
response: samplesGetMessages,
};
}
return {
statusCode: 200,
response: {
data: [],
meta: {
max_id: 0,
},
},
};
}
if (
endpoint === 'loki/v1/channel/1/deletes' ||
endpoint === 'loki/v1/channel/1/moderators'
) {
return {
statusCode: 200,
response: {
data: [],
meta: {
max_id: 0,
},
},
};
}
if (endpoint === 'channels/1') {
let name = 'Unknown group';
if (this.baseServerUrl.includes('/chat-dev.lokinet.org')) {
name = 'Loki Dev Chat';
} else if (this.baseServerUrl.includes('/chat.getsession.org')) {
name = 'Session Public Chat';
}
return {
statusCode: 200,
response: {
data: {
annotations: [
{
type: 'net.patter-app.settings',
value: {
name,
},
},
],
},
},
};
}
if (endpoint === 'token') {
return {
statusCode: 200,
response: {
data: {
user: {
name: 'unknown name',
},
},
},
};
}
return {
statusCode: 200,
response: {},
};
}
}
module.exports = StubAppDotNetAPI;

@ -0,0 +1,46 @@
/* global clearTimeout, dcodeIO, Buffer, TextDecoder, process */
const nodeFetch = require('node-fetch');
class StubMessageAPI {
constructor(ourKey) {
this.ourKey = ourKey;
this.baseUrl = 'http://localhost:3000';
}
// eslint-disable-next-line no-unused-vars
async sendMessage(pubKey, data, messageTimeStamp, ttl, options = {}) {
// console.warn('STUBBED message api ', pubKey, ttl);
const post = {
method: 'POST',
};
const data64 = dcodeIO.ByteBuffer.wrap(data).toString('base64');
await nodeFetch(
`${
this.baseUrl
}/messages?pubkey=${pubKey}&timestamp=${messageTimeStamp}&data=${encodeURIComponent(
data64
)}`,
post
);
}
async startLongPolling(numConnections, stopPolling, callback) {
const ourPubkey = this.ourKey;
const get = {
method: 'GET',
};
const res = await nodeFetch(
`${this.baseUrl}/messages?pubkey=${ourPubkey}`,
get
);
const json = await res.json();
// console.warn('STUBBED polling messages ', json.messages);
callback(json.messages || []);
}
}
module.exports = StubMessageAPI;

@ -320,7 +320,6 @@
getSpellCheck: () => storage.get('spell-check', true),
setSpellCheck: value => {
storage.put('spell-check', value);
startSpellCheck();
},
addDarkOverlay: () => {
@ -419,19 +418,6 @@
}
});
const startSpellCheck = () => {
if (!window.enableSpellCheck || !window.disableSpellCheck) {
return;
}
if (window.Events.getSpellCheck()) {
window.enableSpellCheck();
} else {
window.disableSpellCheck();
}
};
startSpellCheck();
const themeSetting = window.Events.getThemeSetting();
const newThemeSetting = mapOldThemeToNew(themeSetting);
window.Events.setThemeSetting(newThemeSetting);
@ -1037,8 +1023,16 @@
};
window.toggleSpellCheck = () => {
const newValue = !window.getSettingValue('spell-check');
const currentValue = window.getSettingValue('spell-check');
// if undefined, it means 'default' so true. but we have to toggle it, so false
// if not undefined, we take the opposite
const newValue = currentValue !== undefined ? !currentValue : false;
window.Events.setSpellCheck(newValue);
window.pushToast({
description: window.i18n('spellCheckDirty'),
type: 'info',
id: 'spellCheckDirty',
});
};
window.toggleLinkPreview = () => {
@ -1340,6 +1334,18 @@
}
});
Whisper.events.on('devicePairingRequestReceivedNoListener', async () => {
window.pushToast({
title: window.i18n('devicePairingRequestReceivedNoListenerTitle'),
description: window.i18n(
'devicePairingRequestReceivedNoListenerDescription'
),
type: 'info',
id: 'pairingRequestNoListener',
shouldFade: false,
});
});
Whisper.events.on('devicePairingRequestAccepted', async (pubKey, cb) => {
try {
await getAccountManager().authoriseSecondaryDevice(pubKey);
@ -1428,6 +1434,9 @@
async function connect(firstRun) {
window.log.info('connect');
// Initialize paths for onion requests
await window.lokiSnodeAPI.buildNewOnionPaths();
// Bootstrap our online/offline detection, only the first time we connect
if (connectCount === 0 && navigator.onLine) {
window.addEventListener('offline', onOffline);
@ -2145,7 +2154,7 @@
const shouldSendReceipt =
!isError &&
data.unidentifiedDeliveryReceived &&
!data.isFriendRequest &&
!data.friendRequest &&
!isGroup;
// Send the receipt async and hope that it succeeds

@ -187,6 +187,7 @@
}
await conversation.destroyMessages();
await window.Signal.Data.removeConversation(id, {
Conversation: Whisper.Conversation,
});

@ -16,7 +16,7 @@
LokiFileServerAPI.secureRpcPubKey
);
let nextWaitSeconds = 1;
let nextWaitSeconds = 5;
const checkForUpgrades = async () => {
const result = await window.tokenlessFileServerAdnAPI.serverRequest(
'loki/v1/version/client/desktop'
@ -67,9 +67,7 @@
return res(expiredVersion);
}
log.info(
'Delaying sending checks for',
nextWaitSeconds,
's, no version yet'
`Delaying sending checks for ${nextWaitSeconds}s, no version yet`
);
setTimeout(waitForVersion, nextWaitSeconds * 1000);
return true;
@ -85,11 +83,7 @@
window.extension.expired = cb => {
if (expiredVersion === null) {
// just give it another second
log.info(
'Delaying expire banner determination for',
nextWaitSeconds,
's'
);
log.info(`Delaying expire banner determination for ${nextWaitSeconds}s`);
setTimeout(() => {
window.extension.expired(cb);
}, nextWaitSeconds * 1000);

@ -247,9 +247,12 @@
async acceptFriendRequest() {
const messages = await window.Signal.Data.getMessagesByConversation(
this.id,
{ limit: 1, MessageCollection: Whisper.MessageCollection }
{
limit: 1,
MessageCollection: Whisper.MessageCollection,
type: 'friend-request',
}
);
const lastMessageModel = messages.at(0);
if (lastMessageModel) {
lastMessageModel.acceptFriendRequest();
@ -264,7 +267,11 @@
async declineFriendRequest() {
const messages = await window.Signal.Data.getMessagesByConversation(
this.id,
{ limit: 1, MessageCollection: Whisper.MessageCollection }
{
limit: 1,
MessageCollection: Whisper.MessageCollection,
type: 'friend-request',
}
);
const lastMessageModel = messages.at(0);
@ -2767,7 +2774,9 @@
window.confirmationDialog({
title,
message,
resolve: () => ConversationController.deleteContact(this.id),
resolve: () => {
ConversationController.deleteContact(this.id);
},
});
},

@ -101,6 +101,9 @@ module.exports = {
getPrimaryDeviceFor,
getPairedDevicesFor,
getGuardNodes,
updateGuardNodes,
createOrUpdateItem,
getItemById,
getAllItems,
@ -117,6 +120,7 @@ module.exports = {
removeAllSessions,
getAllSessions,
// Doesn't look like this is used at all
getSwarmNodesByPubkey,
getConversationCount,
@ -647,6 +651,14 @@ function getSecondaryDevicesFor(primaryDevicePubKey) {
return channels.getSecondaryDevicesFor(primaryDevicePubKey);
}
function getGuardNodes() {
return channels.getGuardNodes();
}
function updateGuardNodes(nodes) {
return channels.updateGuardNodes(nodes);
}
function getPrimaryDeviceFor(secondaryDevicePubKey) {
return channels.getPrimaryDeviceFor(secondaryDevicePubKey);
}

@ -32,6 +32,8 @@ const snodeHttpsAgent = new https.Agent({
rejectUnauthorized: false,
});
const timeoutDelay = ms => new Promise(resolve => setTimeout(resolve, ms));
const sendToProxy = async (
srvPubKey,
endpoint,
@ -44,8 +46,6 @@ const sendToProxy = async (
);
return {};
}
const randSnode = await lokiSnodeAPI.getRandomSnodeAddress();
const url = `https://${randSnode.ip}:${randSnode.port}/file_proxy`;
const fetchOptions = pFetchOptions; // make lint happy
// safety issue with file server, just safer to have this
@ -61,6 +61,7 @@ const sendToProxy = async (
};
// from https://github.com/sindresorhus/is-stream/blob/master/index.js
let fileUpload = false;
if (
payloadObj.body &&
typeof payloadObj.body === 'object' &&
@ -74,8 +75,22 @@ const sendToProxy = async (
payloadObj.body = {
fileUpload: fData.toString('base64'),
};
fileUpload = true;
}
// use nodes that support more than 1mb
const randomFunc = fileUpload
? 'getRandomProxySnodeAddress'
: 'getRandomSnodeAddress';
const randSnode = await lokiSnodeAPI[randomFunc]();
if (randSnode === false) {
log.warn('proxy random snode pool is not ready, retrying 10s', endpoint);
// no nodes in the pool yet, give it some time and retry
await timeoutDelay(1000);
return sendToProxy(srvPubKey, endpoint, pFetchOptions, options);
}
const url = `https://${randSnode.ip}:${randSnode.port}/file_proxy`;
// convert our payload to binary buffer
const payloadData = Buffer.from(
dcodeIO.ByteBuffer.wrap(JSON.stringify(payloadObj)).toArrayBuffer()
@ -138,7 +153,7 @@ const sendToProxy = async (
);
// retry (hopefully with new snode)
// FIXME: max number of retries...
return sendToProxy(srvPubKey, endpoint, fetchOptions);
return sendToProxy(srvPubKey, endpoint, fetchOptions, options);
}
let response = {};
@ -242,8 +257,8 @@ const serverRequest = async (endpoint, options = {}) => {
FILESERVER_HOSTS.includes(host)
) {
mode = 'sendToProxy';
// strip trailing slash
const search = url.search ? `?${url.search}` : '';
// strip first slash
const endpointWithQS = `${url.pathname}${search}`.replace(/^\//, '');
// log.info('endpointWithQS', endpointWithQS)
({ response, txtResponse, result } = await sendToProxy(
@ -254,7 +269,7 @@ const serverRequest = async (endpoint, options = {}) => {
));
} else {
// disable check for .loki
process.env.NODE_TLS_REJECT_UNAUTHORIZED = url.host.match(/\.loki$/i)
process.env.NODE_TLS_REJECT_UNAUTHORIZED = host.match(/\.loki$/i)
? '0'
: '1';
result = await nodeFetch(url, fetchOptions);
@ -283,7 +298,7 @@ const serverRequest = async (endpoint, options = {}) => {
url
);
}
if (mode === '_sendToProxy') {
if (mode === 'sendToProxy') {
// if we can detect, certain types of failures, we can retry...
if (e.code === 'ECONNRESET') {
// retry with counter?
@ -297,7 +312,7 @@ const serverRequest = async (endpoint, options = {}) => {
if (result.status !== 200) {
if (!forceFreshToken && (!response.meta || response.meta.code === 401)) {
// retry with forcing a fresh token
return this.serverRequest(endpoint, {
return serverRequest(endpoint, {
...options,
forceFreshToken: true,
});
@ -643,7 +658,7 @@ class LokiAppDotNetServerAPI {
try {
const res = await this.proxyFetch(
`${this.baseServerUrl}/loki/v1/submit_challenge`,
new URL(`${this.baseServerUrl}/loki/v1/submit_challenge`),
fetchOptions,
{ textResponse: true }
);
@ -668,7 +683,8 @@ class LokiAppDotNetServerAPI {
}
const urlStr = urlObj.toString();
const endpoint = urlStr.replace(`${this.baseServerUrl}/`, '');
const { response, result } = await this._sendToProxy(
const { response, result } = await sendToProxy(
this.pubKey,
endpoint,
finalOptions,
options
@ -679,8 +695,8 @@ class LokiAppDotNetServerAPI {
json: () => response,
};
}
const urlStr = urlObj.toString();
if (urlStr.match(/\.loki\//)) {
const host = urlObj.host.toLowerCase();
if (host.match(/\.loki$/)) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
}
const result = nodeFetch(urlObj, fetchOptions, options);

@ -58,7 +58,13 @@ class LokiFileServerInstance {
// LokiAppDotNetAPI (base) should not know about LokiFileServer.
async establishConnection(serverUrl, options) {
// why don't we extend this?
this._server = new LokiAppDotNetAPI(this.ourKey, serverUrl);
if (process.env.USE_STUBBED_NETWORK) {
// eslint-disable-next-line global-require
const StubAppDotNetAPI = require('../../integration_test/stubs/stub_app_dot_net_api.js');
this._server = new StubAppDotNetAPI(this.ourKey, serverUrl);
} else {
this._server = new LokiAppDotNetAPI(this.ourKey, serverUrl);
}
// configure proxy
this._server.pubKey = this.pubKey;

@ -7,7 +7,6 @@ const { lokiRpc } = require('./loki_rpc');
const DEFAULT_CONNECTIONS = 3;
const MAX_ACCEPTABLE_FAILURES = 1;
const LOKI_LONGPOLL_HEADER = 'X-Loki-Long-Poll';
function sleepFor(time) {
return new Promise(resolve => {
@ -207,6 +206,18 @@ class LokiMessageAPI {
targetNode
);
// do not return true if we get false here...
if (result === false) {
log.warn(
`loki_message:::_sendToNode - Got false from ${targetNode.ip}:${
targetNode.port
}`
);
successiveFailures += 1;
// eslint-disable-next-line no-continue
continue;
}
// Make sure we aren't doing too much PoW
const currentDifficulty = window.storage.get('PoWDifficulty', null);
if (
@ -283,8 +294,6 @@ class LokiMessageAPI {
!stopPollingResult &&
successiveFailures < MAX_ACCEPTABLE_FAILURES
) {
await sleepFor(successiveFailures * 1000);
// TODO: Revert back to using snode address instead of IP
try {
// in general, I think we want exceptions to bubble up
@ -337,6 +346,9 @@ class LokiMessageAPI {
}
successiveFailures += 1;
}
// Always wait a bit as we are no longer long-polling
await sleepFor(Math.max(successiveFailures, 2) * 1000);
}
if (successiveFailures >= MAX_ACCEPTABLE_FAILURES) {
const remainingSwarmSnodes = await lokiSnodeAPI.unreachableNode(
@ -374,9 +386,6 @@ class LokiMessageAPI {
const options = {
timeout: 40000,
ourPubKey: this.ourKey,
headers: {
[LOKI_LONGPOLL_HEADER]: true,
},
};
// let exceptions bubble up
@ -390,6 +399,15 @@ class LokiMessageAPI {
nodeData
);
if (result === false) {
// make a note of it because of caller doesn't care...
log.warn(
`loki_message:::_retrieveNextMessages - lokiRpc returned false to ${
nodeData.ip
}:${nodeData.port}`
);
}
return result.messages || [];
}

@ -59,7 +59,13 @@ class LokiPublicChatFactoryAPI extends EventEmitter {
}
// after verification then we can start up all the pollers
thisServer = new LokiAppDotNetAPI(this.ourKey, serverUrl);
if (process.env.USE_STUBBED_NETWORK) {
// eslint-disable-next-line global-require
const StubAppDotNetAPI = require('../../integration_test/stubs/stub_app_dot_net_api.js');
thisServer = new StubAppDotNetAPI(this.ourKey, serverUrl);
} else {
thisServer = new LokiAppDotNetAPI(this.ourKey, serverUrl);
}
const gotToken = await thisServer.getOrRefreshServerToken();
if (!gotToken) {

@ -1,45 +1,231 @@
/* global log, libloki, textsecure, getStoragePubKey, lokiSnodeAPI, StringView,
libsignal, window, TextDecoder, TextEncoder, dcodeIO, process */
libsignal, window, TextDecoder, TextEncoder, dcodeIO, process, crypto */
const nodeFetch = require('node-fetch');
const https = require('https');
const { parse } = require('url');
const snodeHttpsAgent = new https.Agent({
rejectUnauthorized: false,
});
const LOKI_EPHEMKEY_HEADER = 'X-Loki-EphemKey';
const endpointBase = '/storage_rpc/v1';
const decryptResponse = async (response, address) => {
let plaintext = false;
try {
const ciphertext = await response.text();
plaintext = await libloki.crypto.snodeCipher.decrypt(address, ciphertext);
const result = plaintext === '' ? {} : JSON.parse(plaintext);
return result;
} catch (e) {
// Request index for debugging
let onionReqIdx = 0;
const timeoutDelay = ms => new Promise(resolve => setTimeout(resolve, ms));
const encryptForNode = async (node, payload) => {
const textEncoder = new TextEncoder();
const plaintext = textEncoder.encode(payload);
const ephemeral = libloki.crypto.generateEphemeralKeyPair();
const snPubkey = StringView.hexToArrayBuffer(node.pubkey_x25519);
const ephemeralSecret = libsignal.Curve.calculateAgreement(
snPubkey,
ephemeral.privKey
);
const salt = window.Signal.Crypto.bytesFromString('LOKI');
const key = await crypto.subtle.importKey(
'raw',
salt,
{ name: 'HMAC', hash: { name: 'SHA-256' } },
false,
['sign']
);
const symmetricKey = await crypto.subtle.sign(
{ name: 'HMAC', hash: 'SHA-256' },
key,
ephemeralSecret
);
const ciphertext = await window.libloki.crypto.EncryptGCM(
symmetricKey,
plaintext
);
return { ciphertext, symmetricKey, ephemeral_key: ephemeral.pubKey };
};
// Returns the actual ciphertext, symmetric key that will be used
// for decryption, and an ephemeral_key to send to the next hop
const encryptForDestination = async (node, payload) => {
// Do we still need "headers"?
const reqStr = JSON.stringify({ body: payload, headers: '' });
return encryptForNode(node, reqStr);
};
// `ctx` holds info used by `node` to relay further
const encryptForRelay = async (node, nextNode, ctx) => {
const payload = ctx.ciphertext;
const reqJson = {
ciphertext: dcodeIO.ByteBuffer.wrap(payload).toString('base64'),
ephemeral_key: StringView.arrayBufferToHex(ctx.ephemeral_key),
destination: nextNode.pubkey_ed25519,
};
const reqStr = JSON.stringify(reqJson);
return encryptForNode(node, reqStr);
};
const BAD_PATH = 'bad_path';
// May return false BAD_PATH, indicating that we should try a new
const sendOnionRequest = async (reqIdx, nodePath, targetNode, plaintext) => {
log.debug('Sending an onion request');
const ctx1 = await encryptForDestination(targetNode, plaintext);
const ctx2 = await encryptForRelay(nodePath[2], targetNode, ctx1);
const ctx3 = await encryptForRelay(nodePath[1], nodePath[2], ctx2);
const ctx4 = await encryptForRelay(nodePath[0], nodePath[1], ctx3);
const ciphertextBase64 = dcodeIO.ByteBuffer.wrap(ctx4.ciphertext).toString(
'base64'
);
const payload = {
ciphertext: ciphertextBase64,
ephemeral_key: StringView.arrayBufferToHex(ctx4.ephemeral_key),
};
const fetchOptions = {
method: 'POST',
body: JSON.stringify(payload),
};
const url = `https://${nodePath[0].ip}:${nodePath[0].port}/onion_req`;
// we only proxy to snodes...
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
const response = await nodeFetch(url, fetchOptions);
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
return processOnionResponse(reqIdx, response, ctx1.symmetricKey, true);
};
// Process a response as it arrives from `nodeFetch`, handling
// http errors and attempting to decrypt the body with `sharedKey`
const processOnionResponse = async (reqIdx, response, sharedKey, useAesGcm) => {
log.debug(`(${reqIdx}) [path] processing onion response`);
// detect SNode is not ready (not in swarm; not done syncing)
if (response.status === 503) {
log.warn(`(${reqIdx}) [path] Got 503: snode not ready`);
return BAD_PATH;
}
if (response.status === 504) {
log.warn(`(${reqIdx}) [path] Got 504: Gateway timeout`);
return BAD_PATH;
}
if (response.status === 404) {
// Why would we get this error on testnet?
log.warn(`(${reqIdx}) [path] Got 404: Gateway timeout`);
return BAD_PATH;
}
if (response.status !== 200) {
log.warn(
`Could not decrypt response [${plaintext}] from [${address}],`,
e.code,
e.message
`(${reqIdx}) [path] fetch unhandled error code: ${response.status}`
);
return false;
}
return {};
};
const timeoutDelay = ms => new Promise(resolve => setTimeout(resolve, ms));
const ciphertext = await response.text();
if (!ciphertext) {
log.warn(`(${reqIdx}) [path]: Target node return empty ciphertext`);
return false;
}
let plaintext;
let ciphertextBuffer;
try {
ciphertextBuffer = dcodeIO.ByteBuffer.wrap(
ciphertext,
'base64'
).toArrayBuffer();
const decryptFn = useAesGcm
? window.libloki.crypto.DecryptGCM
: window.libloki.crypto.DHDecrypt;
const plaintextBuffer = await decryptFn(sharedKey, ciphertextBuffer);
const textDecoder = new TextDecoder();
plaintext = textDecoder.decode(plaintextBuffer);
} catch (e) {
log.error(`(${reqIdx}) [path] decode error`);
if (ciphertextBuffer) {
log.error(`(${reqIdx}) [path] ciphertextBuffer`, ciphertextBuffer);
}
return false;
}
try {
const jsonRes = JSON.parse(plaintext);
// emulate nodeFetch response...
jsonRes.json = () => {
try {
const res = JSON.parse(jsonRes.body);
return res;
} catch (e) {
log.error(`(${reqIdx}) [path] parse error json: `, jsonRes.body);
}
return false;
};
return jsonRes;
} catch (e) {
log.error('[path] parse error', e.code, e.message, `json:`, plaintext);
return false;
}
};
const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
const randSnode = await lokiSnodeAPI.getRandomSnodeAddress();
const _ = window.Lodash;
let snodePool = await lokiSnodeAPI.getRandomSnodePool();
if (snodePool.length < 2) {
log.error(
'lokiRpc::sendToProxy - Not enough service nodes for a proxy request, only have:',
snodePool.length,
'snode, attempting refresh'
);
await lokiSnodeAPI.refreshRandomPool();
snodePool = await lokiSnodeAPI.getRandomSnodePool();
if (snodePool.length < 2) {
log.error(
'lokiRpc::sendToProxy - Not enough service nodes for a proxy request, only have:',
snodePool.length,
'failing'
);
return false;
}
}
// Making sure the proxy node is not the same as the target node:
const snodePoolSafe = _.without(
snodePool,
_.find(snodePool, { pubkey_ed25519: targetNode.pubkey_ed25519 })
);
const randSnode = window.Lodash.sample(snodePoolSafe);
// Don't allow arbitrary URLs, only snodes and loki servers
const url = `https://${randSnode.ip}:${randSnode.port}/proxy`;
const snPubkeyHex = StringView.hexToArrayBuffer(targetNode.pubkey_x25519);
const myKeys = window.libloki.crypto.snodeCipher._ephemeralKeyPair;
const myKeys = window.libloki.crypto.generateEphemeralKeyPair();
const symmetricKey = libsignal.Curve.calculateAgreement(
snPubkeyHex,
@ -75,8 +261,9 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
// we got a ton of randomPool nodes, let's just not worry about this one
lokiSnodeAPI.markRandomNodeUnreachable(randSnode);
const randomPoolRemainingCount = lokiSnodeAPI.getRandomPoolLength();
const ciphertext = await response.text();
log.warn(
`lokiRpc sendToProxy`,
`lokiRpc:::sendToProxy -`,
`snode ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${
targetNode.port
}`,
@ -92,16 +279,13 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
// detect SNode is not ready (not in swarm; not done syncing)
if (response.status === 503 || response.status === 500) {
const ciphertext = await response.text();
// we shouldn't do these,
// it's seems to be not the random node that's always bad
// but the target node
// we got a ton of randomPool nodes, let's just not worry about this one
// this doesn't mean the random node is bad, it could be the target node
// but we got a ton of randomPool nodes, let's just not worry about this one
lokiSnodeAPI.markRandomNodeUnreachable(randSnode);
const randomPoolRemainingCount = lokiSnodeAPI.getRandomPoolLength();
const ciphertext = await response.text();
log.warn(
`lokiRpc sendToProxy`,
`lokiRpc:::sendToProxy -`,
`snode ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${
targetNode.port
}`,
@ -118,7 +302,11 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
// it's likely a net problem or an actual problem on the target node
// lets mark the target node bad for now
// we'll just rotate it back in if it's a net problem
log.warn(`Failing ${targetNode.ip}:${targetNode.port} after 5 retries`);
log.warn(
`lokiRpc:::sendToProxy - Failing ${targetNode.ip}:${
targetNode.port
} after 5 retries`
);
if (options.ourPubKey) {
lokiSnodeAPI.unreachableNode(options.ourPubKey, targetNode);
}
@ -141,7 +329,7 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
if (response.status !== 200) {
// let us know we need to create handlers for new unhandled codes
log.warn(
'lokiRpc sendToProxy fetch non-200 statusCode',
'lokiRpc:::sendToProxy - fetch non-200 statusCode',
response.status,
`from snode ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${
targetNode.port
@ -155,7 +343,11 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
// avoid base64 decode failure
// usually a 500 but not always
// could it be a timeout?
log.warn('Server did not return any data for', options, targetNode);
log.warn(
'lokiRpc:::sendToProxy - Server did not return any data for',
options,
targetNode
);
return false;
}
@ -176,7 +368,7 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
plaintext = textDecoder.decode(plaintextBuffer);
} catch (e) {
log.error(
'lokiRpc sendToProxy decode error',
'lokiRpc:::sendToProxy - decode error',
e.code,
e.message,
`from ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${
@ -198,7 +390,7 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
return JSON.parse(jsonRes.body);
} catch (e) {
log.error(
'lokiRpc sendToProxy parse error',
'lokiRpc:::sendToProxy - parse error',
e.code,
e.message,
`from ${randSnode.ip}:${randSnode.port} json:`,
@ -209,7 +401,7 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
};
if (retryNumber) {
log.info(
`lokiRpc sendToProxy request succeeded,`,
`lokiRpc:::sendToProxy - request succeeded,`,
`snode ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${
targetNode.port
}`,
@ -219,7 +411,7 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
return jsonRes;
} catch (e) {
log.error(
'lokiRpc sendToProxy parse error',
'lokiRpc:::sendToProxy - parse error',
e.code,
e.message,
`from ${randSnode.ip}:${randSnode.port} json:`,
@ -230,31 +422,11 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
};
// A small wrapper around node-fetch which deserializes response
// returns nodeFetch response or false
const lokiFetch = async (url, options = {}, targetNode = null) => {
const timeout = options.timeout || 10000;
const method = options.method || 'GET';
const address = parse(url).hostname;
// const doEncryptChannel = address.endsWith('.snode');
const doEncryptChannel = false; // ENCRYPTION DISABLED
if (doEncryptChannel) {
try {
// eslint-disable-next-line no-param-reassign
options.body = await libloki.crypto.snodeCipher.encrypt(
address,
options.body
);
// eslint-disable-next-line no-param-reassign
options.headers = {
...options.headers,
'Content-Type': 'text/plain',
[LOKI_EPHEMKEY_HEADER]: libloki.crypto.snodeCipher.getChannelPublicKeyHex(),
};
} catch (e) {
log.warn(`Could not encrypt channel for ${address}: `, e);
}
}
const fetchOptions = {
...options,
timeout,
@ -262,8 +434,61 @@ const lokiFetch = async (url, options = {}, targetNode = null) => {
};
try {
// Absence of targetNode indicates that we want a direct connection
// (e.g. to connect to a seed node for the first time)
if (window.lokiFeatureFlags.useOnionRequests && targetNode) {
// Loop until the result is not BAD_PATH
// eslint-disable-next-line no-constant-condition
while (true) {
// Get a path excluding `targetNode`:
// eslint-disable-next-line no-await-in-loop
const path = await lokiSnodeAPI.getOnionPath(targetNode);
const thisIdx = onionReqIdx;
onionReqIdx += 1;
log.debug(
`(${thisIdx}) using path ${path[0].ip}:${path[0].port} -> ${
path[1].ip
}:${path[1].port} -> ${path[2].ip}:${path[2].port} => ${
targetNode.ip
}:${targetNode.port}`
);
// eslint-disable-next-line no-await-in-loop
const result = await sendOnionRequest(
thisIdx,
path,
targetNode,
fetchOptions.body
);
if (result === BAD_PATH) {
log.error('[path] Error on the path');
lokiSnodeAPI.markPathAsBad(path);
} else {
return result ? result.json() : false;
}
}
}
if (window.lokiFeatureFlags.useSnodeProxy && targetNode) {
const result = await sendToProxy(fetchOptions, targetNode);
if (result === false) {
// should we retry?
log.warn(`lokiRpc:::lokiFetch - sendToProxy returned false`);
// one case is:
// snodePool didn't have enough
// even after a refresh
// likely a network disconnect?
// but not all cases...
/*
log.warn(
'lokiRpc:::lokiFetch - useSnodeProxy failure, could not refresh randomPool, offline?'
);
*/
// pass the false value up
return false;
}
// if not result, maybe we should throw??
return result ? result.json() : {};
}
@ -273,7 +498,7 @@ const lokiFetch = async (url, options = {}, targetNode = null) => {
fetchOptions.agent = snodeHttpsAgent;
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
} else {
log.info('lokiRpc http communication', url);
log.info('lokirpc:::lokiFetch - http communication', url);
}
const response = await nodeFetch(url, fetchOptions);
// restore TLS checking
@ -282,22 +507,14 @@ const lokiFetch = async (url, options = {}, targetNode = null) => {
let result;
// Wrong swarm
if (response.status === 421) {
if (doEncryptChannel) {
result = decryptResponse(response, address);
} else {
result = await response.json();
}
result = await response.json();
const newSwarm = result.snodes ? result.snodes : [];
throw new textsecure.WrongSwarmError(newSwarm);
}
// Wrong PoW difficulty
if (response.status === 432) {
if (doEncryptChannel) {
result = decryptResponse(response, address);
} else {
result = await response.json();
}
result = await response.json();
const { difficulty } = result;
throw new textsecure.WrongDifficultyError(difficulty);
}
@ -316,8 +533,6 @@ const lokiFetch = async (url, options = {}, targetNode = null) => {
result = await response.json();
} else if (options.responseType === 'arraybuffer') {
result = await response.buffer();
} else if (doEncryptChannel) {
result = decryptResponse(response, address);
} else {
result = await response.text();
}
@ -332,6 +547,7 @@ const lokiFetch = async (url, options = {}, targetNode = null) => {
};
// Wrapper for a JSON RPC request
// Annoyngly, this is used for Lokid requests too
const lokiRpc = (
address,
port,

@ -1,13 +1,21 @@
/* eslint-disable class-methods-use-this */
/* global window, ConversationController, _, log, clearTimeout */
/* global window, textsecure, ConversationController, _, log, clearTimeout, process, Buffer, StringView, dcodeIO */
const is = require('@sindresorhus/is');
const { lokiRpc } = require('./loki_rpc');
const https = require('https');
const nodeFetch = require('node-fetch');
const semver = require('semver');
const snodeHttpsAgent = new https.Agent({
rejectUnauthorized: false,
});
const RANDOM_SNODES_TO_USE_FOR_PUBKEY_SWARM = 3;
const RANDOM_SNODES_POOL_SIZE = 1024;
const SEED_NODE_RETRIES = 3;
const timeoutDelay = ms => new Promise(resolve => setTimeout(resolve, ms));
class LokiSnodeAPI {
constructor({ serverUrl, localUrl }) {
if (!is.string(serverUrl)) {
@ -18,6 +26,251 @@ class LokiSnodeAPI {
this.randomSnodePool = [];
this.swarmsPendingReplenish = {};
this.refreshRandomPoolPromise = false;
this.versionPools = {};
this.versionMap = {}; // reverse version look up
this.versionsRetrieved = false; // to mark when it's done getting versions
this.onionPaths = [];
this.guardNodes = [];
}
async getRandomSnodePool() {
if (this.randomSnodePool.length === 0) {
await this.refreshRandomPool();
}
return this.randomSnodePool;
}
getRandomPoolLength() {
return this.randomSnodePool.length;
}
async testGuardNode(snode) {
log.info('Testing a candidate guard node ', snode);
// Send a post request and make sure it is OK
const endpoint = '/storage_rpc/v1';
const url = `https://${snode.ip}:${snode.port}${endpoint}`;
const ourPK = textsecure.storage.user.getNumber();
const pubKey = window.getStoragePubKey(ourPK); // truncate if testnet
const method = 'get_snodes_for_pubkey';
const params = { pubKey };
const body = {
jsonrpc: '2.0',
id: '0',
method,
params,
};
const fetchOptions = {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
timeout: 10000, // 10s, we want a smaller timeout for testing
};
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
let response;
try {
response = await nodeFetch(url, fetchOptions);
} catch (e) {
if (e.type === 'request-timeout') {
log.warn(`test timeout for node,`, snode);
}
return false;
} finally {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
}
if (!response.ok) {
log.info(`Node failed the guard test:`, snode);
}
return response.ok;
}
async selectGuardNodes() {
const _ = window.Lodash;
let nodePool = await this.getRandomSnodePool();
if (nodePool.length === 0) {
log.error(`Could not select guarn nodes: node pool is empty`);
return [];
}
let shuffled = _.shuffle(nodePool);
let guardNodes = [];
const DESIRED_GUARD_COUNT = 3;
if (shuffled.length < DESIRED_GUARD_COUNT) {
log.error(
`Could not select guarn nodes: node pool is not big enough, pool size ${
shuffled.length
}, need ${DESIRED_GUARD_COUNT}, attempting to refresh randomPool`
);
await this.refreshRandomPool();
nodePool = await this.getRandomSnodePool();
shuffled = _.shuffle(nodePool);
if (shuffled.length < DESIRED_GUARD_COUNT) {
log.error(
`Could not select guarn nodes: node pool is not big enough, pool size ${
shuffled.length
}, need ${DESIRED_GUARD_COUNT}, failing...`
);
return [];
}
}
// The use of await inside while is intentional:
// we only want to repeat if the await fails
// eslint-disable-next-line-no-await-in-loop
while (guardNodes.length < 3) {
if (shuffled.length < DESIRED_GUARD_COUNT) {
log.error(`Not enought nodes in the pool`);
break;
}
const candidateNodes = shuffled.splice(0, DESIRED_GUARD_COUNT);
// Test all three nodes at once
// eslint-disable-next-line no-await-in-loop
const idxOk = await Promise.all(
candidateNodes.map(n => this.testGuardNode(n))
);
const goodNodes = _.zip(idxOk, candidateNodes)
.filter(x => x[0])
.map(x => x[1]);
guardNodes = _.concat(guardNodes, goodNodes);
}
if (guardNodes.length < DESIRED_GUARD_COUNT) {
log.error(
`COULD NOT get enough guard nodes, only have: ${guardNodes.length}`
);
}
log.info('new guard nodes: ', guardNodes);
const edKeys = guardNodes.map(n => n.pubkey_ed25519);
await window.libloki.storage.updateGuardNodes(edKeys);
return guardNodes;
}
async getOnionPath(toExclude = null) {
const _ = window.Lodash;
const goodPaths = this.onionPaths.filter(x => !x.bad);
if (goodPaths.length < 2) {
log.error(
`Must have at least 2 good onion paths, actual: ${goodPaths.length}`
);
await this.buildNewOnionPaths();
}
const paths = _.shuffle(goodPaths);
if (!toExclude) {
return paths[0];
}
// Select a path that doesn't contain `toExclude`
const otherPaths = paths.filter(
path =>
!_.some(path, node => node.pubkey_ed25519 === toExclude.pubkey_ed25519)
);
if (otherPaths.length === 0) {
// This should never happen!
throw new Error('No onion paths available after filtering');
}
return otherPaths[0].path;
}
async markPathAsBad(path) {
this.onionPaths.forEach(p => {
if (p.path === path) {
// eslint-disable-next-line no-param-reassign
p.bad = true;
}
});
}
async buildNewOnionPaths() {
// Note: this function may be called concurrently, so
// might consider blocking the other calls
const _ = window.Lodash;
log.info('building new onion paths');
const allNodes = await this.getRandomSnodePool();
if (this.guardNodes.length === 0) {
// Not cached, load from DB
const nodes = await window.libloki.storage.getGuardNodes();
if (nodes.length === 0) {
log.warn('no guard nodes in DB. Will be selecting new guards nodes...');
} else {
// We only store the nodes' keys, need to find full entries:
const edKeys = nodes.map(x => x.ed25519PubKey);
this.guardNodes = allNodes.filter(
x => edKeys.indexOf(x.pubkey_ed25519) !== -1
);
if (this.guardNodes.length < edKeys.length) {
log.warn(
`could not find some guard nodes: ${this.guardNodes.length}/${
edKeys.length
} left`
);
}
}
// If guard nodes is still empty (the old nodes are now invalid), select new ones:
if (this.guardNodes.length === 0) {
this.guardNodes = await this.selectGuardNodes();
}
}
// TODO: select one guard node and 2 other nodes randomly
let otherNodes = _.difference(allNodes, this.guardNodes);
if (otherNodes.length < 2) {
log.error('Too few nodes to build an onion path!');
return;
}
otherNodes = _.shuffle(otherNodes);
const guards = _.shuffle(this.guardNodes);
// Create path for every guard node:
// Each path needs 2 nodes in addition to the guard node:
const maxPath = Math.floor(Math.min(guards.length, otherNodes.length / 2));
// TODO: might want to keep some of the existing paths
this.onionPaths = [];
for (let i = 0; i < maxPath; i += 1) {
const path = [guards[i], otherNodes[i * 2], otherNodes[i * 2 + 1]];
this.onionPaths.push({ path, bad: false });
}
log.info('Built onion paths: ', this.onionPaths);
}
async getRandomSnodeAddress() {
@ -29,11 +282,95 @@ class LokiSnodeAPI {
if (this.randomSnodePool.length === 0) {
throw new window.textsecure.SeedNodeError('Invalid seed node response');
}
// FIXME: _.sample?
return this.randomSnodePool[
Math.floor(Math.random() * this.randomSnodePool.length)
];
}
async getNodesMinVersion(minVersion) {
const _ = window.Lodash;
return _.flatten(
_.entries(this.versionPools)
.filter(v => semver.gte(v[0], minVersion))
.map(v => v[1])
);
}
// use nodes that support more than 1mb
async getRandomProxySnodeAddress() {
/* resolve random snode */
if (this.randomSnodePool.length === 0) {
// allow exceptions to pass through upwards
await this.refreshRandomPool();
}
if (this.randomSnodePool.length === 0) {
throw new window.textsecure.SeedNodeError('Invalid seed node response');
}
const goodVersions = Object.keys(this.versionPools).filter(version =>
semver.gt(version, '2.0.1')
);
if (!goodVersions.length) {
return false;
}
// FIXME: _.sample?
const goodVersion =
goodVersions[Math.floor(Math.random() * goodVersions.length)];
const pool = this.versionPools[goodVersion];
// FIXME: _.sample?
return pool[Math.floor(Math.random() * pool.length)];
}
// WARNING: this leaks our IP to all snodes but with no other identifying information
// except that a client started up or ran out of random pool snodes
async getVersion(node) {
try {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
const result = await nodeFetch(
`https://${node.ip}:${node.port}/get_stats/v1`,
{ agent: snodeHttpsAgent }
);
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
const data = await result.json();
if (data.version) {
if (this.versionPools[data.version] === undefined) {
this.versionPools[data.version] = [node];
} else {
this.versionPools[data.version].push(node);
}
// set up reverse mapping for removal lookup
this.versionMap[`${node.ip}:${node.port}`] = data.version;
}
} catch (e) {
// ECONNREFUSED likely means it's just offline...
// ECONNRESET seems to retry and fail as ECONNREFUSED (so likely a node going offline)
// ETIMEDOUT not sure what to do about these
// retry for now but maybe we should be marking bad...
if (e.code === 'ECONNREFUSED') {
this.markRandomNodeUnreachable(node, { versionPoolFailure: true });
const randomNodesLeft = this.getRandomPoolLength();
// clean up these error messages to be a little neater
log.warn(
`loki_snode:::getVersion - ${node.ip}:${
node.port
} is offline, removing, leaving ${randomNodesLeft} in the randomPool`
);
} else {
// mostly ECONNRESETs
// ENOTFOUND could mean no internet or hiccup
log.warn(
'loki_snode:::getVersion - Error',
e.code,
e.message,
`on ${node.ip}:${node.port} retrying in 1s`
);
await timeoutDelay(1000);
await this.getVersion(node);
}
}
}
async refreshRandomPool(seedNodes = [...window.seedNodeList]) {
// if currently not in progress
if (this.refreshRandomPoolPromise === false) {
@ -42,8 +379,10 @@ class LokiSnodeAPI {
let timeoutTimer = null;
// private retry container
const trySeedNode = async (consecutiveErrors = 0) => {
// Removed limit until there is a way to get snode info
// for individual nodes (needed for guard nodes); this way
// we get all active nodes
const params = {
limit: RANDOM_SNODES_POOL_SIZE,
active_only: true,
fields: {
public_ip: true,
@ -73,6 +412,12 @@ class LokiSnodeAPI {
snodes = response.result.service_node_states.filter(
snode => snode.public_ip !== '0.0.0.0'
);
// make sure order of the list is random, so we get version in a non-deterministic way
snodes = _.shuffle(snodes);
// commit changes to be live
// we'll update the version (in case they upgrade) every cycle
this.versionPools = {};
this.versionsRetrieved = false;
this.randomSnodePool = snodes.map(snode => ({
ip: snode.public_ip,
port: snode.storage_port,
@ -90,7 +435,35 @@ class LokiSnodeAPI {
clearTimeout(timeoutTimer);
timeoutTimer = null;
}
// start polling versions
resolve();
// now get version for all snodes
// also acts an early online test/purge of bad nodes
let c = 0;
const verionStart = Date.now();
const t = this.randomSnodePool.length;
const noticeEvery = parseInt(t / 10, 10);
// eslint-disable-next-line no-restricted-syntax
for (const node of this.randomSnodePool) {
c += 1;
// eslint-disable-next-line no-await-in-loop
await this.getVersion(node);
if (c % noticeEvery === 0) {
// give stats
const diff = Date.now() - verionStart;
log.info(
`${c}/${t} pool version status update, has taken ${diff.toLocaleString()}ms`
);
Object.keys(this.versionPools).forEach(version => {
const nodes = this.versionPools[version].length;
log.info(
`version ${version} has ${nodes.toLocaleString()} snodes`
);
});
}
}
log.info('Versions retrieved from network!');
this.versionsRetrieved = true;
} catch (e) {
log.warn(
'loki_snodes:::refreshRandomPoolPromise - error',
@ -145,6 +518,7 @@ class LokiSnodeAPI {
throw new window.textsecure.SeedNodeError('Failed to contact seed node');
}
log.info('loki_snodes:::refreshRandomPoolPromise - RESOLVED');
delete this.refreshRandomPoolPromise; // clear any lock
}
// unreachableNode.url is like 9hrje1bymy7hu6nmtjme9idyu3rm8gr3mkstakjyuw1997t7w4ny.snode
@ -180,15 +554,45 @@ class LokiSnodeAPI {
return filteredNodes;
}
markRandomNodeUnreachable(snode) {
this.randomSnodePool = _.without(
this.randomSnodePool,
_.find(this.randomSnodePool, { ip: snode.ip, port: snode.port })
);
}
getRandomPoolLength() {
return this.randomSnodePool.length;
markRandomNodeUnreachable(snode, options = {}) {
// avoid retries when we can't get the version because they're offline
if (!options.versionPoolFailure) {
const snodeVersion = this.versionMap[`${snode.ip}:${snode.port}`];
if (this.versionPools[snodeVersion]) {
this.versionPools[snodeVersion] = _.without(
this.versionPools[snodeVersion],
snode
);
} else {
if (snodeVersion) {
// reverse map (versionMap) is out of sync with versionPools
log.error(
'loki_snode:::markRandomNodeUnreachable - No snodes for version',
snodeVersion,
'retrying in 10s'
);
} else {
// we don't know our version yet
// and if we're offline, we'll likely not get it until it restarts if it does...
log.warn(
'loki_snode:::markRandomNodeUnreachable - No version for snode',
`${snode.ip}:${snode.port}`,
'retrying in 10s'
);
}
// make sure we don't retry past 15 mins (10s * 100 ~ 1000s)
const retries = options.retries || 0;
if (retries < 100) {
setTimeout(() => {
this.markRandomNodeUnreachable(snode, {
...options,
retries: retries + 1,
});
}, 10000);
}
}
}
this.randomSnodePool = _.without(this.randomSnodePool, snode);
}
async updateLastHash(snode, hash, expiresAt) {
@ -249,6 +653,98 @@ class LokiSnodeAPI {
return newSwarmNodes;
}
// helper function
async _requestLnsMapping(node, nameHash) {
log.debug('[lns] lns requests to {}:{}', node.ip, node.port);
try {
// Hm, in case of proxy/onion routing we
// are not even using ip/port...
return lokiRpc(
`https://${node.ip}`,
node.port,
'get_lns_mapping',
{
name_hash: nameHash,
},
{},
'/storage_rpc/v1',
node
);
} catch (e) {
log.warn('exception caught making lns requests to a node', node, e);
return false;
}
}
async getLnsMapping(lnsName) {
const _ = window.Lodash;
const input = Buffer.from(lnsName);
const output = await window.blake2b(input);
const nameHash = dcodeIO.ByteBuffer.wrap(output).toString('base64');
// Get nodes capable of doing LNS
let lnsNodes = await this.getNodesMinVersion('2.0.3');
lnsNodes = _.shuffle(lnsNodes);
// Loop until 3 confirmations
// We don't trust any single node, so we accumulate
// answers here and select a dominating answer
const allResults = [];
let ciphertextHex = null;
while (!ciphertextHex) {
if (lnsNodes.length < 3) {
log.error('Not enough nodes for lns lookup');
return false;
}
// extract 3 and make requests in parallel
const nodes = lnsNodes.splice(0, 3);
// eslint-disable-next-line no-await-in-loop
const results = await Promise.all(
nodes.map(node => this._requestLnsMapping(node, nameHash))
);
results.forEach(res => {
if (
res &&
res.result &&
res.result.status === 'OK' &&
res.result.entries &&
res.result.entries.length > 0
) {
allResults.push(results[0].result.entries[0].encrypted_value);
}
});
const [winner, count] = _.maxBy(
_.entries(_.countBy(allResults)),
x => x[1]
);
if (count >= 3) {
// eslint-disable-next-lint prefer-destructuring
ciphertextHex = winner;
}
}
const ciphertext = new Uint8Array(
StringView.hexToArrayBuffer(ciphertextHex)
);
const res = await window.decryptLnsEntry(lnsName, ciphertext);
const pubkey = StringView.arrayBufferToHex(res);
return pubkey;
}
async getSnodesForPubkey(snode, pubKey) {
try {
const result = await lokiRpc(

@ -1,180 +0,0 @@
/* global require, process, _ */
/* eslint-disable strict */
const electron = require('electron');
const osLocale = require('os-locale');
const os = require('os');
const semver = require('semver');
const spellchecker = require('spellchecker');
const { remote, webFrame } = electron;
// `remote.require` since `Menu` is a main-process module.
const buildEditorContextMenu = remote.require('electron-editor-context-menu');
const EN_VARIANT = /^en/;
// Prevent the spellchecker from showing contractions as errors.
const ENGLISH_SKIP_WORDS = [
'ain',
'couldn',
'didn',
'doesn',
'hadn',
'hasn',
'mightn',
'mustn',
'needn',
'oughtn',
'shan',
'shouldn',
'wasn',
'weren',
'wouldn',
];
function setupLinux(locale) {
if (process.env.HUNSPELL_DICTIONARIES || locale !== 'en_US') {
// apt-get install hunspell-<locale> can be run for easy access
// to other dictionaries
const location = process.env.HUNSPELL_DICTIONARIES || '/usr/share/hunspell';
window.log.info(
'Detected Linux. Setting up spell check with locale',
locale,
'and dictionary location',
location
);
spellchecker.setDictionary(locale, location);
} else {
window.log.info(
'Detected Linux. Using default en_US spell check dictionary'
);
}
}
function setupWin7AndEarlier(locale) {
if (process.env.HUNSPELL_DICTIONARIES || locale !== 'en_US') {
const location = process.env.HUNSPELL_DICTIONARIES;
window.log.info(
'Detected Windows 7 or below. Setting up spell-check with locale',
locale,
'and dictionary location',
location
);
spellchecker.setDictionary(locale, location);
} else {
window.log.info(
'Detected Windows 7 or below. Using default en_US spell check dictionary'
);
}
}
// We load locale this way and not via app.getLocale() because this call returns
// 'es_ES' and not just 'es.' And hunspell requires the fully-qualified locale.
const locale = osLocale.sync().replace('-', '_');
// The LANG environment variable is how node spellchecker finds its default language:
// https://github.com/atom/node-spellchecker/blob/59d2d5eee5785c4b34e9669cd5d987181d17c098/lib/spellchecker.js#L29
if (!process.env.LANG) {
process.env.LANG = locale;
}
if (process.platform === 'linux') {
setupLinux(locale);
} else if (process.platform === 'windows' && semver.lt(os.release(), '8.0.0')) {
setupWin7AndEarlier(locale);
} else {
// OSX and Windows 8+ have OS-level spellcheck APIs
window.log.info(
'Using OS-level spell check API with locale',
process.env.LANG
);
}
const simpleChecker = {
spellCheck(text) {
return !this.isMisspelled(text);
},
isMisspelled(text) {
const misspelled = spellchecker.isMisspelled(text);
// The idea is to make this as fast as possible. For the many, many calls which
// don't result in the red squiggly, we minimize the number of checks.
if (!misspelled) {
return false;
}
// Only if we think we've found an error do we check the locale and skip list.
if (locale.match(EN_VARIANT) && _.contains(ENGLISH_SKIP_WORDS, text)) {
return false;
}
return true;
},
getSuggestions(text) {
return spellchecker.getCorrectionsForMisspelling(text);
},
add(text) {
spellchecker.add(text);
},
};
const dummyChecker = {
spellCheck() {
return true;
},
isMisspelled() {
return false;
},
getSuggestions() {
return [];
},
add() {
// nothing
},
};
window.spellChecker = simpleChecker;
window.disableSpellCheck = () => {
window.removeEventListener('contextmenu', spellCheckHandler);
webFrame.setSpellCheckProvider('en-US', false, dummyChecker);
};
window.enableSpellCheck = () => {
webFrame.setSpellCheckProvider(
'en-US',
// Not sure what this parameter (`autoCorrectWord`) does: https://github.com/atom/electron/issues/4371
// The documentation for `webFrame.setSpellCheckProvider` passes `true` so we do too.
true,
simpleChecker
);
window.addEventListener('contextmenu', spellCheckHandler);
};
const spellCheckHandler = e => {
// Only show the context menu in text editors.
if (!e.target.closest('textarea, input, [contenteditable="true"]')) {
return;
}
const selectedText = window.getSelection().toString();
const isMisspelled = selectedText && simpleChecker.isMisspelled(selectedText);
const spellingSuggestions =
isMisspelled && simpleChecker.getSuggestions(selectedText).slice(0, 5);
const menu = buildEditorContextMenu({
isMisspelled,
spellingSuggestions,
});
// The 'contextmenu' event is emitted after 'selectionchange' has fired
// but possibly before the visible selection has changed. Try to wait
// to show the menu until after that, otherwise the visible selection
// will update after the menu dismisses and look weird.
setTimeout(() => {
menu.popup(remote.getCurrentWindow());
}, 30);
};

@ -165,9 +165,9 @@
props: {
titleText: this.titleText,
groupName: this.groupName,
okText: this.okText,
okText: i18n('ok'),
cancelText: i18n('cancel'),
isPublic: this.isPublic,
cancelText: this.cancelText,
existingMembers: this.existingMembers,
friendList: this.friendsAndMembers,
isAdmin: this.isAdmin,

@ -1,490 +0,0 @@
/* global
Whisper,
$,
getAccountManager,
textsecure,
i18n,
passwordUtil,
_,
setTimeout,
displayNameRegex
*/
/* eslint-disable more/no-then */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
const REGISTER_INDEX = 0;
const PROFILE_INDEX = 1;
let currentPageIndex = REGISTER_INDEX;
Whisper.StandaloneRegistrationView = Whisper.View.extend({
templateName: 'standalone',
className: 'full-screen-flow standalone-fullscreen',
initialize() {
this.accountManager = getAccountManager();
// Clean status in case the app closed unexpectedly
textsecure.storage.remove('secondaryDeviceStatus');
this.render();
const number = textsecure.storage.user.getNumber();
if (number) {
this.$('input.number').val(number);
}
this.phoneView = new Whisper.PhoneInputView({
el: this.$('#phone-number-input'),
});
this.$('#error').hide();
this.$('.standalone-mnemonic').hide();
this.$('.standalone-secondary-device').hide();
this.onGenerateMnemonic();
const options = window.mnemonic.get_languages().map(language => {
const text = language
// Split by whitespace or underscore
.split(/[\s_]+/)
// Capitalise each word
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return `<option value="${language}">${text}</option>`;
});
this.$('#mnemonic-language').append(options);
this.$('#mnemonic-language').val('english');
this.$('#mnemonic-display-language').append(options);
this.$('#mnemonic-display-language').val('english');
this.$passwordInput = this.$('#password');
this.$passwordConfirmationInput = this.$('#password-confirmation');
this.$passwordInputError = this.$('.password-inputs .error');
this.registrationParams = {};
this.$pages = this.$('.page');
this.pairingInterval = null;
this.showRegisterPage();
this.onValidatePassword();
this.onSecondaryDeviceRegistered = this.onSecondaryDeviceRegistered.bind(
this
);
this.$('#display-name').get(0).oninput = () => {
this.sanitiseNameInput();
};
this.$('#display-name').get(0).onpaste = () => {
// Sanitise data immediately after paste because it's easier
setTimeout(() => {
this.sanitiseNameInput();
});
};
this.sanitiseNameInput();
},
events: {
keyup: 'onKeyup',
'validation input.number': 'onValidation',
'click #request-voice': 'requestVoice',
'click #request-sms': 'requestSMSVerification',
'change #code': 'onChangeCode',
'click #register': 'registerWithoutMnemonic',
'click #register-mnemonic': 'registerWithMnemonic',
'click #register-secondary-device': 'registerSecondaryDevice',
'click #cancel-secondary-device': 'cancelSecondaryDevice',
'click #back-button': 'onBack',
'click #save-button': 'onSaveProfile',
'change #mnemonic': 'onChangeMnemonic',
'click #generate-mnemonic': 'onGenerateMnemonic',
'change #mnemonic-display-language': 'onGenerateMnemonic',
'click #copy-mnemonic': 'onCopyMnemonic',
'click .section-toggle': 'toggleSection',
'keyup #password': 'onValidatePassword',
'keyup #password-confirmation': 'onValidatePassword',
},
sanitiseNameInput() {
const oldVal = this.$('#display-name').val();
const newVal = oldVal.replace(displayNameRegex, '');
this.$('#display-name').val(newVal);
if (_.isEmpty(newVal)) {
this.$('#save-button').attr('disabled', 'disabled');
return false;
}
this.$('#save-button').removeAttr('disabled');
return true;
},
async showPage(pageIndex) {
// eslint-disable-next-line func-names
this.$pages.each(function(index) {
if (index !== pageIndex) {
$(this).hide();
} else {
$(this).show();
currentPageIndex = pageIndex;
}
});
},
async showRegisterPage() {
this.registrationParams = {};
this.showPage(REGISTER_INDEX);
},
async showProfilePage(mnemonic, language) {
this.registrationParams = {
mnemonic,
language,
};
this.$passwordInput.val('');
this.$passwordConfirmationInput.val('');
this.onValidatePassword();
this.showPage(PROFILE_INDEX);
this.$('#display-name').focus();
},
onKeyup(event) {
if (
currentPageIndex !== PROFILE_INDEX &&
currentPageIndex !== REGISTER_INDEX
) {
// Only want enter/escape keys to work on profile page
return;
}
const validName = this.sanitiseNameInput();
switch (event.key) {
case 'Enter':
if (event.target.id === 'mnemonic') {
this.registerWithMnemonic();
} else if (event.target.id === 'primary-pubkey') {
this.registerSecondaryDevice();
} else if (validName) {
this.onSaveProfile();
}
break;
case 'Escape':
case 'Esc':
this.onBack();
break;
default:
}
},
async register(mnemonic, language) {
// Make sure the password is valid
if (this.validatePassword()) {
window.pushToast({
title: i18n('invalidPassword'),
type: 'info',
});
return;
}
const input = this.trim(this.$passwordInput.val());
// Ensure we clear the secondary device registration status
textsecure.storage.remove('secondaryDeviceStatus');
try {
await this.resetRegistration();
await window.setPassword(input);
await this.accountManager.registerSingleDevice(
mnemonic,
language,
this.trim(this.$('#display-name').val())
);
this.$el.trigger('openInbox');
} catch (e) {
if (typeof e === 'string') {
window.pushToast({
title: e,
type: 'info',
});
}
this.log(e);
}
},
registerWithoutMnemonic() {
const mnemonic = this.$('#mnemonic-display').text();
const language = this.$('#mnemonic-display-language').val();
this.showProfilePage(mnemonic, language);
},
async onSecondaryDeviceRegistered() {
clearInterval(this.pairingInterval);
// Ensure the left menu is updated
Whisper.events.trigger('userChanged', { isSecondaryDevice: true });
// will re-run the background initialisation
Whisper.events.trigger('registration_done');
this.$el.trigger('openInbox');
},
async resetRegistration() {
await window.Signal.Data.removeAllIdentityKeys();
await window.Signal.Data.removeAllPrivateConversations();
Whisper.Registration.remove();
// Do not remove all items since they are only set
// at startup.
textsecure.storage.remove('identityKey');
textsecure.storage.remove('secondaryDeviceStatus');
window.ConversationController.reset();
await window.ConversationController.load();
Whisper.RotateSignedPreKeyListener.stop(Whisper.events);
},
async cancelSecondaryDevice() {
Whisper.events.off(
'secondaryDeviceRegistration',
this.onSecondaryDeviceRegistered
);
this.$('#register-secondary-device')
.removeAttr('disabled')
.text('Link');
this.$('#cancel-secondary-device').hide();
this.$('.standalone-secondary-device #pubkey').text('');
await this.resetRegistration();
},
async registerSecondaryDevice() {
if (textsecure.storage.get('secondaryDeviceStatus') === 'ongoing') {
return;
}
await this.resetRegistration();
textsecure.storage.put('secondaryDeviceStatus', 'ongoing');
this.$('#register-secondary-device')
.attr('disabled', 'disabled')
.text('Sending...');
this.$('#cancel-secondary-device').show();
const mnemonic = this.$('#mnemonic-display').text();
const language = this.$('#mnemonic-display-language').val();
const primaryPubKey = this.$('#primary-pubkey').val();
this.$('.standalone-secondary-device #error').hide();
// Ensure only one listener
Whisper.events.off(
'secondaryDeviceRegistration',
this.onSecondaryDeviceRegistered
);
Whisper.events.once(
'secondaryDeviceRegistration',
this.onSecondaryDeviceRegistered
);
const onError = async error => {
this.$('.standalone-secondary-device #error')
.text(error)
.show();
await this.resetRegistration();
this.$('#register-secondary-device')
.removeAttr('disabled')
.text('Link');
this.$('#cancel-secondary-device').hide();
};
const c = new Whisper.Conversation({
id: primaryPubKey,
type: 'private',
});
const validationError = c.validateNumber();
if (validationError) {
onError('Invalid public key');
return;
}
try {
await this.accountManager.registerSingleDevice(
mnemonic,
language,
null
);
await this.accountManager.requestPairing(primaryPubKey);
const pubkey = textsecure.storage.user.getNumber();
const words = window.mnemonic.pubkey_to_secret_words(pubkey);
this.$('.standalone-secondary-device #pubkey').text(
`Here is your secret:\n${words}`
);
} catch (e) {
onError(e);
}
},
registerWithMnemonic() {
const mnemonic = this.$('#mnemonic').val();
const language = this.$('#mnemonic-language').val();
try {
window.mnemonic.mn_decode(mnemonic, language);
} catch (error) {
this.$('#mnemonic').addClass('error-input');
this.$('#error').text(error);
this.$('#error').show();
return;
}
this.$('#error').hide();
this.$('#mnemonic').removeClass('error-input');
if (!mnemonic) {
this.log('Please provide a mnemonic word list');
} else {
this.showProfilePage(mnemonic, language);
}
},
onSaveProfile() {
if (_.isEmpty(this.registrationParams)) {
this.onBack();
return;
}
const { mnemonic, language } = this.registrationParams;
this.register(mnemonic, language);
},
onBack() {
this.showRegisterPage();
},
onChangeMnemonic() {
this.$('#status').html('');
},
async onGenerateMnemonic() {
const language = this.$('#mnemonic-display-language').val();
const mnemonic = await this.accountManager.generateMnemonic(language);
this.$('#mnemonic-display').text(mnemonic);
},
onCopyMnemonic() {
window.clipboard.writeText(this.$('#mnemonic-display').text());
window.pushToast({
title: i18n('copiedMnemonic'),
type: 'info',
});
},
log(s) {
window.log.info(s);
this.$('#status').text(s);
},
displayError(error) {
this.$('#error')
.hide()
.text(error)
.addClass('in')
.fadeIn();
},
onValidation() {
if (this.$('#number-container').hasClass('valid')) {
this.$('#request-sms, #request-voice').removeAttr('disabled');
} else {
this.$('#request-sms, #request-voice').prop('disabled', 'disabled');
}
},
onChangeCode() {
if (!this.validateCode()) {
this.$('#code').addClass('invalid');
} else {
this.$('#code').removeClass('invalid');
}
},
requestVoice() {
window.removeSetupMenuItems();
this.$('#error').hide();
const number = this.phoneView.validateNumber();
if (number) {
this.accountManager
.requestVoiceVerification(number)
.catch(this.displayError.bind(this));
this.$('#step2')
.addClass('in')
.fadeIn();
} else {
this.$('#number-container').addClass('invalid');
}
},
requestSMSVerification() {
window.removeSetupMenuItems();
$('#error').hide();
const number = this.phoneView.validateNumber();
if (number) {
this.accountManager
.requestSMSVerification(number)
.catch(this.displayError.bind(this));
this.$('#step2')
.addClass('in')
.fadeIn();
} else {
this.$('#number-container').addClass('invalid');
}
},
toggleSection(e) {
function focusInput() {
const inputs = $(this).find('input');
if ($(this).is(':visible')) {
if (inputs[0]) {
inputs[0].focus();
}
}
}
// Expand or collapse this panel
const $target = this.$(e.currentTarget);
const $next = $target.next();
// Toggle section visibility
$next.slideToggle('fast', focusInput);
$target.toggleClass('section-toggle-visible');
// Hide the other sections
this.$('.section-toggle')
.not($target)
.removeClass('section-toggle-visible');
this.$('.section-content')
.not($next)
.slideUp('fast');
},
validatePassword() {
const input = this.trim(this.$passwordInput.val());
const confirmationInput = this.trim(
this.$passwordConfirmationInput.val()
);
// If user hasn't set a value then skip
if (!input && !confirmationInput) {
return null;
}
const error = passwordUtil.validatePassword(input, i18n);
if (error) {
return error;
}
if (input !== confirmationInput) {
return "Password don't match";
}
return null;
},
onValidatePassword() {
const passwordValidation = this.validatePassword();
if (passwordValidation) {
this.$passwordInput.addClass('error-input');
this.$passwordConfirmationInput.addClass('error-input');
this.$passwordInput.removeClass('match-input');
this.$passwordConfirmationInput.removeClass('match-input');
this.$passwordInputError.text(passwordValidation);
this.$passwordInputError.show();
} else {
this.$passwordInput.removeClass('error-input');
this.$passwordConfirmationInput.removeClass('error-input');
this.$passwordInputError.text('');
this.$passwordInputError.hide();
// Show green box around inputs that match
const input = this.trim(this.$passwordInput.val());
const confirmationInput = this.trim(
this.$passwordConfirmationInput.val()
);
if (input && input === confirmationInput) {
this.$passwordInput.addClass('match-input');
this.$passwordConfirmationInput.addClass('match-input');
} else {
this.$passwordInput.removeClass('match-input');
this.$passwordConfirmationInput.removeClass('match-input');
}
}
},
trim(value) {
return value ? value.trim() : value;
},
});
})();

@ -17,6 +17,7 @@
class FallBackDecryptionError extends Error {}
const IV_LENGTH = 16;
const NONCE_LENGTH = 12;
async function DHEncrypt(symmetricKey, plainText) {
const iv = libsignal.crypto.getRandomBytes(IV_LENGTH);
@ -33,6 +34,52 @@
return ivAndCiphertext;
}
async function EncryptGCM(symmetricKey, plaintext) {
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
const key = await crypto.subtle.importKey(
'raw',
symmetricKey,
{ name: 'AES-GCM' },
false,
['encrypt']
);
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: nonce, tagLength: 128 },
key,
plaintext
);
const ivAndCiphertext = new Uint8Array(
NONCE_LENGTH + ciphertext.byteLength
);
ivAndCiphertext.set(nonce);
ivAndCiphertext.set(new Uint8Array(ciphertext), nonce.byteLength);
return ivAndCiphertext;
}
async function DecryptGCM(symmetricKey, ivAndCiphertext) {
const nonce = ivAndCiphertext.slice(0, NONCE_LENGTH);
const ciphertext = ivAndCiphertext.slice(NONCE_LENGTH);
const key = await crypto.subtle.importKey(
'raw',
symmetricKey,
{ name: 'AES-GCM' },
false,
['decrypt']
);
return crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: nonce },
key,
ciphertext
);
}
async function DHDecrypt(symmetricKey, ivAndCiphertext) {
const iv = ivAndCiphertext.slice(0, IV_LENGTH);
const ciphertext = ivAndCiphertext.slice(IV_LENGTH);
@ -89,15 +136,6 @@
const base32zIndex = Multibase.names.indexOf('base32z');
const base32zCode = Multibase.codes[base32zIndex];
function bufferToArrayBuffer(buf) {
const ab = new ArrayBuffer(buf.length);
const view = new Uint8Array(ab);
for (let i = 0; i < buf.length; i += 1) {
view[i] = buf[i];
}
return ab;
}
function decodeSnodeAddressToPubKey(snodeAddress) {
const snodeAddressClean = snodeAddress
.replace('.snode', '')
@ -106,64 +144,11 @@
return Multibase.decode(`${base32zCode}${snodeAddressClean}`);
}
class LokiSnodeChannel {
constructor() {
this._ephemeralKeyPair = libsignal.Curve.generateKeyPair();
// Signal protocol prepends with "0x05"
this._ephemeralKeyPair.pubKey = this._ephemeralKeyPair.pubKey.slice(1);
this._ephemeralPubKeyHex = StringView.arrayBufferToHex(
this._ephemeralKeyPair.pubKey
);
this._cache = {};
}
async _getSymmetricKey(snodeAddress) {
if (snodeAddress in this._cache) {
return this._cache[snodeAddress];
}
const ed25519PubKey = decodeSnodeAddressToPubKey(snodeAddress);
const sodium = await window.getSodium();
const curve25519PubKey = sodium.crypto_sign_ed25519_pk_to_curve25519(
ed25519PubKey
);
const snodePubKeyArrayBuffer = bufferToArrayBuffer(curve25519PubKey);
const symmetricKey = libsignal.Curve.calculateAgreement(
snodePubKeyArrayBuffer,
this._ephemeralKeyPair.privKey
);
this._cache[snodeAddress] = symmetricKey;
return symmetricKey;
}
getChannelPublicKeyHex() {
return this._ephemeralPubKeyHex;
}
async decrypt(snodeAddress, ivAndCiphertextBase64) {
const ivAndCiphertext = dcodeIO.ByteBuffer.wrap(
ivAndCiphertextBase64,
'base64'
).toArrayBuffer();
const symmetricKey = await this._getSymmetricKey(snodeAddress);
try {
const decrypted = await DHDecrypt(symmetricKey, ivAndCiphertext);
const decoder = new TextDecoder();
return decoder.decode(decrypted);
} catch (e) {
return ivAndCiphertext;
}
}
async encrypt(snodeAddress, plainText) {
if (typeof plainText === 'string') {
const textEncoder = new TextEncoder();
// eslint-disable-next-line no-param-reassign
plainText = textEncoder.encode(plainText);
}
const symmetricKey = await this._getSymmetricKey(snodeAddress);
const ciphertext = await DHEncrypt(symmetricKey, plainText);
return dcodeIO.ByteBuffer.wrap(ciphertext).toString('base64');
}
function generateEphemeralKeyPair() {
const keys = libsignal.Curve.generateKeyPair();
// Signal protocol prepends with "0x05"
keys.pubKey = keys.pubKey.slice(1);
return keys;
}
async function generateSignatureForPairing(secondaryPubKey, type) {
@ -315,7 +300,6 @@
const tokenString = dcodeIO.ByteBuffer.wrap(token).toString('utf8');
return tokenString;
}
const snodeCipher = new LokiSnodeChannel();
const sha512 = data => crypto.subtle.digest('SHA-512', data);
@ -474,10 +458,11 @@
window.libloki.crypto = {
DHEncrypt,
EncryptGCM, // AES-GCM
DHDecrypt,
DecryptGCM, // AES-GCM
FallBackSessionCipher,
FallBackDecryptionError,
snodeCipher,
decryptToken,
generateSignatureForPairing,
verifyPairingSignature,
@ -485,8 +470,7 @@
validateAuthorisation,
PairingType,
LokiSessionCipher,
// for testing
_LokiSnodeChannel: LokiSnodeChannel,
generateEphemeralKeyPair,
_decodeSnodeAddressToPubKey: decodeSnodeAddressToPubKey,
sha512,
};

@ -240,6 +240,14 @@
return window.Signal.Data.getSecondaryDevicesFor(primaryDevicePubKey);
}
function getGuardNodes() {
return window.Signal.Data.getGuardNodes();
}
function updateGuardNodes(nodes) {
return window.Signal.Data.updateGuardNodes(nodes);
}
async function getAllDevicePubKeysForPrimaryPubKey(primaryDevicePubKey) {
await saveAllPairingAuthorisationsFor(primaryDevicePubKey);
const secondaryPubKeys =
@ -265,6 +273,8 @@
getAllDevicePubKeysForPrimaryPubKey,
getSecondaryDevicesFor,
getPrimaryDeviceMapping,
getGuardNodes,
updateGuardNodes,
};
// Libloki protocol store

@ -33,7 +33,6 @@
<script type="text/javascript" src="proof-of-work_test.js"></script>
<script type="text/javascript" src="service_nodes_test.js"></script>
<script type="text/javascript" src="storage_test.js"></script>
<script type="text/javascript" src="snode_channel_test.js"></script>
<!-- Comment out to turn off code coverage. Useful for getting real callstacks. -->
<!-- NOTE: blanket doesn't support modern syntax and will choke until we find a replacement. :0( -->

@ -1,141 +0,0 @@
/* global libloki, Multibase, libsignal, StringView, dcodeIO */
'use strict';
async function generateSnodeKeysAndAddress() {
// snode identitys is a ed25519 keypair
const sodium = await window.getSodium();
const ed25519KeyPair = sodium.crypto_sign_keypair();
const keyPair = {
pubKey: ed25519KeyPair.publicKey,
privKey: ed25519KeyPair.privateKey,
};
// snode address is the pubkey in base32z
let address = Multibase.encode(
'base32z',
Multibase.Buffer.from(keyPair.pubKey)
).toString();
// remove first letter, which is the encoding code
address = address.substring(1);
return { keyPair, address };
}
describe('Snode Channel', () => {
describe('snodeCipher singleton', () => {
it('should be defined at libloki.crypto', () => {
assert.isDefined(libloki.crypto.snodeCipher);
assert.isTrue(
libloki.crypto.snodeCipher instanceof libloki.crypto._LokiSnodeChannel
);
});
});
describe('#decodeSnodeAddressToPubKey', () => {
it('should decode a base32z encoded .snode address', async () => {
const { keyPair, address } = await generateSnodeKeysAndAddress();
const buffer = libloki.crypto._decodeSnodeAddressToPubKey(
`http://${address}.snode`
);
const expected = new Uint8Array(keyPair.pubKey);
assert.strictEqual(expected.length, 32);
assert.strictEqual(buffer.length, 32);
for (let i = 0; i < buffer.length; i += 1) {
assert.strictEqual(buffer[i], expected[i]);
}
});
});
describe('#LokiSnodeChannel', () => {
it('should generate an ephemeral key pair', () => {
const channel = new libloki.crypto._LokiSnodeChannel();
assert.isDefined(channel._ephemeralKeyPair);
assert.isTrue(channel._ephemeralKeyPair.privKey instanceof ArrayBuffer);
assert.isTrue(channel._ephemeralKeyPair.pubKey instanceof ArrayBuffer);
const pubKeyHex = StringView.arrayBufferToHex(
channel._ephemeralKeyPair.pubKey
);
assert.strictEqual(channel.getChannelPublicKeyHex(), pubKeyHex);
});
it('should cache something by snode address', async () => {
const { address } = await generateSnodeKeysAndAddress();
const channel = new libloki.crypto._LokiSnodeChannel();
// cache should be empty
assert.strictEqual(Object.keys(channel._cache).length, 0);
// push to cache
await channel._getSymmetricKey(address);
assert.strictEqual(Object.keys(channel._cache).length, 1);
assert.strictEqual(Object.keys(channel._cache)[0], address);
});
it('should encrypt data correctly', async () => {
// message sent by Session
const snode = await generateSnodeKeysAndAddress();
const messageSent = 'I am Groot';
const textEncoder = new TextEncoder();
const data = textEncoder.encode(messageSent);
const channel = new libloki.crypto._LokiSnodeChannel();
const encrypted = await channel.encrypt(snode.address, data);
assert.strictEqual(typeof encrypted, 'string');
// message received by storage server
const senderPubKey = StringView.hexToArrayBuffer(
channel.getChannelPublicKeyHex()
);
const sodium = await window.getSodium();
const snodePrivKey = sodium.crypto_sign_ed25519_sk_to_curve25519(
snode.keyPair.privKey
).buffer;
const symmetricKey = libsignal.Curve.calculateAgreement(
senderPubKey,
snodePrivKey
);
const encryptedArrayBuffer = dcodeIO.ByteBuffer.wrap(
encrypted,
'base64'
).toArrayBuffer();
const decrypted = await libloki.crypto.DHDecrypt(
symmetricKey,
encryptedArrayBuffer
);
const textDecoder = new TextDecoder();
const messageReceived = textDecoder.decode(decrypted);
assert.strictEqual(messageSent, messageReceived);
});
it('should decrypt data correctly', async () => {
const channel = new libloki.crypto._LokiSnodeChannel();
// message sent by storage server
const snode = await generateSnodeKeysAndAddress();
const messageSent = 'You are Groot';
const textEncoder = new TextEncoder();
const data = textEncoder.encode(messageSent);
const senderPubKey = StringView.hexToArrayBuffer(
channel.getChannelPublicKeyHex()
);
const sodium = await window.getSodium();
const snodePrivKey = sodium.crypto_sign_ed25519_sk_to_curve25519(
snode.keyPair.privKey
).buffer;
const symmetricKey = libsignal.Curve.calculateAgreement(
senderPubKey,
snodePrivKey
);
const encrypted = await libloki.crypto.DHEncrypt(symmetricKey, data);
const encryptedBase64 = dcodeIO.ByteBuffer.wrap(encrypted).toString(
'base64'
);
// message received by Session
const decrypted = await channel.decrypt(snode.address, encryptedBase64);
assert.strictEqual(messageSent, decrypted);
});
});
});

@ -618,11 +618,27 @@
};
// Update authorisation in database with the new grant signature
await libloki.storage.savePairingAuthorisation(authorisation);
await lokiFileServerAPI.updateOurDeviceMapping();
await libloki.api.sendPairingAuthorisation(
authorisation,
secondaryDevicePubKey
);
// Try to upload to the file server and then send a message
try {
await lokiFileServerAPI.updateOurDeviceMapping();
await libloki.api.sendPairingAuthorisation(
authorisation,
secondaryDevicePubKey
);
} catch (e) {
log.error(
'Failed to authorise secondary device: ',
e && e.stack ? e.stack : e
);
// File server upload failed or message sending failed, we should rollback changes
await libloki.storage.removePairingAuthorisationForSecondaryPubKey(
secondaryDevicePubKey
);
await lokiFileServerAPI.updateOurDeviceMapping();
throw e;
}
// Always be friends with secondary devices
await secondaryConversation.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends,

@ -977,6 +977,11 @@ MessageReceiver.prototype.extend({
'devicePairingRequestReceived',
pairingRequest.secondaryDevicePubKey
);
} else {
Whisper.events.trigger(
'devicePairingRequestReceivedNoListener',
pairingRequest.secondaryDevicePubKey
);
}
// Ignore requests if the dialog is closed
}

@ -26,6 +26,7 @@
: 0;
},
// This is not a "standard" base64, do not use!
base64ToBytes(sBase64, nBlocksSize) {
const sB64Enc = sBase64.replace(/[^A-Za-z0-9+/]/g, '');
const nInLen = sB64Enc.length;

@ -9,7 +9,7 @@ const crypto = require('crypto');
const _ = require('lodash');
const pify = require('pify');
const electron = require('electron');
const { setup: setupSpellChecker } = require('./app/spell_check');
const packageJson = require('./package.json');
const GlobalErrors = require('./app/global_errors');
@ -82,6 +82,25 @@ const {
} = require('./app/protocol_filter');
const { installPermissionsHandler } = require('./app/permissions');
const _sodium = require('libsodium-wrappers');
async function getSodium() {
await _sodium.ready;
return _sodium;
}
let appStartInitialSpellcheckSetting = true;
async function getSpellCheckSetting() {
const json = await sql.getItemById('spell-check');
// Default to `true` if setting doesn't exist yet
if (!json) {
return true;
}
return json.value;
}
function showWindow() {
if (!mainWindow) {
return;
@ -155,7 +174,6 @@ function prepareURL(pathSegments, moreKeys) {
serverUrl: config.get('serverUrl'),
localUrl: config.get('localUrl'),
cdnUrl: config.get('cdnUrl'),
localServerPort: config.get('localServerPort'),
defaultPoWDifficulty: config.get('defaultPoWDifficulty'),
seedNodeList: JSON.stringify(config.get('seedNodeList')),
certificateAuthority: config.get('certificateAuthority'),
@ -167,6 +185,7 @@ function prepareURL(pathSegments, moreKeys) {
contentProxyUrl: config.contentProxyUrl,
importMode: importMode ? true : undefined, // for stringify()
serverTrustRoot: config.get('serverTrustRoot'),
appStartInitialSpellcheckSetting,
defaultFileServer: config.get('defaultFileServer'),
...moreKeys,
},
@ -186,10 +205,12 @@ function captureClicks(window) {
window.webContents.on('new-window', handleUrl);
}
const DEFAULT_WIDTH = 800;
const DEFAULT_HEIGHT = 720;
const DEFAULT_WIDTH = 880;
// add contact button needs to be visible (on HiDpi screens?)
// otherwise integration test fail
const DEFAULT_HEIGHT = 820;
const MIN_WIDTH = 880;
const MIN_HEIGHT = 580;
const MIN_HEIGHT = 820;
const BOUNDS_BUFFER = 100;
function isVisible(window, bounds) {
@ -217,7 +238,7 @@ function isVisible(window, bounds) {
);
}
function createWindow() {
async function createWindow() {
const { screen } = electron;
const windowOptions = Object.assign(
{
@ -234,6 +255,7 @@ function createWindow() {
contextIsolation: false,
preload: path.join(__dirname, 'preload.js'),
nativeWindowOpen: true,
spellcheck: await getSpellCheckSetting(),
},
icon: path.join(__dirname, 'images', 'session', 'icon_64.png'),
},
@ -284,6 +306,8 @@ function createWindow() {
// Create the browser window.
mainWindow = new BrowserWindow(windowOptions);
setupSpellChecker(mainWindow, locale.messages);
// Disable system main menu
mainWindow.setMenu(null);
@ -331,6 +355,10 @@ function createWindow() {
mainWindow.on('focus', () => {
mainWindow.flashFrame(false);
if (passwordWindow) {
passwordWindow.close();
passwordWindow = null;
}
});
if (config.environment === 'test') {
@ -343,6 +371,8 @@ function createWindow() {
mainWindow.loadURL(
prepareURL([__dirname, 'libloki', 'test', 'index.html'])
);
} else if (config.environment.includes('test-integration')) {
mainWindow.loadURL(prepareURL([__dirname, 'background_test.html']));
} else {
mainWindow.loadURL(prepareURL([__dirname, 'background.html']));
}
@ -367,6 +397,7 @@ function createWindow() {
config.environment === 'test' ||
config.environment === 'test-lib' ||
config.environment === 'test-loki' ||
config.environment.includes('test-integration') ||
(mainWindow.readyForShutdown && windowState.shouldQuit())
) {
return;
@ -599,7 +630,7 @@ async function showDebugLogWindow() {
return;
}
const theme = await pify(getDataFromMainWindow)('theme-setting');
const theme = await getThemeFromMainWindow();
const size = mainWindow.getSize();
const options = {
width: Math.max(size[0] - 100, MIN_WIDTH),
@ -647,7 +678,7 @@ async function showPermissionsPopupWindow() {
return;
}
const theme = await pify(getDataFromMainWindow)('theme-setting');
const theme = await getThemeFromMainWindow();
const size = mainWindow.getSize();
const options = {
width: Math.min(400, size[0]),
@ -698,7 +729,8 @@ app.on('ready', async () => {
if (
process.env.NODE_ENV !== 'test' &&
process.env.NODE_ENV !== 'test-lib' &&
process.env.NODE_ENV !== 'test-loki'
process.env.NODE_ENV !== 'test-loki' &&
!process.env.NODE_ENV.includes('test-integration')
) {
installFileHandler({
protocol: electronProtocol,
@ -771,6 +803,7 @@ async function showMainWindow(sqlKey, passwordAttempt = false) {
messages: locale.messages,
passwordAttempt,
});
appStartInitialSpellcheckSetting = await getSpellCheckSetting();
await sqlChannels.initialize();
try {
@ -894,7 +927,8 @@ app.on('window-all-closed', () => {
process.platform !== 'darwin' ||
config.environment === 'test' ||
config.environment === 'test-lib' ||
config.environment === 'test-loki'
config.environment === 'test-loki' ||
config.environment.includes('test-integration')
) {
app.quit();
}
@ -945,11 +979,10 @@ ipc.on('add-setup-menu-items', () => {
});
ipc.on('draw-attention', () => {
if (process.platform === 'darwin') {
app.dock.bounce();
} else if (process.platform === 'win32') {
mainWindow.flashFrame(true);
} else if (process.platform === 'linux') {
if (!mainWindow) {
return;
}
if (process.platform === 'win32' || process.platform === 'linux') {
mainWindow.flashFrame(true);
}
});
@ -998,11 +1031,6 @@ ipc.on('password-window-login', async (event, passPhrase) => {
const passwordAttempt = true;
await showMainWindow(passPhrase, passwordAttempt);
sendResponse();
if (passwordWindow) {
passwordWindow.close();
passwordWindow = null;
}
} catch (e) {
const localisedError = locale.messages.invalidPassword.message;
sendResponse(localisedError || 'Invalid password');
@ -1099,6 +1127,52 @@ ipc.on('get-auto-update-setting', event => {
event.returnValue = typeof configValue !== 'boolean' ? true : configValue;
});
async function decryptLns(event, lnsName, ciphertext) {
const sodium = await getSodium();
const salt = new Uint8Array(sodium.crypto_pwhash_SALTBYTES);
try {
const key = sodium.crypto_pwhash(
sodium.crypto_secretbox_KEYBYTES,
lnsName,
salt,
sodium.crypto_pwhash_OPSLIMIT_MODERATE,
sodium.crypto_pwhash_MEMLIMIT_MODERATE,
sodium.crypto_pwhash_ALG_ARGON2ID13
);
const nonce = new Uint8Array(sodium.crypto_secretbox_NONCEBYTES);
const res = sodium.crypto_secretbox_open_easy(ciphertext, nonce, key);
// null as first parameter to indivate no error
event.reply('decrypt-lns-response', null, res);
} catch (err) {
event.reply('decrypt-lns-response', err);
}
}
async function blake2bDigest(event, input) {
const sodium = await getSodium();
try {
const res = sodium.crypto_generichash(32, input);
event.reply('blake2b-digest-response', null, res);
} catch (err) {
event.reply('blake2b-digest-response', err);
}
}
ipc.on('blake2b-digest', (event, input) => {
blake2bDigest(event, input);
});
ipc.on('decrypt-lns-entry', (event, lnsName, ciphertext) => {
decryptLns(event, lnsName, ciphertext);
});
ipc.on('set-auto-update-setting', (event, enabled) => {
userConfig.set('autoUpdate', !!enabled);
@ -1110,9 +1184,9 @@ ipc.on('set-auto-update-setting', (event, enabled) => {
}
});
function getDataFromMainWindow(name, callback) {
ipc.once(`get-success-${name}`, (_event, error, value) =>
callback(error, value)
);
mainWindow.webContents.send(`get-${name}`);
function getThemeFromMainWindow() {
return new Promise(resolve => {
ipc.once('get-success-theme-setting', (_event, value) => resolve(value));
mainWindow.webContents.send('get-theme-setting');
});
}

@ -2,7 +2,7 @@
"name": "session-messenger-desktop",
"productName": "Session",
"description": "Private messaging from your desktop",
"version": "1.0.5",
"version": "1.0.6",
"license": "GPL-3.0",
"author": {
"name": "Loki Project",
@ -18,47 +18,40 @@
"start": "electron .",
"start-multi": "cross-env NODE_APP_INSTANCE=1 electron .",
"start-multi2": "cross-env NODE_APP_INSTANCE=2 electron .",
"start-prod": "cross-env NODE_ENV=production NODE_APP_INSTANCE=devprod LOKI_DEV=1 electron .",
"start-prod-multi": "cross-env NODE_ENV=production NODE_APP_INSTANCE=devprod1 LOKI_DEV=1 electron .",
"start-swarm-test": "cross-env NODE_ENV=swarm-testing NODE_APP_INSTANCE=test1 LOKI_DEV=1 electron .",
"start-swarm-test-2": "cross-env NODE_ENV=swarm-testing2 NODE_APP_INSTANCE=test2 LOKI_DEV=1 electron .",
"start-prod": "cross-env NODE_ENV=production NODE_APP_INSTANCE=devprod electron .",
"start-prod-multi": "cross-env NODE_ENV=production NODE_APP_INSTANCE=devprod1 electron .",
"start-swarm-test": "cross-env NODE_ENV=swarm-testing NODE_APP_INSTANCE=1 electron .",
"start-swarm-test-2": "cross-env NODE_ENV=swarm-testing NODE_APP_INSTANCE=2 electron .",
"grunt": "grunt",
"icon-gen": "electron-icon-maker --input=images/icon_1024.png --output=./build",
"generate": "yarn icon-gen && yarn grunt",
"build": "electron-builder --config.extraMetadata.environment=$SIGNAL_ENV",
"build-release": "cross-env SIGNAL_ENV=production npm run build -- --config.directories.output=release",
"make:linux:x64:appimage": "electron-builder build --linux appimage --x64",
"build-module-protobuf": "pbjs --target static-module --wrap commonjs --out ts/protobuf/compiled.js protos/*.proto && pbts --out ts/protobuf/compiled.d.ts ts/protobuf/compiled.js",
"clean-module-protobuf": "rm -f ts/protobuf/compiled.d.ts ts/protobuf/compiled.js",
"build-protobuf": "yarn build-module-protobuf",
"clean-protobuf": "yarn clean-module-protobuf",
"prepare-beta-build": "node prepare_beta_build.js",
"prepare-import-build": "node prepare_import_build.js",
"publish-to-apt": "NAME=$npm_package_name VERSION=$npm_package_version ./aptly.sh",
"test": "yarn test-node && yarn test-electron",
"test-view": "NODE_ENV=test yarn run start",
"test-lib-view": "NODE_ENV=test-lib yarn run start",
"test-loki-view": "NODE_ENV=test-loki yarn run start",
"test-electron": "yarn grunt test",
"test-integration": "ELECTRON_DISABLE_SANDBOX=1 mocha --exit --timeout 10000 integration_test/integration_test.js",
"test-integration-parts": "ELECTRON_DISABLE_SANDBOX=1 mocha --exit --timeout 10000 integration_test/integration_test.js --grep 'registration' && ELECTRON_DISABLE_SANDBOX=1 mocha --exit --timeout 10000 integration_test/integration_test.js --grep 'openGroup' && ELECTRON_DISABLE_SANDBOX=1 mocha --exit --timeout 10000 integration_test/integration_test.js --grep 'addFriends' && ELECTRON_DISABLE_SANDBOX=1 mocha --exit --timeout 10000 integration_test/integration_test.js --grep 'linkDevice' && ELECTRON_DISABLE_SANDBOX=1 mocha --exit --timeout 10000 integration_test/integration_test.js --grep 'closedGroup'",
"test-node": "mocha --recursive --exit test/app test/modules ts/test libloki/test/node",
"test-node-coverage": "nyc --reporter=lcov --reporter=text mocha --recursive test/app test/modules ts/test libloki/test/node",
"test-node-coverage-html": "nyc --reporter=lcov --reporter=html mocha --recursive test/a/* */pp test/modules ts/test libloki/test/node",
"eslint": "eslint --cache .",
"eslint-fix": "eslint --fix .",
"eslint-full": "eslint .",
"lint": "yarn format --list-different && yarn lint-windows",
"lint-full": "yarn format-full --list-different; yarn lint-windows-full",
"dev-lint": "yarn format --list-different; yarn lint-windows",
"lint-windows": "yarn eslint && yarn tslint",
"lint-windows-full": "yarn eslint-full && yarn tslint",
"lint": "yarn format && yarn lint-files",
"lint-full": "yarn format-full && yarn lint-files-full",
"dev-lint": "yarn format && yarn lint-files",
"lint-files": "yarn eslint && yarn tslint",
"lint-files-full": "yarn eslint-full && yarn tslint",
"lint-deps": "node ts/util/lint/linter.js",
"tslint": "tslint --format stylish --project .",
"format": "prettier --write `git ls-files --modified *.{css,js,json,md,scss,ts,tsx}` `git ls-files --modified ./**/*.{css,js,json,md,scss,ts,tsx}`",
"format-full": "prettier --write \"*.{css,js,json,md,scss,ts,tsx}\" \"./**/*.{css,js,json,md,scss,ts,tsx}\"",
"format": "prettier --list-different --write `git ls-files --modified *.{css,js,json,scss,ts,tsx}` `git ls-files --modified ./**/*.{css,js,json,scss,ts,tsx}`",
"format-full": "prettier --list-different --write \"*.{css,js,json,scss,ts,tsx}\" \"./**/*.{css,js,json,scss,ts,tsx}\"",
"transpile": "tsc",
"clean-transpile": "rimraf ts/**/*.js && rimraf ts/*.js",
"open-coverage": "open coverage/lcov-report/index.html",
"styleguide": "styleguidist server",
"pow-metrics": "node metrics_app.js localhost 9000",
"ready": "yarn clean-transpile && yarn grunt && yarn lint-full && yarn test-node && yarn test-electron && yarn lint-deps"
},
@ -78,8 +71,6 @@
"config": "1.28.1",
"cross-env": "^6.0.3",
"dompurify": "^2.0.7",
"electron-context-menu": "^0.15.0",
"electron-editor-context-menu": "1.1.1",
"electron-is-dev": "^1.1.0",
"electron-localshortcut": "^3.2.1",
"electron-updater": "^4.2.2",
@ -90,7 +81,7 @@
"filesize": "3.6.1",
"firstline": "1.2.1",
"form-data": "^3.0.0",
"fs-extra": "5.0.0",
"fs-extra": "9.0.0",
"glob": "7.1.2",
"google-libphonenumber": "3.2.2",
"got": "8.2.0",
@ -100,7 +91,7 @@
"js-sha512": "0.8.0",
"js-yaml": "3.13.0",
"jsbn": "1.1.0",
"libsodium-wrappers": "^0.7.4",
"libsodium-wrappers": "^0.7.6",
"linkify-it": "2.0.3",
"lodash": "4.17.11",
"mkdirp": "0.5.1",
@ -128,7 +119,6 @@
"reselect": "4.0.0",
"rimraf": "2.6.2",
"semver": "5.4.1",
"spellchecker": "3.7.0",
"tar": "4.4.8",
"testcheck": "1.0.0-rc.2",
"tmp": "0.0.33",
@ -169,9 +159,10 @@
"asar": "0.14.0",
"bower": "1.8.2",
"chai": "4.1.2",
"chai-as-promised": "^7.1.1",
"dashdash": "1.14.1",
"electron": "4.1.2",
"electron-builder": "22.3.2",
"electron": "8.2.0",
"electron-builder": "22.3.6",
"electron-icon-maker": "0.0.3",
"electron-notarize": "^0.2.0",
"eslint": "4.14.0",
@ -198,7 +189,7 @@
"react-docgen-typescript": "1.2.6",
"react-styleguidist": "7.0.1",
"sinon": "4.4.2",
"spectron": "5.0.0",
"spectron": "^10.0.0",
"ts-loader": "4.1.0",
"tslint": "5.13.0",
"tslint-microsoft-contrib": "6.0.0",
@ -272,7 +263,6 @@
"config/local-${env.SIGNAL_ENV}.json",
"background.html",
"about.html",
"settings.html",
"password.html",
"permissions_popup.html",
"debug_log.html",

@ -34,7 +34,7 @@ window.Signal.Logs = require('./js/modules/logs');
window.CONSTANTS = {
MAX_LOGIN_TRIES: 3,
MAX_PASSWORD_LENGTH: 32,
MAX_PASSWORD_LENGTH: 64,
MAX_USERNAME_LENGTH: 20,
};

@ -1,3 +1,4 @@
/* eslint-disable global-require */
/* global Whisper: false */
/* global window: false */
const path = require('path');
@ -50,6 +51,13 @@ window.getStoragePubKey = key =>
window.getDefaultFileServer = () => config.defaultFileServer;
window.initialisedAPI = false;
if (
typeof process.env.NODE_ENV === 'string' &&
process.env.NODE_ENV.includes('test-integration')
) {
window.electronRequire = require;
}
window.isBeforeVersion = (toCheck, baseVersion) => {
try {
return semver.lt(toCheck, baseVersion);
@ -64,7 +72,7 @@ window.isBeforeVersion = (toCheck, baseVersion) => {
window.CONSTANTS = {
MAX_LOGIN_TRIES: 3,
MAX_PASSWORD_LENGTH: 32,
MAX_PASSWORD_LENGTH: 64,
MAX_USERNAME_LENGTH: 20,
MAX_GROUP_NAME_LENGTH: 64,
DEFAULT_PUBLIC_CHAT_URL: appConfig.get('defaultPublicChatServer'),
@ -87,6 +95,26 @@ window.wrapDeferred = deferredToPromise;
const ipc = electron.ipcRenderer;
const localeMessages = ipc.sendSync('locale-data');
window.blake2b = input =>
new Promise((resolve, reject) => {
ipc.once('blake2b-digest-response', (event, error, res) => {
// eslint-disable-next-line no-unused-expressions
error ? reject(error) : resolve(res);
});
ipc.send('blake2b-digest', input);
});
window.decryptLnsEntry = (key, value) =>
new Promise((resolve, reject) => {
ipc.once('decrypt-lns-response', (event, error, res) => {
// eslint-disable-next-line no-unused-expressions
error ? reject(error) : resolve(res);
});
ipc.send('decrypt-lns-entry', key, value);
});
window.updateZoomFactor = () => {
const zoomFactor = window.getSettingValue('zoom-factor-setting') || 100;
window.setZoomFactor(zoomFactor / 100);
@ -189,6 +217,11 @@ ipc.on('set-up-as-standalone', () => {
Whisper.events.trigger('setupAsStandalone');
});
ipc.on('get-theme-setting', () => {
const theme = window.Events.getThemeSetting();
ipc.send('get-success-theme-setting', theme);
});
// Settings-related events
window.showPermissionsPopup = () => ipc.send('show-permissions-popup');
@ -297,6 +330,10 @@ window.lokiSnodeAPI = new LokiSnodeAPI({
window.LokiMessageAPI = require('./js/modules/loki_message_api');
if (process.env.USE_STUBBED_NETWORK) {
window.StubMessageAPI = require('./integration_test/stubs/stub_message_api');
window.StubAppDotNetApi = require('./integration_test/stubs/stub_app_dot_net_api');
}
window.LokiPublicChatAPI = require('./js/modules/loki_public_chat_api');
window.LokiAppDotNetServerAPI = require('./js/modules/loki_app_dot_net_api');
@ -305,8 +342,6 @@ window.LokiFileServerAPI = require('./js/modules/loki_file_server_api');
window.LokiRssAPI = require('./js/modules/loki_rss_api');
window.localServerPort = config.localServerPort;
window.mnemonic = require('./libloki/modules/mnemonic');
const WorkerInterface = require('./js/modules/util_worker_interface');
@ -337,13 +372,6 @@ window.React = require('react');
window.ReactDOM = require('react-dom');
window.moment = require('moment');
const _sodium = require('libsodium-wrappers');
window.getSodium = async () => {
await _sodium.ready;
return _sodium;
};
window.clipboard = clipboard;
const Signal = require('./js/modules/signal');
@ -373,34 +401,17 @@ window.Signal.Backup = require('./js/modules/backup');
window.Signal.Debug = require('./js/modules/debug');
window.Signal.Logs = require('./js/modules/logs');
// Add right-click listener for selected text and urls
const contextMenu = require('electron-context-menu');
const isQR = params =>
params.mediaType === 'image' && params.titleText === 'Scan me!';
// QR saving doesn't work so we just disable it
contextMenu({
showInspectElement: false,
shouldShowMenu: (event, params) => {
const isRegular =
params.mediaType === 'none' && (params.linkURL || params.selectionText);
return Boolean(!params.isEditable && (isQR(params) || isRegular));
},
menu: (actions, params) => {
// If it's not a QR then show the default options
if (!isQR(params)) {
return actions;
}
return [actions.copyImage()];
},
window.addEventListener('contextmenu', e => {
const editable = e.target.closest(
'textarea, input, [contenteditable="true"]'
);
const link = e.target.closest('a');
const selection = Boolean(window.getSelection().toString());
if (!editable && !selection && !link) {
e.preventDefault();
}
});
// We pull this in last, because the native module involved appears to be sensitive to
// /tmp mounted as noexec on Linux.
require('./js/spell_check');
window.shortenPubkey = pubkey => `(...${pubkey.substring(pubkey.length - 6)})`;
window.pubkeyPattern = /@[a-fA-F0-9]{64,66}\b/g;
@ -409,8 +420,9 @@ window.pubkeyPattern = /@[a-fA-F0-9]{64,66}\b/g;
window.lokiFeatureFlags = {
multiDeviceUnpairing: true,
privateGroupChats: true,
useSnodeProxy: true,
useSnodeProxy: !process.env.USE_STUBBED_NETWORK,
useSealedSender: true,
useOnionRequests: false,
};
// eslint-disable-next-line no-extend-native,func-names
@ -419,7 +431,10 @@ Promise.prototype.ignore = function() {
this.then(() => {});
};
if (config.environment.includes('test')) {
if (
config.environment.includes('test') &&
!config.environment.includes('swarm-testing')
) {
const isWindows = process.platform === 'win32';
/* eslint-disable global-require, import/no-extraneous-dependencies */
window.test = {
@ -439,5 +454,13 @@ if (config.environment.includes('test')) {
updateSwarmNodes: () => {},
updateLastHash: () => {},
getSwarmNodesForPubKey: () => [],
buildNewOnionPaths: () => [],
};
}
if (config.environment.includes('test-integration')) {
window.lokiFeatureFlags = {
multiDeviceUnpairing: true,
privateGroupChats: true,
useSnodeProxy: !process.env.USE_STUBBED_NETWORK,
};
}

@ -1,66 +0,0 @@
/* eslint-disable no-console */
const fs = require('fs');
const _ = require('lodash');
const packageJson = require('./package.json');
const { version } = packageJson;
const beta = /beta/;
// You might be wondering why this file is necessary. It comes down to our desire to allow
// side-by-side installation of production and beta builds. Electron-Builder uses
// top-level data from package.json for many things, like the executable name, the
// debian package name, the install directory under /opt on linux, etc. We tried
// adding the ${channel} macro to these values, but Electron-Builder didn't like that.
if (!beta.test(version)) {
process.exit();
}
console.log('prepare_beta_build: updating package.json');
// -------
const NAME_PATH = 'name';
const PRODUCTION_NAME = 'loki-messenger-desktop';
const BETA_NAME = 'loki-messenger-desktop-beta';
const PRODUCT_NAME_PATH = 'productName';
const PRODUCTION_PRODUCT_NAME = 'Session';
const BETA_PRODUCT_NAME = 'Session Beta';
const APP_ID_PATH = 'build.appId';
const PRODUCTION_APP_ID = 'com.loki-project.messenger-desktop';
const BETA_APP_ID = 'com.loki-project.messenger-desktop-beta';
const STARTUP_WM_CLASS_PATH = 'build.linux.desktop.StartupWMClass';
const PRODUCTION_STARTUP_WM_CLASS = 'Session';
const BETA_STARTUP_WM_CLASS = 'Session Beta';
// -------
function checkValue(object, objectPath, expected) {
const actual = _.get(object, objectPath);
if (actual !== expected) {
throw new Error(`${objectPath} was ${actual}; expected ${expected}`);
}
}
// ------
checkValue(packageJson, NAME_PATH, PRODUCTION_NAME);
checkValue(packageJson, PRODUCT_NAME_PATH, PRODUCTION_PRODUCT_NAME);
checkValue(packageJson, APP_ID_PATH, PRODUCTION_APP_ID);
checkValue(packageJson, STARTUP_WM_CLASS_PATH, PRODUCTION_STARTUP_WM_CLASS);
// -------
_.set(packageJson, NAME_PATH, BETA_NAME);
_.set(packageJson, PRODUCT_NAME_PATH, BETA_PRODUCT_NAME);
_.set(packageJson, APP_ID_PATH, BETA_APP_ID);
_.set(packageJson, STARTUP_WM_CLASS_PATH, BETA_STARTUP_WM_CLASS);
// -------
fs.writeFileSync('./package.json', JSON.stringify(packageJson, null, ' '));

@ -1,69 +0,0 @@
/* eslint-disable no-console */
const fs = require('fs');
const _ = require('lodash');
const packageJson = require('./package.json');
const defaultConfig = require('./config/default.json');
function checkValue(object, objectPath, expected) {
const actual = _.get(object, objectPath);
if (actual !== expected) {
throw new Error(`${objectPath} was ${actual}; expected ${expected}`);
}
}
// You might be wondering why this file is necessary. We have some very specific
// requirements around our import-flavor builds. They need to look exactly the same as
// normal builds, but they must immediately open into import mode. So they need a
// slight config tweak, and then a change to the .app/.exe name (note: we do NOT want to
// change where data is stored or anything, since that would make these builds
// incompatible with the mainline builds) So we just change the artifact name.
//
// Another key thing to know about these builds is that we should not upload the
// latest.yml (windows) and latest-mac.yml (mac) that go along with the executables.
// This would interrupt the normal install flow for users installing from
// signal.org/download. So any release script will need to upload these files manually
// instead of relying on electron-builder, which will upload everything.
// -------
console.log('prepare_import_build: updating config/default.json');
const IMPORT_PATH = 'import';
const IMPORT_START_VALUE = false;
const IMPORT_END_VALUE = true;
checkValue(defaultConfig, IMPORT_PATH, IMPORT_START_VALUE);
_.set(defaultConfig, IMPORT_PATH, IMPORT_END_VALUE);
// -------
console.log('prepare_import_build: updating package.json');
const MAC_ASSET_PATH = 'build.mac.artifactName';
// eslint-disable-next-line no-template-curly-in-string
const MAC_ASSET_START_VALUE = '${name}-mac-${version}.${ext}';
// eslint-disable-next-line no-template-curly-in-string
const MAC_ASSET_END_VALUE = '${name}-mac-${version}-import.${ext}';
const WIN_ASSET_PATH = 'build.win.artifactName';
// eslint-disable-next-line no-template-curly-in-string
const WIN_ASSET_START_VALUE = '${name}-win-${version}.${ext}';
// eslint-disable-next-line no-template-curly-in-string
const WIN_ASSET_END_VALUE = '${name}-win-${version}-import.${ext}';
checkValue(packageJson, MAC_ASSET_PATH, MAC_ASSET_START_VALUE);
checkValue(packageJson, WIN_ASSET_PATH, WIN_ASSET_START_VALUE);
_.set(packageJson, MAC_ASSET_PATH, MAC_ASSET_END_VALUE);
_.set(packageJson, WIN_ASSET_PATH, WIN_ASSET_END_VALUE);
// ---
fs.writeFileSync(
'./config/default.json',
JSON.stringify(defaultConfig, null, ' ')
);
fs.writeFileSync('./package.json', JSON.stringify(packageJson, null, ' '));

@ -1,184 +0,0 @@
<html>
<head>
<meta http-equiv="Content-Security-Policy"
content="default-src 'none';
child-src 'self';
connect-src 'self' https: wss:;
font-src 'self';
form-action 'self';
frame-src 'none';
img-src 'self' blob: data:;
media-src 'self' blob:;
object-src 'none';
script-src 'self';
style-src 'self' 'unsafe-inline';"
>
<link href="stylesheets/manifest.css" rel="stylesheet" type="text/css" />
<style>
</style>
</head>
<body>
</body>
<script type='text/x-tmpl-mustache' id='syncSettings'>
<hr>
<h3>{{ sync }}</h3>
<div>
<button class='grey sync'>{{ syncNow }}</button>
<p>
{{ syncExplanation }}
<div class='synced_at'>
{{ lastSynced }} {{ syncDate }} {{ syncTime }}
</div>
<div class='sync_failed'>{{ syncFailed }}</div>
<div class='clearfix'></div>
</p>
</div>
</script>
<script type='text/x-tmpl-mustache' id='blockedUserSettings'>
<h3>{{ blockedHeader }}</h3>
<div class='blocked-user-settings'>
<button class='grey unblock-button'>{{ unblockMessage }}</button>
</div>
</script>
<script type='text/x-tmpl-mustache' id='settings'>
<div class='content'>
<a class='x close' alt='close settings' href='#'></a>
<h2>{{ settings }}</h2>
<div class='device-name-settings'>
<b>{{ deviceNameLabel }}:</b> {{ deviceName }}
</div>
<hr>
<div class='theme-settings'>
<h3>{{ theme }}</h3>
<div>
<input type='radio' name='theme' id='theme-setting-light' value='light'>
<label for='theme-setting-light'>{{ themeLight }}</label>
</div>
<div>
<input type='radio' name='theme' id='theme-setting-dark' value='dark'>
<label for='theme-setting-dark'>{{ themeDark }}</label>
</div>
</div>
<br />
{{ #isHideMenuBarSupported }}
<div class='menu-bar-setting'>
<input type='checkbox' name='hide-menu-bar' id='hide-menu-bar'/>
<label for='hide-menu-bar'>{{ hideMenuBar }}</label>
</div>
{{ /isHideMenuBarSupported }}
<hr>
<div class='notification-settings'>
<h3>{{ notifications }}</h3>
<p>{{ notificationSettingsDialog }}</p>
<div>
<input type='radio' name='notifications' id='notification-setting-message' value='message'>
<label for='notification-setting-message'>{{ nameAndMessage }} </label>
</div>
<div>
<input type='radio' name='notifications' id='notification-setting-name' value='name'/>
<label for='notification-setting-name'>{{ nameOnly }} </label>
</div>
<div>
<input type='radio' name='notifications' id='notification-setting-count' value='count'/>
<label for='notification-setting-count'>{{ noNameOrMessage }} </label>
</div>
<div>
<input type='radio' name='notifications' id='notification-setting-off' value='off'/>
<label for='notification-setting-off'>{{ disableNotifications }} </label>
</div>
</div>
<br />
{{ #isAudioNotificationSupported }}
<div class='audio-notification-setting'>
<input type='checkbox' name='audio-notification' id='audio-notification'/>
<label for='audio-notification'>{{ audioNotificationDescription }}</label>
</div>
{{ /isAudioNotificationSupported }}
<hr>
<h3>{{ generalHeader }}</h3>
<div class='spell-check-setting'>
<input type='checkbox' name='spell-check-setting' id='spell-check-setting' />
<label for='spell-check-setting'>{{ spellCheckDescription }}</label>
</div>
<div class='link-preview-setting'>
<input type='checkbox' name='link-preview-setting' id='link-preview-setting' />
<label for='link-preview-setting'>{{ linkPreviewsSettingDescription }}</label>
</div>
<hr>
<div class='permissions-setting'>
<h3>{{ permissions }}</h3>
<div class='media-permissions'>
<input type='checkbox' name='media-permissions' id='media-permissions' />
<label for='media-permissions'>{{ mediaPermissionsDescription }}</label>
</div>
<div class='read-receipt-setting'>
<input type='checkbox' name='read-receipt-setting' id='read-receipt-setting' />
<label for='read-receipt-setting'>{{ readReceiptSettingDescription }}</label>
</div>
<div class='typing-indicators-setting'>
<input type='checkbox' name='typing-indicators-setting' id='typing-indicators-setting' />
<label for='typing-indicators-setting'>{{ typingIndicatorsSettingDescription }}</label>
</div>
</div>
<div class='sync-setting'></div>
<hr>
<div class='message-ttl-setting'>
<h3>{{ messageTTL }}</h3>
<div>{{ messageTTLSettingDescription }}</div>
<div id='warning'>{{ messageTTLSettingWarning }}</div>
<div class='inputs'>
<input
name='message-ttl-setting'
id='message-ttl-setting'
type='range'
list='tickmarks'
min='12'
max='96'
step='6'
value='24'
>
<label for='message-ttl-setting'>24 Hours</label>
<datalist id='tickmarks'>
<option value='12'>
<option value='18'>
<option value='24'>
<option value='30'>
<option value='36'>
<option value='42'>
<option value='48'>
<option value='54'>
<option value='60'>
<option value='66'>
<option value='72'>
<option value='78'>
<option value='84'>
<option value='90'>
<option value='96'>
</datalist>
</div>
</div>
<hr>
<div class='clear-data-settings'>
<h3>{{ clearDataHeader }}</h3>
<div>
<button class='grey destructive clear-data'>{{ clearDataButton }}</button>
<p>{{ clearDataExplanation }}</p>
</div>
</div>
<hr>
<div class='blocked-user-setting'>
</div>
</div>
</script>
<script type='text/javascript' src='js/components.js'></script>
<script type='text/javascript' src='js/storage.js'></script>
<script type='text/javascript' src='js/models/blockedNumbers.js'></script>
<script type='text/javascript' src='js/blocked_number_controller.js'></script>
<script type='text/javascript' src='js/views/whisper_view.js'></script>
<script type='text/javascript' src='js/views/list_view.js'></script>
<script type='text/javascript' src='js/views/blocked_number_view.js'></script>
<script type='text/javascript' src='js/views/settings_view.js'></script>
<script type='text/javascript' src='js/settings_start.js'></script>
</html>

@ -82,7 +82,7 @@
.error-faded {
opacity: 0;
margin-top: -20px;
margin-top: -5px;
transition: all 100ms linear;
}
@ -96,8 +96,6 @@
max-height: 240px;
overflow-y: auto;
margin: 4px;
border-top: 1px solid #2f2f2f;
border-bottom: 1px solid #2f2f2f;
.check-mark {
float: right;

@ -1,50 +0,0 @@
.password {
.content-wrapper {
display: flex;
align-items: center;
justify-content: center;
color: $color-dark-05;
width: 100%;
height: 100%;
}
.content {
margin: 3em;
}
.inputs {
display: flex;
flex-direction: column;
}
input {
width: 30em;
}
.error {
font-weight: bold;
font-size: 16px;
margin-top: 1em;
}
.reset {
font-size: 15px;
margin-top: 1em;
cursor: pointer;
user-select: none;
a {
color: #78be20;
font-weight: bold;
}
}
.overlay {
color: $color-dark-05;
background: $color-dark-85;
.step {
padding: 0;
}
}
}

@ -140,11 +140,11 @@ div.spacer-lg {
transition: filter 0.1s;
}
.text-subtle {
.subtle {
opacity: 0.6;
}
.text-soft {
.soft {
opacity: 0.4;
}
@ -460,22 +460,43 @@ $session_message-container-border-radius: 5px;
.notification-count {
position: absolute;
font-size: $session-font-xs;
font-family: $session-font-family;
top: 20px;
right: 20px;
width: 20px;
height: 20px;
top: $session-margin-lg;
right: $session-margin-lg;
padding: 3px;
border-radius: 50%;
font-weight: 700;
background: red;
color: $session-color-white;
text-align: center;
opacity: 1;
}
}
.notification-count {
display: flex;
align-items: center;
justify-content: center;
font-family: $session-font-family;
border-radius: 50%;
font-weight: 700;
background: $session-color-danger;
color: $session-color-white;
text-align: center;
span {
position: relative;
sup {
font-size: 130%;
position: absolute;
}
}
&.hover {
transition: $session-transition-duration;
cursor: pointer;
&:hover {
filter: brightness(80%);
}
}
}
.session-icon {
fill: $session-color-white;
}
@ -802,6 +823,24 @@ label {
}
}
.device-pairing-dialog {
&__desc {
font-weight: 300;
font-size: $session-font-sm;
margin-bottom: $session-margin-lg;
}
&__secret-words {
display: flex;
flex-direction: column;
align-items: center;
background-color: $session-shade-6;
padding: $session-margin-sm $session-margin-lg;
border-radius: 3px;
margin-bottom: $session-margin-md;
}
}
.create-group-dialog .session-modal__body {
display: flex;
flex-direction: column;
@ -809,6 +848,12 @@ label {
.friend-selection-list {
width: unset;
}
.create-group-dialog__member-count {
text-align: center;
margin-top: -25px;
opacity: 0.6;
}
}
.session-confirm {
@ -924,7 +969,7 @@ label {
list-style: none;
padding: 0px;
margin: 0px;
max-height: 450px;
max-height: 40vh;
overflow-y: auto;
}
@ -1597,92 +1642,6 @@ input {
}
}
.clear-data,
.password-prompt {
&-wrapper {
display: flex;
justify-content: center;
align-items: center;
background-color: $session-color-black;
width: 100%;
height: 100%;
padding: 3 * $session-margin-lg;
}
&-error-section {
width: 100%;
color: $session-color-white;
margin: -$session-margin-sm 0px 2 * $session-margin-lg 0px;
.session-label {
&.primary {
background-color: rgba($session-color-primary, 0.3);
}
padding: $session-margin-xs $session-margin-sm;
font-size: $session-font-xs;
text-align: center;
}
}
&-container {
font-family: 'SF Pro Text';
color: $session-color-white;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 600px;
min-width: 420px;
padding: 3 * $session-margin-lg 2 * $session-margin-lg;
box-sizing: border-box;
background-color: $session-shade-4;
border: 1px solid $session-shade-8;
border-radius: 2px;
.warning-info-area,
.password-info-area {
display: inline-flex;
justify-content: center;
align-items: center;
h1 {
color: $session-color-white;
}
svg {
margin-right: $session-margin-lg;
}
}
p,
input {
margin: $session-margin-lg 0px;
}
.button-group {
display: inline-flex;
}
#password-prompt-input {
width: 100%;
color: #fff;
background-color: #2e2e2e;
margin-top: 2 * $session-margin-lg;
padding: $session-margin-xs $session-margin-lg;
outline: none;
border: none;
border-radius: 2px;
text-align: center;
font-size: 24px;
letter-spacing: 5px;
font-family: 'SF Pro Text';
}
}
}
.onboarding-message-section {
display: flex;
flex-grow: 1;
@ -1806,6 +1765,13 @@ input {
justify-content: space-between;
transition: $session-transition-duration;
&:first-child {
border-top: 1px solid rgba($session-shade-8, 0.6);
}
&:last-child {
border-bottom: 1px solid rgba($session-shade-8, 0.6);
}
&.selected {
background-color: $session-shade-4;
}
@ -1833,6 +1799,10 @@ input {
margin-left: 5px;
opacity: 0.8;
}
&__avatar > div {
margin-bottom: 0px !important;
}
}
.invite-friends-container {

@ -149,6 +149,7 @@ $session-compose-margin: 20px;
&-session {
display: flex;
height: 100vh;
}
&__sections-container {
@ -465,27 +466,6 @@ $session-compose-margin: 20px;
}
}
.contact-notification-count-bubble {
display: flex;
align-items: center;
justify-content: center;
background: $session-color-danger;
width: 22px;
height: 22px;
font-size: $session-font-xs;
margin-left: auto;
text-align: center;
border-radius: 50%;
font-weight: bold;
cursor: pointer;
transition: $session-transition-duration;
color: $session-color-white;
&:hover {
filter: brightness(80%);
}
}
.left-pane-contact {
&-section,
&-content {

@ -0,0 +1,89 @@
.password {
height: 100vh;
.clear-data,
.password-prompt {
&-wrapper {
display: flex;
justify-content: center;
align-items: center;
background-color: $session-color-black;
width: 100%;
height: 100%;
padding: 3 * $session-margin-lg;
}
&-error-section {
width: 100%;
color: $session-color-white;
margin: -$session-margin-sm 0px 2 * $session-margin-lg 0px;
.session-label {
&.primary {
background-color: rgba($session-color-primary, 0.3);
}
padding: $session-margin-xs $session-margin-sm;
font-size: $session-font-xs;
text-align: center;
}
}
&-container {
font-family: 'SF Pro Text';
color: $session-color-white;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 600px;
min-width: 420px;
padding: 3 * $session-margin-lg 2 * $session-margin-lg;
box-sizing: border-box;
background-color: $session-shade-4;
border: 1px solid $session-shade-8;
border-radius: 2px;
.warning-info-area,
.password-info-area {
display: inline-flex;
justify-content: center;
align-items: center;
h1 {
color: $session-color-white;
}
svg {
margin-right: $session-margin-lg;
}
}
p,
input {
margin: $session-margin-lg 0px;
}
.button-group {
display: inline-flex;
}
#password-prompt-input {
width: 100%;
color: #fff;
background-color: #2e2e2e;
margin-top: 2 * $session-margin-lg;
padding: $session-margin-xs $session-margin-lg;
outline: none;
border: none;
border-radius: 2px;
text-align: center;
font-size: 24px;
letter-spacing: 5px;
font-family: 'SF Pro Text';
}
}
}
}

@ -12,7 +12,6 @@
@import 'emoji';
@import 'mentions';
@import 'settings';
@import 'password';
// Build the main view
@import 'index';
@ -22,10 +21,16 @@
@import 'ios';
@import 'theme_dark';
// Session
// /////////////////// //
// ///// Session ///// //
// /////////////////// //
@import 'modules';
@import 'session';
// Separate screens
@import 'session_signin';
@import 'session_password';
@import 'session_theme';
@import 'session_left_pane';
@import 'session_group_panel';

@ -586,7 +586,6 @@
<script type="text/javascript" src="crypto_test.js"></script>
<script type="text/javascript" src="database_test.js"></script>
<script type="text/javascript" src="i18n_test.js"></script>
<script type="text/javascript" src="spellcheck_test.js"></script>
<script type="text/javascript" src="fixtures.js"></script>
<script type="text/javascript" src="fixtures_test.js"></script>

@ -1,12 +0,0 @@
describe('spellChecker', () => {
it('should work', () => {
assert(
window.spellChecker.spellCheck('correct'),
'Spellchecker returned false on a correct word.'
);
assert(
!window.spellChecker.spellCheck('fhqwgads'),
'Spellchecker returned true on a incorrect word.'
);
});
});

@ -85,6 +85,7 @@ export class DevicePairingDialog extends React.Component<Props, State> {
onClose={this.closeDialog}
>
<div className="session-modal__centered">
<div className="spacer-lg" />
{this.renderErrors()}
<input
type="text"
@ -107,14 +108,21 @@ export class DevicePairingDialog extends React.Component<Props, State> {
return (
<SessionModal
title={window.i18n('allowPairingWithDevice')}
title={window.i18n('allowPairing')}
onOk={() => null}
onClose={this.closeDialog}
>
<div className="session-modal__centered">
<h4 className="device-pairing-dialog__desc">
{window.i18n('allowPairingWithDevice')}
</h4>
{this.renderErrors()}
<label>{window.i18n('secretWords')}</label>
<div className="text-subtle">{secretWords}</div>
<div className="device-pairing-dialog__secret-words">
<label>{window.i18n('secretWords')}</label>
<div className="subtle">{secretWords}</div>
</div>
<div className="session-modal__button-group">
<SessionButton
text={window.i18n('cancel')}
@ -140,9 +148,7 @@ export class DevicePairingDialog extends React.Component<Props, State> {
<div className="session-modal__centered">
{this.renderErrors()}
<h4>{window.i18n('waitingForDeviceToRegister')}</h4>
<small className="text-subtle">
{window.i18n('pairNewDevicePrompt')}
</small>
<small className="subtle">{window.i18n('pairNewDevicePrompt')}</small>
<div className="spacer-lg" />
<div className="qr-image">
@ -191,7 +197,7 @@ export class DevicePairingDialog extends React.Component<Props, State> {
<p className="session-modal__description">
{window.i18n('confirmUnpairingTitle')}
<br />
<span className="text-subtle">{description}</span>
<span className="subtle">{description}</span>
</p>
<div className="spacer-xs" />
<div className="session-modal__button-group">
@ -282,6 +288,7 @@ export class DevicePairingDialog extends React.Component<Props, State> {
this.closeDialog();
window.pushToast({
title: window.i18n('devicePairedSuccessfully'),
type: 'success',
});
const conv = window.ConversationController.get(this.state.currentPubKey);
if (conv) {

@ -14,9 +14,16 @@ export const MainViewController = {
},
renderSettingsView: (category: SessionSettingCategory) => {
// tslint:disable-next-line: no-backbone-get-set-outside-model
const isSecondaryDevice = !!window.textsecure.storage.get(
'isSecondaryDevice'
);
if (document.getElementById('main-view')) {
ReactDOM.render(
<SettingsView category={category} />,
<SettingsView
category={category}
isSecondaryDevice={isSecondaryDevice}
/>,
document.getElementById('main-view')
);
}

@ -419,6 +419,7 @@ export class ConversationHeader extends React.Component<Props> {
$('.session-search-input input').focus();
}
// tslint:disable-next-line: cyclomatic-complexity
private renderPublicMenuItems() {
const {
i18n,
@ -438,9 +439,10 @@ export class ConversationHeader extends React.Component<Props> {
timerOptions,
onBlockUser,
onUnblockUser,
hasNickname,
onClearNickname,
onChangeNickname,
// hasNickname,
// onClearNickname,
// onChangeNickname,
isFriend,
} = this.props;
if (isPublic || isRss) {
@ -452,7 +454,7 @@ export class ConversationHeader extends React.Component<Props> {
const blockTitle = isBlocked ? i18n('unblockUser') : i18n('blockUser');
const blockHandler = isBlocked ? onUnblockUser : onBlockUser;
const disappearingMessagesMenuItem = (
const disappearingMessagesMenuItem = isFriend && (
<SubMenu title={disappearingTitle}>
{(timerOptions || []).map(item => (
<MenuItem
@ -475,21 +477,22 @@ export class ConversationHeader extends React.Component<Props> {
{i18n('showSafetyNumber')}
</MenuItem>
);
const resetSessionMenuItem = !isGroup && (
<MenuItem onClick={onResetSession}>{i18n('resetSession')}</MenuItem>
);
const blockHandlerMenuItem = !isMe &&
!isGroup &&
!isRss && <MenuItem onClick={blockHandler}>{blockTitle}</MenuItem>;
const changeNicknameMenuItem = !isMe &&
const resetSessionMenuItem = isFriend &&
!isGroup && (
<MenuItem onClick={onChangeNickname}>{i18n('changeNickname')}</MenuItem>
<MenuItem onClick={onResetSession}>{i18n('resetSession')}</MenuItem>
);
const clearNicknameMenuItem = !isMe &&
const blockHandlerMenuItem = !isMe &&
!isGroup &&
hasNickname && (
<MenuItem onClick={onClearNickname}>{i18n('clearNickname')}</MenuItem>
);
!isRss && <MenuItem onClick={blockHandler}>{blockTitle}</MenuItem>;
// const changeNicknameMenuItem = !isMe &&
// !isGroup && (
// <MenuItem onClick={onChangeNickname}>{i18n('changeNickname')}</MenuItem>
// );
// const clearNicknameMenuItem = !isMe &&
// !isGroup &&
// hasNickname && (
// <MenuItem onClick={onClearNickname}>{i18n('clearNickname')}</MenuItem>
// );
const archiveConversationMenuItem = isArchived ? (
<MenuItem onClick={onMoveToInbox}>
{i18n('moveConversationToInbox')}
@ -506,8 +509,8 @@ export class ConversationHeader extends React.Component<Props> {
{showSafetyNumberMenuItem}
{resetSessionMenuItem}
{blockHandlerMenuItem}
{changeNicknameMenuItem}
{clearNicknameMenuItem}
{/* {changeNicknameMenuItem}
{clearNicknameMenuItem} */}
{archiveConversationMenuItem}
</React.Fragment>
);

@ -1116,11 +1116,19 @@ export class Message extends React.PureComponent<Props, State> {
expiring ? 'module-message--expired' : null
)}
role="button"
onClick={() => {
onClick={event => {
const selection = window.getSelection();
// Text is being selected
if (selection && selection.type === 'Range') {
return;
}
// User clicked on message body
const target = event.target as HTMLDivElement;
if (target.className === 'text-selectable') {
return;
}
this.props.onSelectMessage();
}}
>

@ -93,7 +93,7 @@ export class UpdateGroupMembersDialog extends React.Component<Props, State> {
noFriendsClasses = classNames('no-friends', 'hidden');
} else {
// private group
titleText = `${this.props.titleText} (Members: ${checkMarkedCount})`;
titleText = this.props.titleText;
noFriendsClasses =
this.state.friendList.length === 0
? 'no-friends'
@ -114,6 +114,16 @@ export class UpdateGroupMembersDialog extends React.Component<Props, State> {
onOk={() => null}
>
<div className="spacer-md" />
{!this.props.isPublic && (
<>
<small className="create-group-dialog__member-count">
{`${checkMarkedCount} members`}
</small>
<hr className="subtle" />
</>
)}
<p className={errorMessageClasses}>{errorMsg}</p>
<div className="spacer-md" />
@ -124,6 +134,8 @@ export class UpdateGroupMembersDialog extends React.Component<Props, State> {
'noMembersInThisGroup'
)})`}</p>
<div className="spacer-lg" />
<div className="session-modal__button-group">
<SessionButton text={okText} onClick={this.onClickOK} />

@ -106,26 +106,16 @@ export class ActionsPanel extends React.Component<Props, State> {
default:
iconType = SessionIconType.Moon;
}
if (!isSelected) {
return (
<SessionIconButton
iconSize={SessionIconSize.Medium}
iconType={iconType}
notificationCount={notificationCount}
onClick={handleClick}
/>
);
} else {
return (
<SessionIconButton
iconSize={SessionIconSize.Medium}
iconType={iconType}
notificationCount={notificationCount}
onClick={handleClick}
isSelected={isSelected}
/>
);
}
return (
<SessionIconButton
iconSize={SessionIconSize.Medium}
iconType={iconType}
notificationCount={notificationCount}
onClick={handleClick}
isSelected={isSelected}
/>
);
};
public editProfileHandle() {

@ -1,6 +1,10 @@
import React from 'react';
import classNames from 'classnames';
import { SessionButton } from './SessionButton';
import {
NotificationCountSize,
SessionNotificationCount,
} from './SessionNotificationCount';
const Tab = ({
isSelected,
@ -89,8 +93,6 @@ export class LeftPaneSectionHeader extends React.Component<Props, State> {
/>
);
} else if (buttonLabel && notificationCount && notificationCount > 0) {
const shortenedNotificationCount =
notificationCount > 9 ? 9 : notificationCount;
children.push(
<div className="contact-notification-section">
<SessionButton
@ -99,26 +101,20 @@ export class LeftPaneSectionHeader extends React.Component<Props, State> {
key="compose"
disabled={false}
/>
<div
className="contact-notification-count-bubble"
<SessionNotificationCount
count={notificationCount}
size={NotificationCountSize.ON_HEADER}
onClick={this.props.buttonClicked}
role="button"
>
{shortenedNotificationCount}
</div>
/>
</div>
);
} else if (notificationCount && notificationCount > 0) {
const shortenedNotificationCount =
notificationCount > 9 ? 9 : notificationCount;
children.push(
<div
className="contact-notification-count-bubble"
<SessionNotificationCount
count={notificationCount}
size={NotificationCountSize.ON_HEADER}
onClick={this.props.buttonClicked}
role="button"
>
{shortenedNotificationCount}
</div>
/>
);
}

@ -39,6 +39,7 @@ interface State {
passwordErrorString: string;
passwordFieldsMatch: boolean;
mnemonicSeed: string;
generatedMnemonicSeed: string;
hexGeneratedPubKey: string;
primaryDevicePubKey: string;
mnemonicError: string | undefined;
@ -113,6 +114,7 @@ export class RegistrationTabs extends React.Component<{}, State> {
passwordErrorString: '',
passwordFieldsMatch: false,
mnemonicSeed: '',
generatedMnemonicSeed: '',
hexGeneratedPubKey: '',
primaryDevicePubKey: '',
mnemonicError: undefined,
@ -125,39 +127,11 @@ export class RegistrationTabs extends React.Component<{}, State> {
window.textsecure.storage.remove('secondaryDeviceStatus');
}
public render() {
public componentDidMount() {
this.generateMnemonicAndKeyPair().ignore();
return this.renderTabs();
}
private async generateMnemonicAndKeyPair() {
if (this.state.mnemonicSeed === '') {
const language = 'english';
const mnemonic = await this.accountManager.generateMnemonic(language);
let seedHex = window.mnemonic.mn_decode(mnemonic, language);
// handle shorter than 32 bytes seeds
const privKeyHexLength = 32 * 2;
if (seedHex.length !== privKeyHexLength) {
seedHex = seedHex.concat(seedHex);
seedHex = seedHex.substring(0, privKeyHexLength);
}
const seed = window.dcodeIO.ByteBuffer.wrap(
seedHex,
'hex'
).toArrayBuffer();
const keyPair = await window.libsignal.Curve.async.createKeyPair(seed);
const hexGeneratedPubKey = Buffer.from(keyPair.pubKey).toString('hex');
this.setState({
mnemonicSeed: mnemonic,
hexGeneratedPubKey, // our 'frontend' sessionID
});
}
}
private renderTabs() {
public render() {
const { selectedTab } = this.state;
const createAccount = window.i18n('createAccount');
@ -186,6 +160,32 @@ export class RegistrationTabs extends React.Component<{}, State> {
);
}
private async generateMnemonicAndKeyPair() {
if (this.state.generatedMnemonicSeed === '') {
const language = 'english';
const mnemonic = await this.accountManager.generateMnemonic(language);
let seedHex = window.mnemonic.mn_decode(mnemonic, language);
// handle shorter than 32 bytes seeds
const privKeyHexLength = 32 * 2;
if (seedHex.length !== privKeyHexLength) {
seedHex = seedHex.concat(seedHex);
seedHex = seedHex.substring(0, privKeyHexLength);
}
const seed = window.dcodeIO.ByteBuffer.wrap(
seedHex,
'hex'
).toArrayBuffer();
const keyPair = await window.libsignal.Curve.async.createKeyPair(seed);
const hexGeneratedPubKey = Buffer.from(keyPair.pubKey).toString('hex');
this.setState({
generatedMnemonicSeed: mnemonic,
hexGeneratedPubKey, // our 'frontend' sessionID
});
}
}
private readonly handleTabSelect = (tabType: TabType): void => {
if (tabType !== TabType.SignIn) {
this.cancelSecondaryDevice().ignore();
@ -200,7 +200,6 @@ export class RegistrationTabs extends React.Component<{}, State> {
passwordErrorString: '',
passwordFieldsMatch: false,
mnemonicSeed: '',
hexGeneratedPubKey: '',
primaryDevicePubKey: '',
mnemonicError: undefined,
displayNameError: undefined,
@ -731,15 +730,19 @@ export class RegistrationTabs extends React.Component<{}, State> {
const {
password,
mnemonicSeed,
generatedMnemonicSeed,
signInMode,
displayName,
passwordErrorString,
passwordFieldsMatch,
} = this.state;
// Make sure the password is valid
window.log.info('starting registration');
const trimName = displayName.trim();
if (!trimName) {
window.log.warn('invalid trimmed name for registration');
window.pushToast({
title: window.i18n('displayNameEmpty'),
type: 'error',
@ -750,6 +753,7 @@ export class RegistrationTabs extends React.Component<{}, State> {
}
if (passwordErrorString) {
window.log.warn('invalid password for registration');
window.pushToast({
title: window.i18n('invalidPassword'),
type: 'error',
@ -760,6 +764,8 @@ export class RegistrationTabs extends React.Component<{}, State> {
}
if (!!password && !passwordFieldsMatch) {
window.log.warn('passwords does not match for registration');
window.pushToast({
title: window.i18n('passwordsDoNotMatch'),
type: 'error',
@ -769,28 +775,48 @@ export class RegistrationTabs extends React.Component<{}, State> {
return;
}
if (!mnemonicSeed) {
if (signInMode === SignInMode.UsingSeed && !mnemonicSeed) {
window.log.warn('empty mnemonic seed passed in seed restoration mode');
return;
} else if (!generatedMnemonicSeed) {
window.log.warn('empty generated seed');
return;
}
// Ensure we clear the secondary device registration status
window.textsecure.storage.remove('secondaryDeviceStatus');
const seedToUse =
signInMode === SignInMode.UsingSeed
? mnemonicSeed
: generatedMnemonicSeed;
try {
await this.resetRegistration();
await window.setPassword(password);
await this.accountManager.registerSingleDevice(
mnemonicSeed,
seedToUse,
language,
trimName
);
trigger('openInbox');
} catch (e) {
if (typeof e === 'string') {
//this.showToast(e);
window.pushToast({
title: `Error: ${e.message || 'Something went wrong'}`,
type: 'error',
id: 'registrationError',
});
let exmsg = '';
if (e.message) {
exmsg += e.message;
}
if (e.stack) {
exmsg += ` | stack: + ${e.stack}`;
}
//this.log(e);
window.log.warn('exception during registration:', exmsg);
}
}
@ -804,8 +830,12 @@ export class RegistrationTabs extends React.Component<{}, State> {
}
private async registerSecondaryDevice() {
window.log.warn('starting registerSecondaryDevice');
// tslint:disable-next-line: no-backbone-get-set-outside-model
if (window.textsecure.storage.get('secondaryDeviceStatus') === 'ongoing') {
window.log.warn('registering secondary device already ongoing');
return;
}
this.setState({

@ -176,9 +176,8 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
value={this.state.groupName}
maxLength={window.CONSTANTS.MAX_GROUPNAME_LENGTH}
onChange={this.onGroupNameChanged}
onPressEnter={() => onButtonClick(this.state.groupName)}
/>
{/* */}
</div>
) : (
<SessionIdEditable

@ -48,7 +48,7 @@ export class SessionConfirm extends React.Component<Props> {
const messageSubText = messageSub
? 'session-confirm-main-message'
: 'text-subtle';
: 'subtle';
return (
<SessionModal
@ -63,7 +63,7 @@ export class SessionConfirm extends React.Component<Props> {
<div className="session-modal__centered">
<span className={messageSubText}>{message}</span>
{messageSub && (
<span className="session-confirm-sub-message text-subtle">
<span className="session-confirm-sub-message subtle">
{messageSub}
</span>
)}

@ -240,7 +240,7 @@ export class SessionGroupSettings extends React.Component<Props, any> {
{showMemberCount && (
<>
<div className="spacer-lg" />
<div role="button" className="text-subtle">
<div role="button" className="subtle">
{window.i18n('members', memberCount)}
</div>
<div className="spacer-lg" />

@ -45,6 +45,7 @@ export class SessionIdEditable extends React.PureComponent<Props> {
spellCheck={false}
onKeyDown={this.handleKeyDown}
onChange={this.handleChange}
onBlur={this.handleChange}
value={value || text}
maxLength={maxLength}
/>
@ -61,7 +62,7 @@ export class SessionIdEditable extends React.PureComponent<Props> {
private handleKeyDown(e: any) {
const { editable, onPressEnter } = this.props;
if (editable && e.keyCode === 13) {
if (editable && e.key === 'Enter') {
e.preventDefault();
// tslint:disable-next-line: no-unused-expression
onPressEnter && onPressEnter();

@ -61,6 +61,10 @@ export class SessionInput extends React.PureComponent<Props, State> {
className={classNames(
enableShowHide ? 'session-input-floating-label-show-hide' : ''
)}
// just incase onChange isn't triggered
onBlur={e => {
this.updateInputValue(e);
}}
onKeyPress={event => {
event.persist();
if (event.key === 'Enter' && this.props.onEnterPressed) {

@ -0,0 +1,77 @@
import React from 'react';
import classNames from 'classnames';
export enum NotificationCountSize {
// Size in px
ON_ICON = 20,
ON_HEADER = 24,
}
interface Props {
count?: number;
size: number;
onClick?: any;
}
export class SessionNotificationCount extends React.Component<Props> {
public static defaultProps = {
size: NotificationCountSize.ON_ICON,
};
constructor(props: any) {
super(props);
}
public render() {
const { count, size, onClick } = this.props;
const hasHover = !!onClick;
const MAX_SINGLE_DIGIT = 9;
const overflow = typeof count === 'number' && count > MAX_SINGLE_DIGIT;
const fontSizeVal = overflow ? size / 2 : size / 2 + 2;
const fontSize = `${fontSizeVal}px`;
const bubbleStyle = {
width: `${size}px`,
height: `${size}px`,
};
const countStyle = {
fontSize,
marginTop: overflow ? `${size / 8}px` : '0px',
marginLeft: overflow ? `-${size / 4}px` : '0px',
};
const supStyle = {
top: `-${size * (3 / 8)}px`,
};
const countElement: JSX.Element = overflow ? (
<>
{MAX_SINGLE_DIGIT}
<sup style={supStyle}>+</sup>
</>
) : (
<>{count}</>
);
const shouldRender = typeof count === 'number' && count > 0;
return (
<>
{shouldRender && (
<div
className={classNames('notification-count', hasHover && 'hover')}
onClick={onClick}
style={bubbleStyle}
role="button"
>
<span style={countStyle}>{countElement}</span>
</div>
)}
</>
);
}
}

@ -20,6 +20,9 @@ interface State {
}
export class SessionPasswordModal extends React.Component<Props, State> {
private readonly passwordInput: React.RefObject<HTMLInputElement>;
private readonly passwordInputConfirm: React.RefObject<HTMLInputElement>;
constructor(props: any) {
super(props);
@ -33,10 +36,20 @@ export class SessionPasswordModal extends React.Component<Props, State> {
this.closeDialog = this.closeDialog.bind(this);
this.onKeyUp = this.onKeyUp.bind(this);
this.onPaste = this.onPaste.bind(this);
this.passwordInput = React.createRef();
this.passwordInputConfirm = React.createRef();
}
public componentDidMount() {
setTimeout(() => $('#password-modal-input').focus(), 100);
setTimeout(() => {
if (!this.passwordInput.current) {
return;
}
this.passwordInput.current.focus();
}, 100);
}
public render() {
@ -63,17 +76,21 @@ export class SessionPasswordModal extends React.Component<Props, State> {
<input
type="password"
id="password-modal-input"
ref={this.passwordInput}
placeholder={placeholders[0]}
onKeyUp={this.onKeyUp}
maxLength={window.CONSTANTS.MAX_PASSWORD_LENGTH}
onPaste={this.onPaste}
/>
{action !== PasswordAction.Remove && (
<input
type="password"
id="password-modal-input-confirm"
ref={this.passwordInputConfirm}
placeholder={placeholders[1]}
onKeyUp={this.onKeyUp}
maxLength={window.CONSTANTS.MAX_PASSWORD_LENGTH}
onPaste={this.onPaste}
/>
)}
</div>
@ -123,16 +140,21 @@ export class SessionPasswordModal extends React.Component<Props, State> {
}
private async setPassword(onSuccess: any) {
const enteredPassword = String($('#password-modal-input').val());
if (!this.passwordInput.current || !this.passwordInputConfirm.current) {
return;
}
// Trim leading / trailing whitespace for UX
const enteredPassword = String(this.passwordInput.current.value).trim();
const enteredPasswordConfirm = String(
$('#password-modal-input-confirm').val()
);
this.passwordInputConfirm.current.value
).trim();
if (enteredPassword.length === 0 || enteredPasswordConfirm.length === 0) {
return;
}
// Check passwords enntered
// Check passwords entered
if (
enteredPassword.length === 0 ||
(this.props.action === PasswordAction.Change &&
@ -191,6 +213,27 @@ export class SessionPasswordModal extends React.Component<Props, State> {
}
}
private onPaste(event: any) {
const clipboard = event.clipboardData.getData('text');
if (clipboard.length > window.CONSTANTS.MAX_PASSWORD_LENGTH) {
const title = String(
window.i18n(
'pasteLongPasswordToastTitle',
window.CONSTANTS.MAX_PASSWORD_LENGTH
)
);
window.pushToast({
title,
type: 'warning',
});
}
// Prevent pating into input
return false;
}
private async onKeyUp(event: any) {
const { onOk } = this.props;

@ -15,6 +15,8 @@ interface State {
}
export class SessionPasswordPrompt extends React.PureComponent<{}, State> {
private readonly inputRef: React.RefObject<HTMLInputElement>;
constructor(props: any) {
super(props);
@ -25,12 +27,16 @@ export class SessionPasswordPrompt extends React.PureComponent<{}, State> {
};
this.onKeyUp = this.onKeyUp.bind(this);
this.onPaste = this.onPaste.bind(this);
this.initLogin = this.initLogin.bind(this);
this.initClearDataView = this.initClearDataView.bind(this);
this.inputRef = React.createRef();
}
public componentDidMount() {
setTimeout(() => $('#password-prompt-input').focus(), 100);
(this.inputRef.current as HTMLInputElement).focus();
}
public render() {
@ -62,6 +68,8 @@ export class SessionPasswordPrompt extends React.PureComponent<{}, State> {
placeholder={' '}
onKeyUp={this.onKeyUp}
maxLength={window.CONSTANTS.MAX_PASSWORD_LENGTH}
onPaste={this.onPaste}
ref={this.inputRef}
/>
);
const infoIcon = this.state.clearDataView ? (
@ -120,23 +128,43 @@ export class SessionPasswordPrompt extends React.PureComponent<{}, State> {
event.preventDefault();
}
public onPaste(event: any) {
const clipboard = event.clipboardData.getData('text');
if (clipboard.length > window.CONSTANTS.MAX_PASSWORD_LENGTH) {
this.setState({
error: String(
window.i18n(
'pasteLongPasswordToastTitle',
window.CONSTANTS.MAX_PASSWORD_LENGTH
)
),
});
}
// Prevent pasting into input
return false;
}
public async onLogin(passPhrase: string) {
const trimmed = passPhrase ? passPhrase.trim() : passPhrase;
const passPhraseTrimmed = passPhrase.trim();
try {
await window.onLogin(trimmed);
} catch (e) {
await window.onLogin(passPhraseTrimmed);
} catch (error) {
// Increment the error counter and show the button if necessary
this.setState({
errorCount: this.state.errorCount + 1,
});
this.setState({ error: e });
this.setState({ error });
}
}
private async initLogin() {
const passPhrase = String($('#password-prompt-input').val());
const passPhrase = String(
(this.inputRef.current as HTMLInputElement).value
);
await this.onLogin(passPhrase);
}

@ -29,7 +29,7 @@ export class SessionQRModal extends React.Component<Props> {
>
<div className="spacer-sm" />
<div className="qr-dialog__description text-subtle">
<div className="qr-dialog__description subtle">
<SessionHtmlRenderer html={window.i18n('QRCodeDescription')} />
</div>
<div className="spacer-lg" />

@ -121,7 +121,7 @@ export class SessionSeedModal extends React.Component<Props, State> {
<p className="session-modal__description">
{i18n('seedSavePromptMain')}
<br />
<span className="text-subtle">{i18n('seedSavePromptAlt')}</span>
<span className="subtle">{i18n('seedSavePromptAlt')}</span>
</p>
<div className="spacer-xs" />

@ -1,11 +1,12 @@
import React from 'react';
import classNames from 'classnames';
import { Props, SessionIcon } from '../icon';
import { SessionNotificationCount } from '../SessionNotificationCount';
interface SProps extends Props {
onClick: any;
notificationCount: number | undefined;
notificationCount?: number;
isSelected: boolean;
}
@ -35,13 +36,7 @@ export class SessionIconButton extends React.PureComponent<SProps> {
isSelected,
} = this.props;
let { notificationCount } = this.props;
if (notificationCount === 0) {
notificationCount = undefined;
} else if (notificationCount !== undefined && notificationCount > 9) {
notificationCount = 9;
}
const { notificationCount } = this.props;
return (
<div
@ -62,9 +57,7 @@ export class SessionIconButton extends React.PureComponent<SProps> {
iconColor={iconColor}
iconRotation={iconRotation}
/>
{notificationCount !== undefined && (
<span className="notification-count">{notificationCount}</span>
)}
<SessionNotificationCount count={notificationCount} />
</div>
);
}

@ -27,6 +27,7 @@ export enum SessionSettingType {
export interface SettingsViewProps {
category: SessionSettingCategory;
isSecondaryDevice: boolean;
}
interface State {
@ -115,9 +116,14 @@ export class SettingsView extends React.Component<SettingsViewProps, State> {
const description = setting.description || '';
const comparisonValue = setting.comparisonValue || null;
const storedSetting = window.getSettingValue(
setting.id,
comparisonValue
);
const value =
window.getSettingValue(setting.id, comparisonValue) ||
(setting.content && setting.content.defaultValue);
storedSetting !== undefined
? storedSetting
: setting.content && setting.content.defaultValue;
const sliderFn =
setting.type === SessionSettingType.Slider
@ -221,7 +227,7 @@ export class SettingsView extends React.Component<SettingsViewProps, State> {
}
public render() {
const { category } = this.props;
const { category, isSecondaryDevice } = this.props;
const shouldRenderPasswordLock =
this.state.shouldLockSettings && this.state.hasPassword;
@ -230,6 +236,7 @@ export class SettingsView extends React.Component<SettingsViewProps, State> {
<SettingsHeader
showLinkDeviceButton={!shouldRenderPasswordLock}
category={category}
isSecondaryDevice={isSecondaryDevice}
/>
<div className="session-settings-view">
@ -354,7 +361,7 @@ export class SettingsView extends React.Component<SettingsViewProps, State> {
type: SessionSettingType.Toggle,
category: SessionSettingCategory.Appearance,
setFn: window.toggleSpellCheck,
content: undefined,
content: { defaultValue: true },
comparisonValue: undefined,
onClick: undefined,
confirmationDialogParams: undefined,
@ -574,6 +581,10 @@ export class SettingsView extends React.Component<SettingsViewProps, State> {
private getLinkedDeviceSettings(): Array<LocalSettingType> {
const { linkedPubKeys } = this.state;
const { isSecondaryDevice } = this.props;
const noPairedDeviceText = isSecondaryDevice
? window.i18n('deviceIsSecondaryNoPairing')
: window.i18n('noPairedDevices');
if (linkedPubKeys && linkedPubKeys.length > 0) {
return linkedPubKeys.map((pubkey: any) => {
@ -621,7 +632,7 @@ export class SettingsView extends React.Component<SettingsViewProps, State> {
return [
{
id: 'no-linked-device',
title: window.i18n('noPairedDevices'),
title: noPairedDeviceText,
type: undefined,
description: '',
category: SessionSettingCategory.Devices,

@ -5,20 +5,23 @@ import { SessionSettingCategory, SettingsViewProps } from './SessionSettings';
import { SessionButton } from '../SessionButton';
interface Props extends SettingsViewProps {
// showLinkDeviceButton is used to completely hide the button while the settings password lock is displayed
showLinkDeviceButton: boolean | null;
disableLinkDeviceButton: boolean | null;
// isSecondaryDevice is used to just disable the linkDeviceButton when we are already a secondary device
isSecondaryDevice: boolean;
}
export class SettingsHeader extends React.Component<Props, any> {
public static defaultProps = {
showLinkDeviceButton: false,
disableLinkDeviceButton: true,
};
public constructor(props: any) {
super(props);
// mark the linkDeviceButton as disabled by default.
// it will be enabled if needed during componentDidMount().
this.state = {
disableLinkDeviceButton: this.props.disableLinkDeviceButton,
disableLinkDeviceButton: true,
};
this.showAddLinkedDeviceModal = this.showAddLinkedDeviceModal.bind(this);
}
@ -32,10 +35,12 @@ export class SettingsHeader extends React.Component<Props, any> {
}
public componentDidMount() {
window.Whisper.events.on('refreshLinkedDeviceList', async () => {
if (!this.props.isSecondaryDevice) {
window.Whisper.events.on('refreshLinkedDeviceList', async () => {
this.refreshLinkedDevice();
});
this.refreshLinkedDevice();
});
this.refreshLinkedDevice();
}
}
public refreshLinkedDevice() {
@ -51,7 +56,9 @@ export class SettingsHeader extends React.Component<Props, any> {
}
public componentWillUnmount() {
window.Whisper.events.off('refreshLinkedDeviceList');
if (!this.props.isSecondaryDevice) {
window.Whisper.events.off('refreshLinkedDeviceList');
}
}
public render() {

@ -37,11 +37,9 @@ export async function showDownloadUpdateDialog(
cancelId: DOWNLOAD_BUTTON,
};
return new Promise(resolve => {
dialog.showMessageBox(mainWindow, options, response => {
resolve(response === DOWNLOAD_BUTTON);
});
});
const ret = await dialog.showMessageBox(mainWindow, options);
return ret.response === DOWNLOAD_BUTTON;
}
export async function showUpdateDialog(
@ -62,33 +60,22 @@ export async function showUpdateDialog(
defaultId: LATER_BUTTON,
cancelId: RESTART_BUTTON,
};
const ret = await dialog.showMessageBox(mainWindow, options);
return new Promise(resolve => {
dialog.showMessageBox(mainWindow, options, response => {
// It's key to delay any install calls here because they don't seem to work inside this
// callback - but only if the message box has a parent window.
// Fixes this: https://github.com/signalapp/Signal-Desktop/issues/1864
resolve(response === RESTART_BUTTON);
});
});
return ret.response === RESTART_BUTTON;
}
export async function showCannotUpdateDialog(
mainWindow: BrowserWindow,
messages: MessagesType
): Promise<boolean> {
) {
const options = {
type: 'error',
buttons: [messages.ok.message],
title: messages.cannotUpdate.message,
message: messages.cannotUpdateDetail.message,
};
return new Promise(resolve => {
dialog.showMessageBox(mainWindow, options, () => {
resolve();
});
});
await dialog.showMessageBox(mainWindow, options);
}
export function getPrintableError(error: Error) {

@ -55,8 +55,8 @@ export function stop() {
if (interval) {
clearInterval(interval);
interval = undefined;
stopped = true;
}
stopped = true;
}
async function checkForUpdates(

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save