Faster WebSocket reconnects
This commit is contained in:
parent
3cac4a19e1
commit
17e6ec468e
|
@ -168,15 +168,15 @@
|
||||||
this.interval = setInterval(() => {
|
this.interval = setInterval(() => {
|
||||||
const status = window.getSocketStatus();
|
const status = window.getSocketStatus();
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case WebSocket.CONNECTING:
|
case 'CONNECTING':
|
||||||
break;
|
break;
|
||||||
case WebSocket.OPEN:
|
case 'OPEN':
|
||||||
clearInterval(this.interval);
|
clearInterval(this.interval);
|
||||||
// if we've connected, we can wait for real empty event
|
// if we've connected, we can wait for real empty event
|
||||||
this.interval = null;
|
this.interval = null;
|
||||||
break;
|
break;
|
||||||
case WebSocket.CLOSING:
|
case 'CLOSING':
|
||||||
case WebSocket.CLOSED:
|
case 'CLOSED':
|
||||||
clearInterval(this.interval);
|
clearInterval(this.interval);
|
||||||
this.interval = null;
|
this.interval = null;
|
||||||
// if we failed to connect, we pretend we got an empty event
|
// if we failed to connect, we pretend we got an empty event
|
||||||
|
@ -184,7 +184,7 @@
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
window.log.warn(
|
window.log.warn(
|
||||||
'startConnectionListener: Found unexpected socket status; calling onEmpty() manually.'
|
`startConnectionListener: Found unexpected socket status ${status}; calling onEmpty() manually.`
|
||||||
);
|
);
|
||||||
this.onEmpty();
|
this.onEmpty();
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -14,7 +14,12 @@ const fakeAPI = {
|
||||||
getAvatar: fakeCall,
|
getAvatar: fakeCall,
|
||||||
getDevices: fakeCall,
|
getDevices: fakeCall,
|
||||||
// getKeysForIdentifier : fakeCall,
|
// getKeysForIdentifier : fakeCall,
|
||||||
getMessageSocket: () => new window.MockSocket('ws://localhost:8081/'),
|
getMessageSocket: async () => ({
|
||||||
|
on() {},
|
||||||
|
removeListener() {},
|
||||||
|
close() {},
|
||||||
|
sendBytes() {},
|
||||||
|
}),
|
||||||
getMyKeys: fakeCall,
|
getMyKeys: fakeCall,
|
||||||
getProfile: fakeCall,
|
getProfile: fakeCall,
|
||||||
getProvisioningSocket: fakeCall,
|
getProvisioningSocket: fakeCall,
|
||||||
|
|
|
@ -37,7 +37,6 @@
|
||||||
<script type="text/javascript" src="crypto_test.js"></script>
|
<script type="text/javascript" src="crypto_test.js"></script>
|
||||||
<script type="text/javascript" src="contacts_parser_test.js"></script>
|
<script type="text/javascript" src="contacts_parser_test.js"></script>
|
||||||
<script type="text/javascript" src="generate_keys_test.js"></script>
|
<script type="text/javascript" src="generate_keys_test.js"></script>
|
||||||
<script type="text/javascript" src="websocket-resources_test.js"></script>
|
|
||||||
<script type="text/javascript" src="task_with_timeout_test.js"></script>
|
<script type="text/javascript" src="task_with_timeout_test.js"></script>
|
||||||
<script type="text/javascript" src="account_manager_test.js"></script>
|
<script type="text/javascript" src="account_manager_test.js"></script>
|
||||||
<script type="text/javascript" src="message_receiver_test.js"></script>
|
<script type="text/javascript" src="message_receiver_test.js"></script>
|
||||||
|
|
|
@ -1,237 +0,0 @@
|
||||||
// Copyright 2015-2020 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
describe('WebSocket-Resource', () => {
|
|
||||||
describe('requests and responses', () => {
|
|
||||||
it('receives requests and sends responses', done => {
|
|
||||||
// mock socket
|
|
||||||
const requestId = '1';
|
|
||||||
const socket = {
|
|
||||||
send(data) {
|
|
||||||
const message = window.textsecure.protobuf.WebSocketMessage.decode(
|
|
||||||
data
|
|
||||||
);
|
|
||||||
assert.strictEqual(
|
|
||||||
message.type,
|
|
||||||
window.textsecure.protobuf.WebSocketMessage.Type.RESPONSE
|
|
||||||
);
|
|
||||||
assert.strictEqual(message.response.message, 'OK');
|
|
||||||
assert.strictEqual(message.response.status, 200);
|
|
||||||
assert.strictEqual(message.response.id.toString(), requestId);
|
|
||||||
done();
|
|
||||||
},
|
|
||||||
addEventListener() {},
|
|
||||||
};
|
|
||||||
|
|
||||||
// actual test
|
|
||||||
this.resource = new window.textsecure.WebSocketResource(socket, {
|
|
||||||
handleRequest(request) {
|
|
||||||
assert.strictEqual(request.verb, 'PUT');
|
|
||||||
assert.strictEqual(request.path, '/some/path');
|
|
||||||
assertEqualArrayBuffers(
|
|
||||||
request.body.toArrayBuffer(),
|
|
||||||
window.Signal.Crypto.typedArrayToArrayBuffer(
|
|
||||||
new Uint8Array([1, 2, 3])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
request.respond(200, 'OK');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// mock socket request
|
|
||||||
socket.onmessage({
|
|
||||||
data: new Blob([
|
|
||||||
new window.textsecure.protobuf.WebSocketMessage({
|
|
||||||
type: window.textsecure.protobuf.WebSocketMessage.Type.REQUEST,
|
|
||||||
request: {
|
|
||||||
id: requestId,
|
|
||||||
verb: 'PUT',
|
|
||||||
path: '/some/path',
|
|
||||||
body: window.Signal.Crypto.typedArrayToArrayBuffer(
|
|
||||||
new Uint8Array([1, 2, 3])
|
|
||||||
),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.encode()
|
|
||||||
.toArrayBuffer(),
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends requests and receives responses', done => {
|
|
||||||
// mock socket and request handler
|
|
||||||
let requestId;
|
|
||||||
const socket = {
|
|
||||||
send(data) {
|
|
||||||
const message = window.textsecure.protobuf.WebSocketMessage.decode(
|
|
||||||
data
|
|
||||||
);
|
|
||||||
assert.strictEqual(
|
|
||||||
message.type,
|
|
||||||
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST
|
|
||||||
);
|
|
||||||
assert.strictEqual(message.request.verb, 'PUT');
|
|
||||||
assert.strictEqual(message.request.path, '/some/path');
|
|
||||||
assertEqualArrayBuffers(
|
|
||||||
message.request.body.toArrayBuffer(),
|
|
||||||
window.Signal.Crypto.typedArrayToArrayBuffer(
|
|
||||||
new Uint8Array([1, 2, 3])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
requestId = message.request.id;
|
|
||||||
},
|
|
||||||
addEventListener() {},
|
|
||||||
};
|
|
||||||
|
|
||||||
// actual test
|
|
||||||
const resource = new window.textsecure.WebSocketResource(socket);
|
|
||||||
resource.sendRequest({
|
|
||||||
verb: 'PUT',
|
|
||||||
path: '/some/path',
|
|
||||||
body: window.Signal.Crypto.typedArrayToArrayBuffer(
|
|
||||||
new Uint8Array([1, 2, 3])
|
|
||||||
),
|
|
||||||
error: done,
|
|
||||||
success(message, status) {
|
|
||||||
assert.strictEqual(message, 'OK');
|
|
||||||
assert.strictEqual(status, 200);
|
|
||||||
done();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// mock socket response
|
|
||||||
socket.onmessage({
|
|
||||||
data: new Blob([
|
|
||||||
new window.textsecure.protobuf.WebSocketMessage({
|
|
||||||
type: window.textsecure.protobuf.WebSocketMessage.Type.RESPONSE,
|
|
||||||
response: { id: requestId, message: 'OK', status: 200 },
|
|
||||||
})
|
|
||||||
.encode()
|
|
||||||
.toArrayBuffer(),
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('close', () => {
|
|
||||||
before(() => {
|
|
||||||
window.WebSocket = MockSocket;
|
|
||||||
});
|
|
||||||
after(() => {
|
|
||||||
window.WebSocket = WebSocket;
|
|
||||||
});
|
|
||||||
it('closes the connection', done => {
|
|
||||||
const mockServer = new MockServer('ws://localhost:8081');
|
|
||||||
mockServer.on('connection', server => {
|
|
||||||
server.on('close', done);
|
|
||||||
});
|
|
||||||
const resource = new window.textsecure.WebSocketResource(
|
|
||||||
new WebSocket('ws://localhost:8081')
|
|
||||||
);
|
|
||||||
resource.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.skip('with a keepalive config', function thisNeeded() {
|
|
||||||
before(() => {
|
|
||||||
window.WebSocket = MockSocket;
|
|
||||||
});
|
|
||||||
after(() => {
|
|
||||||
window.WebSocket = WebSocket;
|
|
||||||
});
|
|
||||||
this.timeout(60000);
|
|
||||||
it('sends keepalives once a minute', done => {
|
|
||||||
const mockServer = new MockServer('ws://localhost:8081');
|
|
||||||
mockServer.on('connection', server => {
|
|
||||||
server.on('message', data => {
|
|
||||||
const message = window.textsecure.protobuf.WebSocketMessage.decode(
|
|
||||||
data
|
|
||||||
);
|
|
||||||
assert.strictEqual(
|
|
||||||
message.type,
|
|
||||||
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST
|
|
||||||
);
|
|
||||||
assert.strictEqual(message.request.verb, 'GET');
|
|
||||||
assert.strictEqual(message.request.path, '/v1/keepalive');
|
|
||||||
server.close();
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
this.resource = new window.textsecure.WebSocketResource(
|
|
||||||
new WebSocket('ws://loc1alhost:8081'),
|
|
||||||
{
|
|
||||||
keepalive: { path: '/v1/keepalive' },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses / as a default path', done => {
|
|
||||||
const mockServer = new MockServer('ws://localhost:8081');
|
|
||||||
mockServer.on('connection', server => {
|
|
||||||
server.on('message', data => {
|
|
||||||
const message = window.textsecure.protobuf.WebSocketMessage.decode(
|
|
||||||
data
|
|
||||||
);
|
|
||||||
assert.strictEqual(
|
|
||||||
message.type,
|
|
||||||
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST
|
|
||||||
);
|
|
||||||
assert.strictEqual(message.request.verb, 'GET');
|
|
||||||
assert.strictEqual(message.request.path, '/');
|
|
||||||
server.close();
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
this.resource = new window.textsecure.WebSocketResource(
|
|
||||||
new WebSocket('ws://localhost:8081'),
|
|
||||||
{
|
|
||||||
keepalive: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('optionally disconnects if no response', function thisNeeded1(done) {
|
|
||||||
this.timeout(65000);
|
|
||||||
const mockServer = new MockServer('ws://localhost:8081');
|
|
||||||
const socket = new WebSocket('ws://localhost:8081');
|
|
||||||
mockServer.on('connection', server => {
|
|
||||||
server.on('close', done);
|
|
||||||
});
|
|
||||||
this.resource = new window.textsecure.WebSocketResource(socket, {
|
|
||||||
keepalive: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows resetting the keepalive timer', function thisNeeded2(done) {
|
|
||||||
this.timeout(65000);
|
|
||||||
const mockServer = new MockServer('ws://localhost:8081');
|
|
||||||
const socket = new WebSocket('ws://localhost:8081');
|
|
||||||
const startTime = Date.now();
|
|
||||||
mockServer.on('connection', server => {
|
|
||||||
server.on('message', data => {
|
|
||||||
const message = window.textsecure.protobuf.WebSocketMessage.decode(
|
|
||||||
data
|
|
||||||
);
|
|
||||||
assert.strictEqual(
|
|
||||||
message.type,
|
|
||||||
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST
|
|
||||||
);
|
|
||||||
assert.strictEqual(message.request.verb, 'GET');
|
|
||||||
assert.strictEqual(message.request.path, '/');
|
|
||||||
assert(
|
|
||||||
Date.now() > startTime + 60000,
|
|
||||||
'keepalive time should be longer than a minute'
|
|
||||||
);
|
|
||||||
server.close();
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const resource = new window.textsecure.WebSocketResource(socket, {
|
|
||||||
keepalive: true,
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
|
||||||
resource.resetKeepAliveTimer();
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
9
main.js
9
main.js
|
@ -123,6 +123,7 @@ const {
|
||||||
} = require('./ts/types/Settings');
|
} = require('./ts/types/Settings');
|
||||||
const { Environment } = require('./ts/environment');
|
const { Environment } = require('./ts/environment');
|
||||||
const { ChallengeMainHandler } = require('./ts/main/challengeMain');
|
const { ChallengeMainHandler } = require('./ts/main/challengeMain');
|
||||||
|
const { PowerChannel } = require('./ts/main/powerChannel');
|
||||||
const { maybeParseUrl, setUrlSearchParams } = require('./ts/util/url');
|
const { maybeParseUrl, setUrlSearchParams } = require('./ts/util/url');
|
||||||
|
|
||||||
const sql = new MainSQL();
|
const sql = new MainSQL();
|
||||||
|
@ -1265,6 +1266,14 @@ app.on('ready', async () => {
|
||||||
cleanupOrphanedAttachments,
|
cleanupOrphanedAttachments,
|
||||||
});
|
});
|
||||||
sqlChannels.initialize(sql);
|
sqlChannels.initialize(sql);
|
||||||
|
PowerChannel.initialize({
|
||||||
|
send(event) {
|
||||||
|
if (!mainWindow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mainWindow.webContents.send(event);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Run window preloading in parallel with database initialization.
|
// Run window preloading in parallel with database initialization.
|
||||||
await createWindow();
|
await createWindow();
|
||||||
|
|
|
@ -179,6 +179,15 @@ try {
|
||||||
ipc.on('challenge:response', (_event, response) => {
|
ipc.on('challenge:response', (_event, response) => {
|
||||||
Whisper.events.trigger('challengeResponse', response);
|
Whisper.events.trigger('challengeResponse', response);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipc.on('power-channel:suspend', () => {
|
||||||
|
Whisper.events.trigger('powerMonitorSuspend');
|
||||||
|
});
|
||||||
|
|
||||||
|
ipc.on('power-channel:resume', () => {
|
||||||
|
Whisper.events.trigger('powerMonitorResume');
|
||||||
|
});
|
||||||
|
|
||||||
window.sendChallengeRequest = request =>
|
window.sendChallengeRequest = request =>
|
||||||
ipc.send('challenge:request', request);
|
ipc.send('challenge:request', request);
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { DataMessageClass } from './textsecure.d';
|
||||||
import { MessageAttributesType } from './model-types.d';
|
import { MessageAttributesType } from './model-types.d';
|
||||||
import { WhatIsThis } from './window.d';
|
import { WhatIsThis } from './window.d';
|
||||||
import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings';
|
import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings';
|
||||||
|
import { SocketStatus } from './types/SocketStatus';
|
||||||
import { DEFAULT_CONVERSATION_COLOR } from './types/Colors';
|
import { DEFAULT_CONVERSATION_COLOR } from './types/Colors';
|
||||||
import { ChallengeHandler } from './challenge';
|
import { ChallengeHandler } from './challenge';
|
||||||
import { isWindowDragElement } from './util/isWindowDragElement';
|
import { isWindowDragElement } from './util/isWindowDragElement';
|
||||||
|
@ -38,6 +39,7 @@ import { connectToServerWithStoredCredentials } from './util/connectToServerWith
|
||||||
import * as universalExpireTimer from './util/universalExpireTimer';
|
import * as universalExpireTimer from './util/universalExpireTimer';
|
||||||
import { isDirectConversation, isGroupV2 } from './util/whatTypeOfConversation';
|
import { isDirectConversation, isGroupV2 } from './util/whatTypeOfConversation';
|
||||||
import { getSendOptions } from './util/getSendOptions';
|
import { getSendOptions } from './util/getSendOptions';
|
||||||
|
import { BackOff } from './util/BackOff';
|
||||||
|
|
||||||
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
|
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
|
||||||
|
|
||||||
|
@ -96,6 +98,15 @@ export async function startApp(): Promise<void> {
|
||||||
resolveOnAppView = resolve;
|
resolveOnAppView = resolve;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fibonacci timeouts
|
||||||
|
const reconnectBackOff = new BackOff([
|
||||||
|
5 * 1000,
|
||||||
|
10 * 1000,
|
||||||
|
15 * 1000,
|
||||||
|
25 * 1000,
|
||||||
|
40 * 1000,
|
||||||
|
]);
|
||||||
|
|
||||||
window.textsecure.protobuf.onLoad(() => {
|
window.textsecure.protobuf.onLoad(() => {
|
||||||
window.storage.onready(() => {
|
window.storage.onready(() => {
|
||||||
senderCertificateService.initialize({
|
senderCertificateService.initialize({
|
||||||
|
@ -302,15 +313,15 @@ export async function startApp(): Promise<void> {
|
||||||
});
|
});
|
||||||
|
|
||||||
let messageReceiver: WhatIsThis;
|
let messageReceiver: WhatIsThis;
|
||||||
let preMessageReceiverStatus: WhatIsThis;
|
let preMessageReceiverStatus: SocketStatus | undefined;
|
||||||
window.getSocketStatus = () => {
|
window.getSocketStatus = () => {
|
||||||
if (messageReceiver) {
|
if (messageReceiver) {
|
||||||
return messageReceiver.getStatus();
|
return messageReceiver.getStatus();
|
||||||
}
|
}
|
||||||
if (window._.isNumber(preMessageReceiverStatus)) {
|
if (preMessageReceiverStatus) {
|
||||||
return preMessageReceiverStatus;
|
return preMessageReceiverStatus;
|
||||||
}
|
}
|
||||||
return WebSocket.CLOSED;
|
return SocketStatus.CLOSED;
|
||||||
};
|
};
|
||||||
window.Whisper.events = window._.clone(window.Backbone.Events);
|
window.Whisper.events = window._.clone(window.Backbone.Events);
|
||||||
let accountManager: typeof window.textsecure.AccountManager;
|
let accountManager: typeof window.textsecure.AccountManager;
|
||||||
|
@ -1549,6 +1560,19 @@ export async function startApp(): Promise<void> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.Whisper.events.on('powerMonitorSuspend', () => {
|
||||||
|
window.log.info('powerMonitor: suspend');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.Whisper.events.on('powerMonitorResume', () => {
|
||||||
|
window.log.info('powerMonitor: resume');
|
||||||
|
if (!messageReceiver) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
messageReceiver.checkSocket();
|
||||||
|
});
|
||||||
|
|
||||||
const reconnectToWebSocketQueue = new LatestQueue();
|
const reconnectToWebSocketQueue = new LatestQueue();
|
||||||
|
|
||||||
const enqueueReconnectToWebSocket = () => {
|
const enqueueReconnectToWebSocket = () => {
|
||||||
|
@ -1884,7 +1908,8 @@ export async function startApp(): Promise<void> {
|
||||||
function isSocketOnline() {
|
function isSocketOnline() {
|
||||||
const socketStatus = window.getSocketStatus();
|
const socketStatus = window.getSocketStatus();
|
||||||
return (
|
return (
|
||||||
socketStatus === WebSocket.CONNECTING || socketStatus === WebSocket.OPEN
|
socketStatus === SocketStatus.CONNECTING ||
|
||||||
|
socketStatus === SocketStatus.OPEN
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1937,7 +1962,7 @@ export async function startApp(): Promise<void> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
preMessageReceiverStatus = WebSocket.CONNECTING;
|
preMessageReceiverStatus = SocketStatus.CONNECTING;
|
||||||
|
|
||||||
if (messageReceiver) {
|
if (messageReceiver) {
|
||||||
await messageReceiver.stopProcessing();
|
await messageReceiver.stopProcessing();
|
||||||
|
@ -2020,7 +2045,7 @@ export async function startApp(): Promise<void> {
|
||||||
|
|
||||||
window.Signal.Services.initializeGroupCredentialFetcher();
|
window.Signal.Services.initializeGroupCredentialFetcher();
|
||||||
|
|
||||||
preMessageReceiverStatus = null;
|
preMessageReceiverStatus = undefined;
|
||||||
|
|
||||||
// eslint-disable-next-line no-inner-declarations
|
// eslint-disable-next-line no-inner-declarations
|
||||||
function addQueuedEventListener(name: string, handler: WhatIsThis) {
|
function addQueuedEventListener(name: string, handler: WhatIsThis) {
|
||||||
|
@ -2258,6 +2283,8 @@ export async function startApp(): Promise<void> {
|
||||||
|
|
||||||
// Intentionally not awaiting
|
// Intentionally not awaiting
|
||||||
challengeHandler.onOnline();
|
challengeHandler.onOnline();
|
||||||
|
|
||||||
|
reconnectBackOff.reset();
|
||||||
} finally {
|
} finally {
|
||||||
connecting = false;
|
connecting = false;
|
||||||
}
|
}
|
||||||
|
@ -3380,8 +3407,10 @@ export async function startApp(): Promise<void> {
|
||||||
) {
|
) {
|
||||||
// Failed to connect to server
|
// Failed to connect to server
|
||||||
if (navigator.onLine) {
|
if (navigator.onLine) {
|
||||||
window.log.info('retrying in 1 minute');
|
const timeout = reconnectBackOff.getAndIncrement();
|
||||||
reconnectTimer = setTimeout(connect, 60000);
|
|
||||||
|
window.log.info(`retrying in ${timeout}ms`);
|
||||||
|
reconnectTimer = setTimeout(connect, timeout);
|
||||||
|
|
||||||
window.Whisper.events.trigger('reconnectTimer');
|
window.Whisper.events.trigger('reconnectTimer');
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { boolean, select } from '@storybook/addon-knobs';
|
||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
|
|
||||||
import { NetworkStatus } from './NetworkStatus';
|
import { NetworkStatus } from './NetworkStatus';
|
||||||
|
import { SocketStatus } from '../types/SocketStatus';
|
||||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||||
import enMessages from '../../_locales/en/messages.json';
|
import enMessages from '../../_locales/en/messages.json';
|
||||||
|
|
||||||
|
@ -16,7 +17,7 @@ const defaultProps = {
|
||||||
hasNetworkDialog: true,
|
hasNetworkDialog: true,
|
||||||
i18n,
|
i18n,
|
||||||
isOnline: true,
|
isOnline: true,
|
||||||
socketStatus: 0,
|
socketStatus: SocketStatus.CONNECTING,
|
||||||
manualReconnect: action('manual-reconnect'),
|
manualReconnect: action('manual-reconnect'),
|
||||||
withinConnectingGracePeriod: false,
|
withinConnectingGracePeriod: false,
|
||||||
challengeStatus: 'idle' as const,
|
challengeStatus: 'idle' as const,
|
||||||
|
@ -26,19 +27,19 @@ const permutations = [
|
||||||
{
|
{
|
||||||
title: 'Connecting',
|
title: 'Connecting',
|
||||||
props: {
|
props: {
|
||||||
socketStatus: 0,
|
socketStatus: SocketStatus.CONNECTING,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Closing (online)',
|
title: 'Closing (online)',
|
||||||
props: {
|
props: {
|
||||||
socketStatus: 2,
|
socketStatus: SocketStatus.CLOSING,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Closed (online)',
|
title: 'Closed (online)',
|
||||||
props: {
|
props: {
|
||||||
socketStatus: 3,
|
socketStatus: SocketStatus.CLOSED,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -56,12 +57,12 @@ storiesOf('Components/NetworkStatus', module)
|
||||||
const socketStatus = select(
|
const socketStatus = select(
|
||||||
'socketStatus',
|
'socketStatus',
|
||||||
{
|
{
|
||||||
CONNECTING: 0,
|
CONNECTING: SocketStatus.CONNECTING,
|
||||||
OPEN: 1,
|
OPEN: SocketStatus.OPEN,
|
||||||
CLOSING: 2,
|
CLOSING: SocketStatus.CLOSING,
|
||||||
CLOSED: 3,
|
CLOSED: SocketStatus.CLOSED,
|
||||||
},
|
},
|
||||||
0
|
SocketStatus.CONNECTING
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { LocalizerType } from '../types/Util';
|
import { LocalizerType } from '../types/Util';
|
||||||
|
import { SocketStatus } from '../types/SocketStatus';
|
||||||
import { NetworkStateType } from '../state/ducks/network';
|
import { NetworkStateType } from '../state/ducks/network';
|
||||||
|
|
||||||
const FIVE_SECONDS = 5 * 1000;
|
const FIVE_SECONDS = 5 * 1000;
|
||||||
|
@ -100,12 +101,12 @@ export const NetworkStatus = ({
|
||||||
let renderActionableButton;
|
let renderActionableButton;
|
||||||
|
|
||||||
switch (socketStatus) {
|
switch (socketStatus) {
|
||||||
case WebSocket.CONNECTING:
|
case SocketStatus.CONNECTING:
|
||||||
subtext = i18n('connectingHangOn');
|
subtext = i18n('connectingHangOn');
|
||||||
title = i18n('connecting');
|
title = i18n('connecting');
|
||||||
break;
|
break;
|
||||||
case WebSocket.CLOSED:
|
case SocketStatus.CLOSED:
|
||||||
case WebSocket.CLOSING:
|
case SocketStatus.CLOSING:
|
||||||
default:
|
default:
|
||||||
renderActionableButton = manualReconnectButton;
|
renderActionableButton = manualReconnectButton;
|
||||||
title = i18n('disconnected');
|
title = i18n('disconnected');
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { powerMonitor } from 'electron';
|
||||||
|
|
||||||
|
export type InitializeOptions = {
|
||||||
|
send(event: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PowerChannel {
|
||||||
|
private static isInitialized = false;
|
||||||
|
|
||||||
|
static initialize({ send }: InitializeOptions): void {
|
||||||
|
if (PowerChannel.isInitialized) {
|
||||||
|
throw new Error('PowerChannel already initialized');
|
||||||
|
}
|
||||||
|
PowerChannel.isInitialized = true;
|
||||||
|
|
||||||
|
powerMonitor.on('suspend', () => {
|
||||||
|
send('power-channel:suspend');
|
||||||
|
});
|
||||||
|
powerMonitor.on('resume', () => {
|
||||||
|
send('power-channel:resume');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,6 +11,7 @@ import {
|
||||||
} from '../util/zkgroup';
|
} from '../util/zkgroup';
|
||||||
|
|
||||||
import { GroupCredentialType } from '../textsecure/WebAPI';
|
import { GroupCredentialType } from '../textsecure/WebAPI';
|
||||||
|
import { BackOff } from '../util/BackOff';
|
||||||
import { sleep } from '../util/sleep';
|
import { sleep } from '../util/sleep';
|
||||||
|
|
||||||
export const GROUP_CREDENTIALS_KEY = 'groupCredentials';
|
export const GROUP_CREDENTIALS_KEY = 'groupCredentials';
|
||||||
|
@ -50,33 +51,28 @@ export async function initializeGroupCredentialFetcher(): Promise<void> {
|
||||||
await runWithRetry(maybeFetchNewCredentials, { scheduleAnother: 4 * HOUR });
|
await runWithRetry(maybeFetchNewCredentials, { scheduleAnother: 4 * HOUR });
|
||||||
}
|
}
|
||||||
|
|
||||||
type BackoffType = {
|
const BACKOFF_TIMEOUTS = [
|
||||||
[key: number]: number | undefined;
|
SECOND,
|
||||||
max: number;
|
5 * SECOND,
|
||||||
};
|
30 * SECOND,
|
||||||
const BACKOFF: BackoffType = {
|
2 * MINUTE,
|
||||||
0: SECOND,
|
5 * MINUTE,
|
||||||
1: 5 * SECOND,
|
];
|
||||||
2: 30 * SECOND,
|
|
||||||
3: 2 * MINUTE,
|
|
||||||
max: 5 * MINUTE,
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function runWithRetry(
|
export async function runWithRetry(
|
||||||
fn: () => Promise<void>,
|
fn: () => Promise<void>,
|
||||||
options: { scheduleAnother?: number } = {}
|
options: { scheduleAnother?: number } = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let count = 0;
|
const backOff = new BackOff(BACKOFF_TIMEOUTS);
|
||||||
|
|
||||||
// eslint-disable-next-line no-constant-condition
|
// eslint-disable-next-line no-constant-condition
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
count += 1;
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await fn();
|
await fn();
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const wait = BACKOFF[count] || BACKOFF.max;
|
const wait = backOff.getAndIncrement();
|
||||||
window.log.info(
|
window.log.info(
|
||||||
`runWithRetry: ${fn.name} failed. Waiting ${wait}ms for retry. Error: ${error.stack}`
|
`runWithRetry: ${fn.name} failed. Waiting ${wait}ms for retry. Error: ${error.stack}`
|
||||||
);
|
);
|
||||||
|
|
|
@ -30,6 +30,7 @@ import {
|
||||||
toGroupV2Record,
|
toGroupV2Record,
|
||||||
} from './storageRecordOps';
|
} from './storageRecordOps';
|
||||||
import { ConversationModel } from '../models/conversations';
|
import { ConversationModel } from '../models/conversations';
|
||||||
|
import { BackOff } from '../util/BackOff';
|
||||||
import { storageJobQueue } from '../util/JobQueue';
|
import { storageJobQueue } from '../util/JobQueue';
|
||||||
import { sleep } from '../util/sleep';
|
import { sleep } from '../util/sleep';
|
||||||
import { isMoreRecentThan } from '../util/timestamp';
|
import { isMoreRecentThan } from '../util/timestamp';
|
||||||
|
@ -45,8 +46,6 @@ const {
|
||||||
updateConversation,
|
updateConversation,
|
||||||
} = dataInterface;
|
} = dataInterface;
|
||||||
|
|
||||||
let consecutiveStops = 0;
|
|
||||||
let consecutiveConflicts = 0;
|
|
||||||
const uploadBucket: Array<number> = [];
|
const uploadBucket: Array<number> = [];
|
||||||
|
|
||||||
const validRecordTypes = new Set([
|
const validRecordTypes = new Set([
|
||||||
|
@ -57,24 +56,18 @@ const validRecordTypes = new Set([
|
||||||
4, // ACCOUNT
|
4, // ACCOUNT
|
||||||
]);
|
]);
|
||||||
|
|
||||||
type BackoffType = {
|
|
||||||
[key: number]: number | undefined;
|
|
||||||
max: number;
|
|
||||||
};
|
|
||||||
const SECOND = 1000;
|
const SECOND = 1000;
|
||||||
const MINUTE = 60 * SECOND;
|
const MINUTE = 60 * SECOND;
|
||||||
const BACKOFF: BackoffType = {
|
|
||||||
0: SECOND,
|
|
||||||
1: 5 * SECOND,
|
|
||||||
2: 30 * SECOND,
|
|
||||||
3: 2 * MINUTE,
|
|
||||||
max: 5 * MINUTE,
|
|
||||||
};
|
|
||||||
|
|
||||||
function backOff(count: number) {
|
const backOff = new BackOff([
|
||||||
const ms = BACKOFF[count] || BACKOFF.max;
|
SECOND,
|
||||||
return sleep(ms);
|
5 * SECOND,
|
||||||
}
|
30 * SECOND,
|
||||||
|
2 * MINUTE,
|
||||||
|
5 * MINUTE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const conflictBackOff = new BackOff([SECOND, 5 * SECOND, 30 * SECOND]);
|
||||||
|
|
||||||
function redactStorageID(storageID: string): string {
|
function redactStorageID(storageID: string): string {
|
||||||
return storageID.substring(0, 3);
|
return storageID.substring(0, 3);
|
||||||
|
@ -494,16 +487,15 @@ async function uploadManifest(
|
||||||
);
|
);
|
||||||
|
|
||||||
if (err.code === 409) {
|
if (err.code === 409) {
|
||||||
if (consecutiveConflicts > 3) {
|
if (conflictBackOff.isFull()) {
|
||||||
window.log.error(
|
window.log.error(
|
||||||
'storageService.uploadManifest: Exceeded maximum consecutive conflicts'
|
'storageService.uploadManifest: Exceeded maximum consecutive conflicts'
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
consecutiveConflicts += 1;
|
|
||||||
|
|
||||||
window.log.info(
|
window.log.info(
|
||||||
`storageService.uploadManifest: Conflict found with v${version}, running sync job times(${consecutiveConflicts})`
|
`storageService.uploadManifest: Conflict found with v${version}, running sync job times(${conflictBackOff.getIndex()})`
|
||||||
);
|
);
|
||||||
|
|
||||||
throw err;
|
throw err;
|
||||||
|
@ -517,8 +509,8 @@ async function uploadManifest(
|
||||||
version
|
version
|
||||||
);
|
);
|
||||||
window.storage.put('manifestVersion', version);
|
window.storage.put('manifestVersion', version);
|
||||||
consecutiveConflicts = 0;
|
conflictBackOff.reset();
|
||||||
consecutiveStops = 0;
|
backOff.reset();
|
||||||
await window.textsecure.messaging.sendFetchManifestSyncMessage();
|
await window.textsecure.messaging.sendFetchManifestSyncMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -527,21 +519,21 @@ async function stopStorageServiceSync() {
|
||||||
|
|
||||||
await window.storage.remove('storageKey');
|
await window.storage.remove('storageKey');
|
||||||
|
|
||||||
if (consecutiveStops < 5) {
|
if (backOff.isFull()) {
|
||||||
await backOff(consecutiveStops);
|
|
||||||
window.log.info(
|
window.log.info(
|
||||||
'storageService.stopStorageServiceSync: requesting new keys'
|
'storageService.stopStorageServiceSync: too many consecutive stops'
|
||||||
);
|
);
|
||||||
consecutiveStops += 1;
|
return;
|
||||||
setTimeout(() => {
|
|
||||||
if (!window.textsecure.messaging) {
|
|
||||||
throw new Error(
|
|
||||||
'storageService.stopStorageServiceSync: We are offline!'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
window.textsecure.messaging.sendRequestKeySyncMessage();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await sleep(backOff.getAndIncrement());
|
||||||
|
window.log.info('storageService.stopStorageServiceSync: requesting new keys');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!window.textsecure.messaging) {
|
||||||
|
throw new Error('storageService.stopStorageServiceSync: We are offline!');
|
||||||
|
}
|
||||||
|
window.textsecure.messaging.sendRequestKeySyncMessage();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createNewManifest() {
|
async function createNewManifest() {
|
||||||
|
@ -976,7 +968,7 @@ async function processRemoteRecords(
|
||||||
return conflictCount;
|
return conflictCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
consecutiveConflicts = 0;
|
conflictBackOff.reset();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.log.error(
|
window.log.error(
|
||||||
'storageService.processRemoteRecords: failed!',
|
'storageService.processRemoteRecords: failed!',
|
||||||
|
@ -1082,7 +1074,7 @@ async function upload(fromSync = false): Promise<void> {
|
||||||
window.log.info(
|
window.log.info(
|
||||||
'storageService.upload: no storageKey, requesting new keys'
|
'storageService.upload: no storageKey, requesting new keys'
|
||||||
);
|
);
|
||||||
consecutiveStops = 0;
|
backOff.reset();
|
||||||
await window.textsecure.messaging.sendRequestKeySyncMessage();
|
await window.textsecure.messaging.sendRequestKeySyncMessage();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -1108,7 +1100,7 @@ async function upload(fromSync = false): Promise<void> {
|
||||||
await uploadManifest(version, generatedManifest);
|
await uploadManifest(version, generatedManifest);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === 409) {
|
if (err.code === 409) {
|
||||||
await backOff(consecutiveConflicts);
|
await sleep(conflictBackOff.getAndIncrement());
|
||||||
window.log.info('storageService.upload: pushing sync on the queue');
|
window.log.info('storageService.upload: pushing sync on the queue');
|
||||||
// The sync job will check for conflicts and as part of that conflict
|
// The sync job will check for conflicts and as part of that conflict
|
||||||
// check if an item needs sync and doesn't match with the remote record
|
// check if an item needs sync and doesn't match with the remote record
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
// Copyright 2020-2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
export function getSocketStatus(): number {
|
import { SocketStatus } from '../types/SocketStatus';
|
||||||
|
|
||||||
|
export function getSocketStatus(): SocketStatus {
|
||||||
const { getSocketStatus: getMessageReceiverStatus } = window;
|
const { getSocketStatus: getMessageReceiverStatus } = window;
|
||||||
|
|
||||||
return getMessageReceiverStatus();
|
return getMessageReceiverStatus();
|
||||||
|
|
|
@ -98,7 +98,7 @@ export const actions = {
|
||||||
export function getEmptyState(): NetworkStateType {
|
export function getEmptyState(): NetworkStateType {
|
||||||
return {
|
return {
|
||||||
isOnline: navigator.onLine,
|
isOnline: navigator.onLine,
|
||||||
socketStatus: WebSocket.OPEN,
|
socketStatus: SocketStatus.OPEN,
|
||||||
withinConnectingGracePeriod: true,
|
withinConnectingGracePeriod: true,
|
||||||
challengeStatus: 'idle',
|
challengeStatus: 'idle',
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { createSelector } from 'reselect';
|
||||||
import { StateType } from '../reducer';
|
import { StateType } from '../reducer';
|
||||||
import { NetworkStateType } from '../ducks/network';
|
import { NetworkStateType } from '../ducks/network';
|
||||||
import { isDone } from '../../util/registration';
|
import { isDone } from '../../util/registration';
|
||||||
|
import { SocketStatus } from '../../types/SocketStatus';
|
||||||
|
|
||||||
const getNetwork = (state: StateType): NetworkStateType => state.network;
|
const getNetwork = (state: StateType): NetworkStateType => state.network;
|
||||||
|
|
||||||
|
@ -18,9 +19,10 @@ export const hasNetworkDialog = createSelector(
|
||||||
): boolean =>
|
): boolean =>
|
||||||
isRegistrationDone &&
|
isRegistrationDone &&
|
||||||
(!isOnline ||
|
(!isOnline ||
|
||||||
(socketStatus === WebSocket.CONNECTING && !withinConnectingGracePeriod) ||
|
(socketStatus === SocketStatus.CONNECTING &&
|
||||||
socketStatus === WebSocket.CLOSED ||
|
!withinConnectingGracePeriod) ||
|
||||||
socketStatus === WebSocket.CLOSING)
|
socketStatus === SocketStatus.CLOSED ||
|
||||||
|
socketStatus === SocketStatus.CLOSING)
|
||||||
);
|
);
|
||||||
|
|
||||||
export const isChallengePending = createSelector(
|
export const isChallengePending = createSelector(
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
|
||||||
|
import { BackOff } from '../../util/BackOff';
|
||||||
|
|
||||||
|
describe('BackOff', () => {
|
||||||
|
it('should return increasing timeouts', () => {
|
||||||
|
const b = new BackOff([1, 2, 3]);
|
||||||
|
|
||||||
|
assert.strictEqual(b.getIndex(), 0);
|
||||||
|
assert.strictEqual(b.isFull(), false);
|
||||||
|
|
||||||
|
assert.strictEqual(b.get(), 1);
|
||||||
|
assert.strictEqual(b.getAndIncrement(), 1);
|
||||||
|
assert.strictEqual(b.get(), 2);
|
||||||
|
assert.strictEqual(b.getIndex(), 1);
|
||||||
|
assert.strictEqual(b.isFull(), false);
|
||||||
|
|
||||||
|
assert.strictEqual(b.getAndIncrement(), 2);
|
||||||
|
assert.strictEqual(b.getIndex(), 2);
|
||||||
|
assert.strictEqual(b.isFull(), true);
|
||||||
|
|
||||||
|
assert.strictEqual(b.getAndIncrement(), 3);
|
||||||
|
assert.strictEqual(b.getIndex(), 2);
|
||||||
|
assert.strictEqual(b.isFull(), true);
|
||||||
|
|
||||||
|
assert.strictEqual(b.getAndIncrement(), 3);
|
||||||
|
assert.strictEqual(b.getIndex(), 2);
|
||||||
|
assert.strictEqual(b.isFull(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset', () => {
|
||||||
|
const b = new BackOff([1, 2, 3]);
|
||||||
|
|
||||||
|
assert.strictEqual(b.getAndIncrement(), 1);
|
||||||
|
assert.strictEqual(b.getAndIncrement(), 2);
|
||||||
|
|
||||||
|
b.reset();
|
||||||
|
|
||||||
|
assert.strictEqual(b.getAndIncrement(), 1);
|
||||||
|
assert.strictEqual(b.getAndIncrement(), 2);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,264 @@
|
||||||
|
// Copyright 2015-2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
/* eslint-disable
|
||||||
|
class-methods-use-this,
|
||||||
|
no-new,
|
||||||
|
@typescript-eslint/no-empty-function,
|
||||||
|
@typescript-eslint/no-explicit-any
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import * as sinon from 'sinon';
|
||||||
|
import EventEmitter from 'events';
|
||||||
|
import { connection as WebSocket } from 'websocket';
|
||||||
|
|
||||||
|
import WebSocketResource from '../textsecure/WebsocketResources';
|
||||||
|
|
||||||
|
describe('WebSocket-Resource', () => {
|
||||||
|
class FakeSocket extends EventEmitter {
|
||||||
|
public sendBytes(_: Uint8Array) {}
|
||||||
|
|
||||||
|
public close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('requests and responses', () => {
|
||||||
|
it('receives requests and sends responses', done => {
|
||||||
|
// mock socket
|
||||||
|
const requestId = '1';
|
||||||
|
const socket = new FakeSocket();
|
||||||
|
|
||||||
|
sinon.stub(socket, 'sendBytes').callsFake((data: Uint8Array) => {
|
||||||
|
const message = window.textsecure.protobuf.WebSocketMessage.decode(
|
||||||
|
data
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
message.type,
|
||||||
|
window.textsecure.protobuf.WebSocketMessage.Type.RESPONSE
|
||||||
|
);
|
||||||
|
assert.strictEqual(message.response?.message, 'OK');
|
||||||
|
assert.strictEqual(message.response?.status, 200);
|
||||||
|
assert.strictEqual(message.response?.id.toString(), requestId);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
// actual test
|
||||||
|
new WebSocketResource(socket as WebSocket, {
|
||||||
|
handleRequest(request: any) {
|
||||||
|
assert.strictEqual(request.verb, 'PUT');
|
||||||
|
assert.strictEqual(request.path, '/some/path');
|
||||||
|
assert.ok(
|
||||||
|
window.Signal.Crypto.constantTimeEqual(
|
||||||
|
request.body.toArrayBuffer(),
|
||||||
|
window.Signal.Crypto.typedArrayToArrayBuffer(
|
||||||
|
new Uint8Array([1, 2, 3])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
request.respond(200, 'OK');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// mock socket request
|
||||||
|
socket.emit('message', {
|
||||||
|
type: 'binary',
|
||||||
|
binaryData: new Uint8Array(
|
||||||
|
new window.textsecure.protobuf.WebSocketMessage({
|
||||||
|
type: window.textsecure.protobuf.WebSocketMessage.Type.REQUEST,
|
||||||
|
request: {
|
||||||
|
id: requestId,
|
||||||
|
verb: 'PUT',
|
||||||
|
path: '/some/path',
|
||||||
|
body: window.Signal.Crypto.typedArrayToArrayBuffer(
|
||||||
|
new Uint8Array([1, 2, 3])
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.encode()
|
||||||
|
.toArrayBuffer()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends requests and receives responses', done => {
|
||||||
|
// mock socket and request handler
|
||||||
|
let requestId: Long | undefined;
|
||||||
|
const socket = new FakeSocket();
|
||||||
|
|
||||||
|
sinon.stub(socket, 'sendBytes').callsFake((data: Uint8Array) => {
|
||||||
|
const message = window.textsecure.protobuf.WebSocketMessage.decode(
|
||||||
|
data
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
message.type,
|
||||||
|
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST
|
||||||
|
);
|
||||||
|
assert.strictEqual(message.request?.verb, 'PUT');
|
||||||
|
assert.strictEqual(message.request?.path, '/some/path');
|
||||||
|
assert.ok(
|
||||||
|
window.Signal.Crypto.constantTimeEqual(
|
||||||
|
message.request?.body.toArrayBuffer(),
|
||||||
|
window.Signal.Crypto.typedArrayToArrayBuffer(
|
||||||
|
new Uint8Array([1, 2, 3])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
requestId = message.request?.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// actual test
|
||||||
|
const resource = new WebSocketResource(socket as WebSocket);
|
||||||
|
resource.sendRequest({
|
||||||
|
verb: 'PUT',
|
||||||
|
path: '/some/path',
|
||||||
|
body: window.Signal.Crypto.typedArrayToArrayBuffer(
|
||||||
|
new Uint8Array([1, 2, 3])
|
||||||
|
),
|
||||||
|
error: done,
|
||||||
|
success(message: string, status: number) {
|
||||||
|
assert.strictEqual(message, 'OK');
|
||||||
|
assert.strictEqual(status, 200);
|
||||||
|
done();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// mock socket response
|
||||||
|
socket.emit('message', {
|
||||||
|
type: 'binary',
|
||||||
|
binaryData: new Uint8Array(
|
||||||
|
new window.textsecure.protobuf.WebSocketMessage({
|
||||||
|
type: window.textsecure.protobuf.WebSocketMessage.Type.RESPONSE,
|
||||||
|
response: { id: requestId, message: 'OK', status: 200 },
|
||||||
|
})
|
||||||
|
.encode()
|
||||||
|
.toArrayBuffer()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('close', () => {
|
||||||
|
it('closes the connection', done => {
|
||||||
|
const socket = new FakeSocket();
|
||||||
|
|
||||||
|
sinon.stub(socket, 'close').callsFake(() => done());
|
||||||
|
|
||||||
|
const resource = new WebSocketResource(socket as WebSocket);
|
||||||
|
resource.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with a keepalive config', () => {
|
||||||
|
const NOW = Date.now();
|
||||||
|
|
||||||
|
beforeEach(function beforeEach() {
|
||||||
|
this.sandbox = sinon.createSandbox();
|
||||||
|
this.clock = this.sandbox.useFakeTimers({
|
||||||
|
now: NOW,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function afterEach() {
|
||||||
|
this.sandbox.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends keepalives once a minute', function test(done) {
|
||||||
|
const socket = new FakeSocket();
|
||||||
|
|
||||||
|
sinon.stub(socket, 'sendBytes').callsFake(data => {
|
||||||
|
const message = window.textsecure.protobuf.WebSocketMessage.decode(
|
||||||
|
data
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
message.type,
|
||||||
|
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST
|
||||||
|
);
|
||||||
|
assert.strictEqual(message.request?.verb, 'GET');
|
||||||
|
assert.strictEqual(message.request?.path, '/v1/keepalive');
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
new WebSocketResource(socket as WebSocket, {
|
||||||
|
keepalive: { path: '/v1/keepalive' },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.clock.next();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses / as a default path', function test(done) {
|
||||||
|
const socket = new FakeSocket();
|
||||||
|
|
||||||
|
sinon.stub(socket, 'sendBytes').callsFake(data => {
|
||||||
|
const message = window.textsecure.protobuf.WebSocketMessage.decode(
|
||||||
|
data
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
message.type,
|
||||||
|
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST
|
||||||
|
);
|
||||||
|
assert.strictEqual(message.request?.verb, 'GET');
|
||||||
|
assert.strictEqual(message.request?.path, '/');
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
new WebSocketResource(socket as WebSocket, {
|
||||||
|
keepalive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.clock.next();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('optionally disconnects if no response', function thisNeeded1(done) {
|
||||||
|
const socket = new FakeSocket();
|
||||||
|
|
||||||
|
sinon.stub(socket, 'close').callsFake(() => done());
|
||||||
|
|
||||||
|
new WebSocketResource(socket as WebSocket, {
|
||||||
|
keepalive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// One to trigger send
|
||||||
|
this.clock.next();
|
||||||
|
|
||||||
|
// Another to trigger send timeout
|
||||||
|
this.clock.next();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows resetting the keepalive timer', function thisNeeded2(done) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const socket = new FakeSocket();
|
||||||
|
|
||||||
|
sinon.stub(socket, 'sendBytes').callsFake(data => {
|
||||||
|
const message = window.textsecure.protobuf.WebSocketMessage.decode(
|
||||||
|
data
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
message.type,
|
||||||
|
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST
|
||||||
|
);
|
||||||
|
assert.strictEqual(message.request?.verb, 'GET');
|
||||||
|
assert.strictEqual(message.request?.path, '/');
|
||||||
|
assert.strictEqual(
|
||||||
|
Date.now(),
|
||||||
|
startTime + 60000,
|
||||||
|
'keepalive time should be one minute'
|
||||||
|
);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
const resource = new WebSocketResource(socket as WebSocket, {
|
||||||
|
keepalive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
resource.keepalive?.reset();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Trigger setTimeout above
|
||||||
|
this.clock.next();
|
||||||
|
|
||||||
|
// Trigger sendBytes
|
||||||
|
this.clock.next();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -199,109 +199,111 @@ export default class AccountManager extends EventTarget {
|
||||||
const queueTask = this.queueTask.bind(this);
|
const queueTask = this.queueTask.bind(this);
|
||||||
const provisioningCipher = new ProvisioningCipher();
|
const provisioningCipher = new ProvisioningCipher();
|
||||||
let gotProvisionEnvelope = false;
|
let gotProvisionEnvelope = false;
|
||||||
return provisioningCipher.getPublicKey().then(
|
const pubKey = await provisioningCipher.getPublicKey();
|
||||||
async (pubKey: ArrayBuffer) =>
|
|
||||||
new Promise((resolve, reject) => {
|
const socket = await getSocket();
|
||||||
const socket = getSocket();
|
|
||||||
socket.onclose = event => {
|
window.log.info('provisioning socket open');
|
||||||
window.log.info('provisioning socket closed. Code:', event.code);
|
|
||||||
if (!gotProvisionEnvelope) {
|
return new Promise((resolve, reject) => {
|
||||||
reject(new Error('websocket closed'));
|
socket.on('close', (code, reason) => {
|
||||||
|
window.log.info(
|
||||||
|
`provisioning socket closed. Code: ${code} Reason: ${reason}`
|
||||||
|
);
|
||||||
|
if (!gotProvisionEnvelope) {
|
||||||
|
reject(new Error('websocket closed'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const wsr = new WebSocketResource(socket, {
|
||||||
|
keepalive: { path: '/v1/keepalive/provisioning' },
|
||||||
|
handleRequest(request: IncomingWebSocketRequest) {
|
||||||
|
if (
|
||||||
|
request.path === '/v1/address' &&
|
||||||
|
request.verb === 'PUT' &&
|
||||||
|
request.body
|
||||||
|
) {
|
||||||
|
const proto = window.textsecure.protobuf.ProvisioningUuid.decode(
|
||||||
|
request.body
|
||||||
|
);
|
||||||
|
const { uuid } = proto;
|
||||||
|
if (!uuid) {
|
||||||
|
throw new Error('registerSecondDevice: expected a UUID');
|
||||||
}
|
}
|
||||||
};
|
const url = getProvisioningUrl(uuid, pubKey);
|
||||||
socket.onopen = () => {
|
|
||||||
window.log.info('provisioning socket open');
|
|
||||||
};
|
|
||||||
const wsr = new WebSocketResource(socket, {
|
|
||||||
keepalive: { path: '/v1/keepalive/provisioning' },
|
|
||||||
handleRequest(request: IncomingWebSocketRequest) {
|
|
||||||
if (
|
|
||||||
request.path === '/v1/address' &&
|
|
||||||
request.verb === 'PUT' &&
|
|
||||||
request.body
|
|
||||||
) {
|
|
||||||
const proto = window.textsecure.protobuf.ProvisioningUuid.decode(
|
|
||||||
request.body
|
|
||||||
);
|
|
||||||
const { uuid } = proto;
|
|
||||||
if (!uuid) {
|
|
||||||
throw new Error('registerSecondDevice: expected a UUID');
|
|
||||||
}
|
|
||||||
const url = getProvisioningUrl(uuid, pubKey);
|
|
||||||
|
|
||||||
if (window.CI) {
|
if (window.CI) {
|
||||||
window.CI.setProvisioningURL(url);
|
window.CI.setProvisioningURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
setProvisioningUrl(url);
|
setProvisioningUrl(url);
|
||||||
request.respond(200, 'OK');
|
request.respond(200, 'OK');
|
||||||
} else if (
|
} else if (
|
||||||
request.path === '/v1/message' &&
|
request.path === '/v1/message' &&
|
||||||
request.verb === 'PUT' &&
|
request.verb === 'PUT' &&
|
||||||
request.body
|
request.body
|
||||||
) {
|
) {
|
||||||
const envelope = window.textsecure.protobuf.ProvisionEnvelope.decode(
|
const envelope = window.textsecure.protobuf.ProvisionEnvelope.decode(
|
||||||
request.body,
|
request.body,
|
||||||
'binary'
|
'binary'
|
||||||
);
|
);
|
||||||
request.respond(200, 'OK');
|
request.respond(200, 'OK');
|
||||||
gotProvisionEnvelope = true;
|
gotProvisionEnvelope = true;
|
||||||
wsr.close();
|
wsr.close();
|
||||||
resolve(
|
resolve(
|
||||||
provisioningCipher
|
provisioningCipher
|
||||||
.decrypt(envelope)
|
.decrypt(envelope)
|
||||||
.then(async provisionMessage =>
|
.then(async provisionMessage =>
|
||||||
queueTask(async () =>
|
queueTask(async () =>
|
||||||
confirmNumber(provisionMessage.number).then(
|
confirmNumber(provisionMessage.number).then(
|
||||||
async deviceName => {
|
async deviceName => {
|
||||||
if (
|
if (
|
||||||
typeof deviceName !== 'string' ||
|
typeof deviceName !== 'string' ||
|
||||||
deviceName.length === 0
|
deviceName.length === 0
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'AccountManager.registerSecondDevice: Invalid device name'
|
'AccountManager.registerSecondDevice: Invalid device name'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!provisionMessage.number ||
|
!provisionMessage.number ||
|
||||||
!provisionMessage.provisioningCode ||
|
!provisionMessage.provisioningCode ||
|
||||||
!provisionMessage.identityKeyPair
|
!provisionMessage.identityKeyPair
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'AccountManager.registerSecondDevice: Provision message was missing key data'
|
'AccountManager.registerSecondDevice: Provision message was missing key data'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return createAccount(
|
return createAccount(
|
||||||
provisionMessage.number,
|
provisionMessage.number,
|
||||||
provisionMessage.provisioningCode,
|
provisionMessage.provisioningCode,
|
||||||
provisionMessage.identityKeyPair,
|
provisionMessage.identityKeyPair,
|
||||||
provisionMessage.profileKey,
|
provisionMessage.profileKey,
|
||||||
deviceName,
|
deviceName,
|
||||||
provisionMessage.userAgent,
|
provisionMessage.userAgent,
|
||||||
provisionMessage.readReceipts,
|
provisionMessage.readReceipts,
|
||||||
{ uuid: provisionMessage.uuid }
|
{ uuid: provisionMessage.uuid }
|
||||||
)
|
|
||||||
.then(clearSessionsAndPreKeys)
|
|
||||||
.then(generateKeys)
|
|
||||||
.then(async (keys: GeneratedKeysType) =>
|
|
||||||
registerKeys(keys).then(async () =>
|
|
||||||
confirmKeys(keys)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.then(registrationDone);
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
)
|
.then(clearSessionsAndPreKeys)
|
||||||
|
.then(generateKeys)
|
||||||
|
.then(async (keys: GeneratedKeysType) =>
|
||||||
|
registerKeys(keys).then(async () =>
|
||||||
|
confirmKeys(keys)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(registrationDone);
|
||||||
|
}
|
||||||
)
|
)
|
||||||
);
|
)
|
||||||
} else {
|
)
|
||||||
window.log.error('Unknown websocket message', request.path);
|
);
|
||||||
}
|
} else {
|
||||||
},
|
window.log.error('Unknown websocket message', request.path);
|
||||||
});
|
}
|
||||||
})
|
},
|
||||||
);
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshPreKeys() {
|
async refreshPreKeys() {
|
||||||
|
|
|
@ -10,9 +10,10 @@
|
||||||
/* eslint-disable max-classes-per-file */
|
/* eslint-disable max-classes-per-file */
|
||||||
/* eslint-disable no-restricted-syntax */
|
/* eslint-disable no-restricted-syntax */
|
||||||
|
|
||||||
import { isNumber, map, omit, noop } from 'lodash';
|
import { isNumber, map, omit } from 'lodash';
|
||||||
import PQueue from 'p-queue';
|
import PQueue from 'p-queue';
|
||||||
import { v4 as getGuid } from 'uuid';
|
import { v4 as getGuid } from 'uuid';
|
||||||
|
import { connection as WebSocket } from 'websocket';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -41,6 +42,7 @@ import {
|
||||||
SignedPreKeys,
|
SignedPreKeys,
|
||||||
} from '../LibSignalStores';
|
} from '../LibSignalStores';
|
||||||
import { BatcherType, createBatcher } from '../util/batcher';
|
import { BatcherType, createBatcher } from '../util/batcher';
|
||||||
|
import { sleep } from '../util/sleep';
|
||||||
import { parseIntOrThrow } from '../util/parseIntOrThrow';
|
import { parseIntOrThrow } from '../util/parseIntOrThrow';
|
||||||
import { Zone } from '../util/Zone';
|
import { Zone } from '../util/Zone';
|
||||||
import EventTarget from './EventTarget';
|
import EventTarget from './EventTarget';
|
||||||
|
@ -53,6 +55,7 @@ import Crypto from './Crypto';
|
||||||
import { deriveMasterKeyFromGroupV1, typedArrayToArrayBuffer } from '../Crypto';
|
import { deriveMasterKeyFromGroupV1, typedArrayToArrayBuffer } from '../Crypto';
|
||||||
import { ContactBuffer, GroupBuffer } from './ContactsParser';
|
import { ContactBuffer, GroupBuffer } from './ContactsParser';
|
||||||
import { isByteBufferEmpty } from '../util/isByteBufferEmpty';
|
import { isByteBufferEmpty } from '../util/isByteBufferEmpty';
|
||||||
|
import { SocketStatus } from '../types/SocketStatus';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AttachmentPointerClass,
|
AttachmentPointerClass,
|
||||||
|
@ -68,13 +71,12 @@ import {
|
||||||
} from '../textsecure.d';
|
} from '../textsecure.d';
|
||||||
import { ByteBufferClass } from '../window.d';
|
import { ByteBufferClass } from '../window.d';
|
||||||
|
|
||||||
import { WebSocket } from './WebSocket';
|
|
||||||
|
|
||||||
import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups';
|
import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups';
|
||||||
|
|
||||||
const GROUPV1_ID_LENGTH = 16;
|
const GROUPV1_ID_LENGTH = 16;
|
||||||
const GROUPV2_ID_LENGTH = 32;
|
const GROUPV2_ID_LENGTH = 32;
|
||||||
const RETRY_TIMEOUT = 2 * 60 * 1000;
|
const RETRY_TIMEOUT = 2 * 60 * 1000;
|
||||||
|
const RECONNECT_DELAY = 1 * 1000;
|
||||||
|
|
||||||
const decryptionErrorTypeSchema = z
|
const decryptionErrorTypeSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -169,7 +171,9 @@ enum TaskType {
|
||||||
}
|
}
|
||||||
|
|
||||||
class MessageReceiverInner extends EventTarget {
|
class MessageReceiverInner extends EventTarget {
|
||||||
_onClose?: (ev: any) => Promise<void>;
|
_onClose?: (code: number, reason: string) => Promise<void>;
|
||||||
|
|
||||||
|
_onError?: (error: Error) => Promise<void>;
|
||||||
|
|
||||||
appQueue: PQueue;
|
appQueue: PQueue;
|
||||||
|
|
||||||
|
@ -185,7 +189,7 @@ class MessageReceiverInner extends EventTarget {
|
||||||
|
|
||||||
deviceId?: number;
|
deviceId?: number;
|
||||||
|
|
||||||
hasConnected?: boolean;
|
hasConnected = false;
|
||||||
|
|
||||||
incomingQueue: PQueue;
|
incomingQueue: PQueue;
|
||||||
|
|
||||||
|
@ -209,6 +213,8 @@ class MessageReceiverInner extends EventTarget {
|
||||||
|
|
||||||
socket?: WebSocket;
|
socket?: WebSocket;
|
||||||
|
|
||||||
|
socketStatus = SocketStatus.CLOSED;
|
||||||
|
|
||||||
stoppingProcessing?: boolean;
|
stoppingProcessing?: boolean;
|
||||||
|
|
||||||
username: string;
|
username: string;
|
||||||
|
@ -304,7 +310,7 @@ class MessageReceiverInner extends EventTarget {
|
||||||
static arrayBufferToStringBase64 = (arrayBuffer: ArrayBuffer): string =>
|
static arrayBufferToStringBase64 = (arrayBuffer: ArrayBuffer): string =>
|
||||||
window.dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('base64');
|
window.dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('base64');
|
||||||
|
|
||||||
connect() {
|
async connect(): Promise<void> {
|
||||||
if (this.calledClose) {
|
if (this.calledClose) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -322,17 +328,43 @@ class MessageReceiverInner extends EventTarget {
|
||||||
|
|
||||||
this.hasConnected = true;
|
this.hasConnected = true;
|
||||||
|
|
||||||
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
|
if (this.socket && this.socket.connected) {
|
||||||
this.socket.close();
|
this.socket.close();
|
||||||
|
this.socket = undefined;
|
||||||
if (this.wsr) {
|
if (this.wsr) {
|
||||||
this.wsr.close();
|
this.wsr.close();
|
||||||
|
this.wsr = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.socketStatus = SocketStatus.CONNECTING;
|
||||||
|
|
||||||
// initialize the socket and start listening for messages
|
// initialize the socket and start listening for messages
|
||||||
this.socket = this.server.getMessageSocket();
|
try {
|
||||||
this.socket.onclose = this.onclose.bind(this);
|
this.socket = await this.server.getMessageSocket();
|
||||||
this.socket.onerror = this.onerror.bind(this);
|
} catch (error) {
|
||||||
this.socket.onopen = this.onopen.bind(this);
|
this.socketStatus = SocketStatus.CLOSED;
|
||||||
|
|
||||||
|
const event = new Event('error');
|
||||||
|
event.error = error;
|
||||||
|
await this.dispatchAndWait(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.socketStatus = SocketStatus.OPEN;
|
||||||
|
|
||||||
|
window.log.info('websocket open');
|
||||||
|
window.logMessageReceiverConnect();
|
||||||
|
|
||||||
|
if (!this._onClose) {
|
||||||
|
this._onClose = this.onclose.bind(this);
|
||||||
|
}
|
||||||
|
if (!this._onError) {
|
||||||
|
this._onError = this.onerror.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.socket.on('close', this._onClose);
|
||||||
|
this.socket.on('error', this._onError);
|
||||||
|
|
||||||
this.wsr = new WebSocketResource(this.socket, {
|
this.wsr = new WebSocketResource(this.socket, {
|
||||||
handleRequest: this.handleRequest.bind(this),
|
handleRequest: this.handleRequest.bind(this),
|
||||||
keepalive: {
|
keepalive: {
|
||||||
|
@ -342,7 +374,6 @@ class MessageReceiverInner extends EventTarget {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Because sometimes the socket doesn't properly emit its close event
|
// Because sometimes the socket doesn't properly emit its close event
|
||||||
this._onClose = this.onclose.bind(this);
|
|
||||||
if (this._onClose) {
|
if (this._onClose) {
|
||||||
this.wsr.addEventListener('close', this._onClose);
|
this.wsr.addEventListener('close', this._onClose);
|
||||||
}
|
}
|
||||||
|
@ -362,9 +393,12 @@ class MessageReceiverInner extends EventTarget {
|
||||||
|
|
||||||
shutdown() {
|
shutdown() {
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
this.socket.onclose = noop;
|
if (this._onClose) {
|
||||||
this.socket.onerror = noop;
|
this.socket.removeListener('close', this._onClose);
|
||||||
this.socket.onopen = noop;
|
}
|
||||||
|
if (this._onError) {
|
||||||
|
this.socket.removeListener('error', this._onError);
|
||||||
|
}
|
||||||
|
|
||||||
this.socket = undefined;
|
this.socket = undefined;
|
||||||
}
|
}
|
||||||
|
@ -380,6 +414,7 @@ class MessageReceiverInner extends EventTarget {
|
||||||
async close() {
|
async close() {
|
||||||
window.log.info('MessageReceiver.close()');
|
window.log.info('MessageReceiver.close()');
|
||||||
this.calledClose = true;
|
this.calledClose = true;
|
||||||
|
this.socketStatus = SocketStatus.CLOSING;
|
||||||
|
|
||||||
// Our WebSocketResource instance will close the socket and emit a 'close' event
|
// Our WebSocketResource instance will close the socket and emit a 'close' event
|
||||||
// if the socket doesn't emit one quickly enough.
|
// if the socket doesn't emit one quickly enough.
|
||||||
|
@ -392,13 +427,8 @@ class MessageReceiverInner extends EventTarget {
|
||||||
return this.drain();
|
return this.drain();
|
||||||
}
|
}
|
||||||
|
|
||||||
onopen() {
|
async onerror(error: Error): Promise<void> {
|
||||||
window.log.info('websocket open');
|
window.log.error('websocket error', error);
|
||||||
window.logMessageReceiverConnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
onerror() {
|
|
||||||
window.log.error('websocket error');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispatchAndWait(event: Event) {
|
async dispatchAndWait(event: Event) {
|
||||||
|
@ -407,35 +437,41 @@ class MessageReceiverInner extends EventTarget {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
async onclose(ev: any) {
|
async onclose(code: number, reason: string): Promise<void> {
|
||||||
window.log.info(
|
window.log.info(
|
||||||
'websocket closed',
|
'websocket closed',
|
||||||
ev.code,
|
code,
|
||||||
ev.reason || '',
|
reason || '',
|
||||||
'calledClose:',
|
'calledClose:',
|
||||||
this.calledClose
|
this.calledClose
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.socketStatus = SocketStatus.CLOSED;
|
||||||
|
|
||||||
this.shutdown();
|
this.shutdown();
|
||||||
|
|
||||||
if (this.calledClose) {
|
if (this.calledClose) {
|
||||||
return Promise.resolve();
|
return;
|
||||||
}
|
}
|
||||||
if (ev.code === 3000) {
|
if (code === 3000) {
|
||||||
return Promise.resolve();
|
return;
|
||||||
}
|
}
|
||||||
if (ev.code === 3001) {
|
if (code === 3001) {
|
||||||
this.onEmpty();
|
this.onEmpty();
|
||||||
}
|
}
|
||||||
// possible 403 or network issue. Make an request to confirm
|
|
||||||
return this.server
|
await sleep(RECONNECT_DELAY);
|
||||||
.getDevices()
|
|
||||||
.then(this.connect.bind(this)) // No HTTP error? Reconnect
|
// Try to reconnect (if there is an error - we'll get an
|
||||||
.catch(async e => {
|
// `error` event from `connect()` and hit the retry backoff logic in
|
||||||
const event = new Event('error');
|
// `ts/background.ts`)
|
||||||
event.error = e;
|
await this.connect();
|
||||||
return this.dispatchAndWait(event);
|
}
|
||||||
});
|
|
||||||
|
checkSocket(): void {
|
||||||
|
if (this.wsr) {
|
||||||
|
this.wsr.forceKeepAlive();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRequest(request: IncomingWebSocketRequest) {
|
handleRequest(request: IncomingWebSocketRequest) {
|
||||||
|
@ -1076,14 +1112,8 @@ class MessageReceiverInner extends EventTarget {
|
||||||
throw new Error('Received message with no content and no legacyMessage');
|
throw new Error('Received message with no content and no legacyMessage');
|
||||||
}
|
}
|
||||||
|
|
||||||
getStatus() {
|
getStatus(): SocketStatus {
|
||||||
if (this.socket) {
|
return this.socketStatus;
|
||||||
return this.socket.readyState;
|
|
||||||
}
|
|
||||||
if (this.hasConnected) {
|
|
||||||
return WebSocket.CLOSED;
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async onDeliveryReceipt(envelope: EnvelopeClass): Promise<void> {
|
async onDeliveryReceipt(envelope: EnvelopeClass): Promise<void> {
|
||||||
|
@ -2693,6 +2723,7 @@ export default class MessageReceiver {
|
||||||
this.hasEmptied = inner.hasEmptied.bind(inner);
|
this.hasEmptied = inner.hasEmptied.bind(inner);
|
||||||
this.removeEventListener = inner.removeEventListener.bind(inner);
|
this.removeEventListener = inner.removeEventListener.bind(inner);
|
||||||
this.stopProcessing = inner.stopProcessing.bind(inner);
|
this.stopProcessing = inner.stopProcessing.bind(inner);
|
||||||
|
this.checkSocket = inner.checkSocket.bind(inner);
|
||||||
this.unregisterBatchers = inner.unregisterBatchers.bind(inner);
|
this.unregisterBatchers = inner.unregisterBatchers.bind(inner);
|
||||||
|
|
||||||
inner.connect();
|
inner.connect();
|
||||||
|
@ -2707,7 +2738,7 @@ export default class MessageReceiver {
|
||||||
attachment: AttachmentPointerClass
|
attachment: AttachmentPointerClass
|
||||||
) => Promise<DownloadAttachmentType>;
|
) => Promise<DownloadAttachmentType>;
|
||||||
|
|
||||||
getStatus: () => number;
|
getStatus: () => SocketStatus;
|
||||||
|
|
||||||
hasEmptied: () => boolean;
|
hasEmptied: () => boolean;
|
||||||
|
|
||||||
|
@ -2717,6 +2748,8 @@ export default class MessageReceiver {
|
||||||
|
|
||||||
unregisterBatchers: () => void;
|
unregisterBatchers: () => void;
|
||||||
|
|
||||||
|
checkSocket: () => void;
|
||||||
|
|
||||||
getProcessedCount: () => number;
|
getProcessedCount: () => number;
|
||||||
|
|
||||||
static stringToArrayBuffer = MessageReceiverInner.stringToArrayBuffer;
|
static stringToArrayBuffer = MessageReceiverInner.stringToArrayBuffer;
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
|
|
||||||
import fetch, { Response } from 'node-fetch';
|
import fetch, { Response } from 'node-fetch';
|
||||||
import ProxyAgent from 'proxy-agent';
|
import ProxyAgent from 'proxy-agent';
|
||||||
import { Agent } from 'https';
|
import { Agent, RequestOptions } from 'https';
|
||||||
import pProps from 'p-props';
|
import pProps from 'p-props';
|
||||||
import {
|
import {
|
||||||
compact,
|
compact,
|
||||||
|
@ -25,9 +25,11 @@ import { pki } from 'node-forge';
|
||||||
import is from '@sindresorhus/is';
|
import is from '@sindresorhus/is';
|
||||||
import PQueue from 'p-queue';
|
import PQueue from 'p-queue';
|
||||||
import { v4 as getGuid } from 'uuid';
|
import { v4 as getGuid } from 'uuid';
|
||||||
|
import { client as WebSocketClient, connection as WebSocket } from 'websocket';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { Long } from '../window.d';
|
import { Long } from '../window.d';
|
||||||
|
import { assert } from '../util/assert';
|
||||||
import { getUserAgent } from '../util/getUserAgent';
|
import { getUserAgent } from '../util/getUserAgent';
|
||||||
import { toWebSafeBase64 } from '../util/webSafeBase64';
|
import { toWebSafeBase64 } from '../util/webSafeBase64';
|
||||||
import { isPackIdValid, redactPackId } from '../../js/modules/stickers';
|
import { isPackIdValid, redactPackId } from '../../js/modules/stickers';
|
||||||
|
@ -59,7 +61,6 @@ import {
|
||||||
StorageServiceCredentials,
|
StorageServiceCredentials,
|
||||||
} from '../textsecure.d';
|
} from '../textsecure.d';
|
||||||
|
|
||||||
import { WebSocket } from './WebSocket';
|
|
||||||
import MessageSender from './SendMessage';
|
import MessageSender from './SendMessage';
|
||||||
|
|
||||||
// Note: this will break some code that expects to be able to use err.response when a
|
// Note: this will break some code that expects to be able to use err.response when a
|
||||||
|
@ -261,31 +262,85 @@ function _validateResponse(response: any, schema: any) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _createSocket(
|
export type ConnectSocketOptions = Readonly<{
|
||||||
|
certificateAuthority: string;
|
||||||
|
proxyUrl?: string;
|
||||||
|
version: string;
|
||||||
|
timeout?: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const TEN_SECONDS = 1000 * 10;
|
||||||
|
|
||||||
|
async function _connectSocket(
|
||||||
url: string,
|
url: string,
|
||||||
{
|
{
|
||||||
certificateAuthority,
|
certificateAuthority,
|
||||||
proxyUrl,
|
proxyUrl,
|
||||||
version,
|
version,
|
||||||
}: { certificateAuthority: string; proxyUrl?: string; version: string }
|
timeout = TEN_SECONDS,
|
||||||
) {
|
}: ConnectSocketOptions
|
||||||
let requestOptions;
|
): Promise<WebSocket> {
|
||||||
|
let tlsOptions: RequestOptions = {
|
||||||
|
ca: certificateAuthority,
|
||||||
|
};
|
||||||
if (proxyUrl) {
|
if (proxyUrl) {
|
||||||
requestOptions = {
|
tlsOptions = {
|
||||||
ca: certificateAuthority,
|
...tlsOptions,
|
||||||
agent: new ProxyAgent(proxyUrl),
|
agent: new ProxyAgent(proxyUrl),
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
requestOptions = {
|
|
||||||
ca: certificateAuthority,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
'User-Agent': getUserAgent(version),
|
'User-Agent': getUserAgent(version),
|
||||||
};
|
};
|
||||||
return new WebSocket(url, undefined, undefined, headers, requestOptions, {
|
const client = new WebSocketClient({
|
||||||
|
tlsOptions,
|
||||||
maxReceivedFrameSize: 0x210000,
|
maxReceivedFrameSize: 0x210000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
client.connect(url, undefined, undefined, headers);
|
||||||
|
|
||||||
|
const { stack } = new Error();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
reject(new Error('Connection timed out'));
|
||||||
|
|
||||||
|
client.abort();
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
client.on('connect', socket => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('httpResponse', async response => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
const statusCode = response.statusCode || -1;
|
||||||
|
await _handleStatusCode(statusCode);
|
||||||
|
|
||||||
|
const error = makeHTTPError(
|
||||||
|
'promiseAjax: invalid websocket response',
|
||||||
|
statusCode || -1,
|
||||||
|
{}, // headers
|
||||||
|
undefined,
|
||||||
|
stack
|
||||||
|
);
|
||||||
|
|
||||||
|
const translatedError = _translateError(error);
|
||||||
|
assert(
|
||||||
|
translatedError,
|
||||||
|
'`httpResponse` event cannot be emitted with 200 status code'
|
||||||
|
);
|
||||||
|
|
||||||
|
reject(translatedError);
|
||||||
|
});
|
||||||
|
client.on('connectFailed', error => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const FIVE_MINUTES = 1000 * 60 * 5;
|
const FIVE_MINUTES = 1000 * 60 * 5;
|
||||||
|
@ -403,6 +458,56 @@ function getHostname(url: string): string {
|
||||||
return urlObject.hostname;
|
return urlObject.hostname;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _handleStatusCode(
|
||||||
|
status: number,
|
||||||
|
unauthenticated = false
|
||||||
|
): Promise<void> {
|
||||||
|
if (status === 499) {
|
||||||
|
window.log.error('Got 499 from Signal Server. Build is expired.');
|
||||||
|
await window.storage.put('remoteBuildExpiration', Date.now());
|
||||||
|
window.reduxActions.expiration.hydrateExpirationStatus(true);
|
||||||
|
}
|
||||||
|
if (!unauthenticated && status === 401) {
|
||||||
|
window.log.error('Got 401 from Signal Server. We might be unlinked.');
|
||||||
|
window.Whisper.events.trigger('mightBeUnlinked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _translateError(error: Error): Error | undefined {
|
||||||
|
const { code } = error;
|
||||||
|
if (code === 200) {
|
||||||
|
// Happens sometimes when we get no response. Might be nice to get 204 instead.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let message: string;
|
||||||
|
switch (code) {
|
||||||
|
case -1:
|
||||||
|
message =
|
||||||
|
'Failed to connect to the server, please check your network connection.';
|
||||||
|
break;
|
||||||
|
case 413:
|
||||||
|
message = 'Rate limit exceeded, please try again later.';
|
||||||
|
break;
|
||||||
|
case 403:
|
||||||
|
message = 'Invalid code, please try again.';
|
||||||
|
break;
|
||||||
|
case 417:
|
||||||
|
message = 'Number already registered.';
|
||||||
|
break;
|
||||||
|
case 401:
|
||||||
|
message =
|
||||||
|
'Invalid authentication, most likely someone re-registered and invalidated our registration.';
|
||||||
|
break;
|
||||||
|
case 404:
|
||||||
|
message = 'Number is not registered.';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
message = 'The server rejected our query, please file a bug report.';
|
||||||
|
}
|
||||||
|
error.message = `${message} (original: ${error.message})`;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
async function _promiseAjax(
|
async function _promiseAjax(
|
||||||
providedUrl: string | null,
|
providedUrl: string | null,
|
||||||
options: PromiseAjaxOptionsType
|
options: PromiseAjaxOptionsType
|
||||||
|
@ -487,25 +592,11 @@ async function _promiseAjax(
|
||||||
|
|
||||||
fetch(url, fetchOptions)
|
fetch(url, fetchOptions)
|
||||||
.then(async response => {
|
.then(async response => {
|
||||||
if (options.serverUrl) {
|
if (
|
||||||
if (
|
options.serverUrl &&
|
||||||
response.status === 499 &&
|
getHostname(options.serverUrl) === getHostname(url)
|
||||||
getHostname(options.serverUrl) === getHostname(url)
|
) {
|
||||||
) {
|
await _handleStatusCode(response.status, unauthenticated);
|
||||||
window.log.error('Got 499 from Signal Server. Build is expired.');
|
|
||||||
await window.storage.put('remoteBuildExpiration', Date.now());
|
|
||||||
window.reduxActions.expiration.hydrateExpirationStatus(true);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!unauthenticated &&
|
|
||||||
response.status === 401 &&
|
|
||||||
getHostname(options.serverUrl) === getHostname(url)
|
|
||||||
) {
|
|
||||||
window.log.error(
|
|
||||||
'Got 401 from Signal Server. We might be unlinked.'
|
|
||||||
);
|
|
||||||
window.Whisper.events.trigger('mightBeUnlinked');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let resultPromise;
|
let resultPromise;
|
||||||
|
@ -863,7 +954,7 @@ export type WebAPIType = {
|
||||||
deviceId?: number,
|
deviceId?: number,
|
||||||
options?: { accessKey?: string }
|
options?: { accessKey?: string }
|
||||||
) => Promise<ServerKeysType>;
|
) => Promise<ServerKeysType>;
|
||||||
getMessageSocket: () => WebSocket;
|
getMessageSocket: () => Promise<WebSocket>;
|
||||||
getMyKeys: () => Promise<number>;
|
getMyKeys: () => Promise<number>;
|
||||||
getProfile: (
|
getProfile: (
|
||||||
identifier: string,
|
identifier: string,
|
||||||
|
@ -880,7 +971,7 @@ export type WebAPIType = {
|
||||||
profileKeyCredentialRequest?: string;
|
profileKeyCredentialRequest?: string;
|
||||||
}
|
}
|
||||||
) => Promise<any>;
|
) => Promise<any>;
|
||||||
getProvisioningSocket: () => WebSocket;
|
getProvisioningSocket: () => Promise<WebSocket>;
|
||||||
getSenderCertificate: (
|
getSenderCertificate: (
|
||||||
withUuid?: boolean
|
withUuid?: boolean
|
||||||
) => Promise<{ certificate: string }>;
|
) => Promise<{ certificate: string }>;
|
||||||
|
@ -1153,39 +1244,10 @@ export function initialize({
|
||||||
unauthenticated: param.unauthenticated,
|
unauthenticated: param.unauthenticated,
|
||||||
accessKey: param.accessKey,
|
accessKey: param.accessKey,
|
||||||
}).catch((e: Error) => {
|
}).catch((e: Error) => {
|
||||||
const { code } = e;
|
const translatedError = _translateError(e);
|
||||||
if (code === 200) {
|
if (translatedError) {
|
||||||
// Happens sometimes when we get no response. Might be nice to get 204 instead.
|
throw translatedError;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
let message: string;
|
|
||||||
switch (code) {
|
|
||||||
case -1:
|
|
||||||
message =
|
|
||||||
'Failed to connect to the server, please check your network connection.';
|
|
||||||
break;
|
|
||||||
case 413:
|
|
||||||
message = 'Rate limit exceeded, please try again later.';
|
|
||||||
break;
|
|
||||||
case 403:
|
|
||||||
message = 'Invalid code, please try again.';
|
|
||||||
break;
|
|
||||||
case 417:
|
|
||||||
message = 'Number already registered.';
|
|
||||||
break;
|
|
||||||
case 401:
|
|
||||||
message =
|
|
||||||
'Invalid authentication, most likely someone re-registered and invalidated our registration.';
|
|
||||||
break;
|
|
||||||
case 404:
|
|
||||||
message = 'Number is not registered.';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
message =
|
|
||||||
'The server rejected our query, please file a bug report.';
|
|
||||||
}
|
|
||||||
e.message = `${message} (original: ${e.message})`;
|
|
||||||
throw e;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2318,7 +2380,7 @@ export function initialize({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMessageSocket() {
|
function getMessageSocket(): Promise<WebSocket> {
|
||||||
window.log.info('opening message socket', url);
|
window.log.info('opening message socket', url);
|
||||||
const fixedScheme = url
|
const fixedScheme = url
|
||||||
.replace('https://', 'wss://')
|
.replace('https://', 'wss://')
|
||||||
|
@ -2327,20 +2389,20 @@ export function initialize({
|
||||||
const pass = encodeURIComponent(password);
|
const pass = encodeURIComponent(password);
|
||||||
const clientVersion = encodeURIComponent(version);
|
const clientVersion = encodeURIComponent(version);
|
||||||
|
|
||||||
return _createSocket(
|
return _connectSocket(
|
||||||
`${fixedScheme}/v1/websocket/?login=${login}&password=${pass}&agent=OWD&version=${clientVersion}`,
|
`${fixedScheme}/v1/websocket/?login=${login}&password=${pass}&agent=OWD&version=${clientVersion}`,
|
||||||
{ certificateAuthority, proxyUrl, version }
|
{ certificateAuthority, proxyUrl, version }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProvisioningSocket() {
|
function getProvisioningSocket(): Promise<WebSocket> {
|
||||||
window.log.info('opening provisioning socket', url);
|
window.log.info('opening provisioning socket', url);
|
||||||
const fixedScheme = url
|
const fixedScheme = url
|
||||||
.replace('https://', 'wss://')
|
.replace('https://', 'wss://')
|
||||||
.replace('http://', 'ws://');
|
.replace('http://', 'ws://');
|
||||||
const clientVersion = encodeURIComponent(version);
|
const clientVersion = encodeURIComponent(version);
|
||||||
|
|
||||||
return _createSocket(
|
return _connectSocket(
|
||||||
`${fixedScheme}/v1/websocket/provisioning/?agent=OWD&version=${clientVersion}`,
|
`${fixedScheme}/v1/websocket/provisioning/?agent=OWD&version=${clientVersion}`,
|
||||||
{ certificateAuthority, proxyUrl, version }
|
{ certificateAuthority, proxyUrl, version }
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
import { w3cwebsocket } from 'websocket';
|
|
||||||
|
|
||||||
type ModifiedEventSource = Omit<EventSource, 'onerror'>;
|
|
||||||
|
|
||||||
declare class ModifiedWebSocket
|
|
||||||
extends w3cwebsocket
|
|
||||||
implements ModifiedEventSource {
|
|
||||||
withCredentials: boolean;
|
|
||||||
|
|
||||||
addEventListener: EventSource['addEventListener'];
|
|
||||||
|
|
||||||
removeEventListener: EventSource['removeEventListener'];
|
|
||||||
|
|
||||||
dispatchEvent: EventSource['dispatchEvent'];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WebSocket = ModifiedWebSocket;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
|
||||||
export const WebSocket = w3cwebsocket as typeof ModifiedWebSocket;
|
|
|
@ -27,12 +27,12 @@
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { connection as WebSocket, IMessage } from 'websocket';
|
||||||
|
|
||||||
import { ByteBufferClass } from '../window.d';
|
import { ByteBufferClass } from '../window.d';
|
||||||
|
|
||||||
import EventTarget from './EventTarget';
|
import EventTarget from './EventTarget';
|
||||||
|
|
||||||
import { WebSocket } from './WebSocket';
|
|
||||||
|
|
||||||
class Request {
|
class Request {
|
||||||
verb: string;
|
verb: string;
|
||||||
|
|
||||||
|
@ -92,14 +92,13 @@ export class IncomingWebSocketRequest {
|
||||||
this.headers = request.headers;
|
this.headers = request.headers;
|
||||||
|
|
||||||
this.respond = (status, message) => {
|
this.respond = (status, message) => {
|
||||||
socket.send(
|
const ab = new window.textsecure.protobuf.WebSocketMessage({
|
||||||
new window.textsecure.protobuf.WebSocketMessage({
|
type: window.textsecure.protobuf.WebSocketMessage.Type.RESPONSE,
|
||||||
type: window.textsecure.protobuf.WebSocketMessage.Type.RESPONSE,
|
response: { id: request.id, message, status },
|
||||||
response: { id: request.id, message, status },
|
})
|
||||||
})
|
.encode()
|
||||||
.encode()
|
.toArrayBuffer();
|
||||||
.toArrayBuffer()
|
socket.sendBytes(Buffer.from(ab));
|
||||||
);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -111,20 +110,19 @@ class OutgoingWebSocketRequest {
|
||||||
constructor(options: any, socket: WebSocket) {
|
constructor(options: any, socket: WebSocket) {
|
||||||
const request = new Request(options);
|
const request = new Request(options);
|
||||||
outgoing[request.id] = request;
|
outgoing[request.id] = request;
|
||||||
socket.send(
|
const ab = new window.textsecure.protobuf.WebSocketMessage({
|
||||||
new window.textsecure.protobuf.WebSocketMessage({
|
type: window.textsecure.protobuf.WebSocketMessage.Type.REQUEST,
|
||||||
type: window.textsecure.protobuf.WebSocketMessage.Type.REQUEST,
|
request: {
|
||||||
request: {
|
verb: request.verb,
|
||||||
verb: request.verb,
|
path: request.path,
|
||||||
path: request.path,
|
body: request.body,
|
||||||
body: request.body,
|
headers: request.headers,
|
||||||
headers: request.headers,
|
id: request.id,
|
||||||
id: request.id,
|
},
|
||||||
},
|
})
|
||||||
})
|
.encode()
|
||||||
.encode()
|
.toArrayBuffer();
|
||||||
.toArrayBuffer()
|
socket.sendBytes(Buffer.from(ab));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,66 +147,58 @@ export default class WebSocketResource extends EventTarget {
|
||||||
this.sendRequest = options => new OutgoingWebSocketRequest(options, socket);
|
this.sendRequest = options => new OutgoingWebSocketRequest(options, socket);
|
||||||
|
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
socket.onmessage = socketMessage => {
|
const onMessage = ({ type, binaryData }: IMessage): void => {
|
||||||
const blob = socketMessage.data;
|
if (type !== 'binary' || !binaryData) {
|
||||||
const handleArrayBuffer = (buffer: ArrayBuffer) => {
|
throw new Error(`Unsupported websocket message type: ${type}`);
|
||||||
const message = window.textsecure.protobuf.WebSocketMessage.decode(
|
}
|
||||||
buffer
|
|
||||||
|
const message = window.textsecure.protobuf.WebSocketMessage.decode(
|
||||||
|
binaryData
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
message.type ===
|
||||||
|
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST &&
|
||||||
|
message.request
|
||||||
|
) {
|
||||||
|
handleRequest(
|
||||||
|
new IncomingWebSocketRequest({
|
||||||
|
verb: message.request.verb,
|
||||||
|
path: message.request.path,
|
||||||
|
body: message.request.body,
|
||||||
|
headers: message.request.headers,
|
||||||
|
id: message.request.id,
|
||||||
|
socket,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
if (
|
} else if (
|
||||||
message.type ===
|
message.type ===
|
||||||
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST &&
|
window.textsecure.protobuf.WebSocketMessage.Type.RESPONSE &&
|
||||||
message.request
|
message.response
|
||||||
) {
|
) {
|
||||||
handleRequest(
|
const { response } = message;
|
||||||
new IncomingWebSocketRequest({
|
const request = outgoing[response.id];
|
||||||
verb: message.request.verb,
|
if (request) {
|
||||||
path: message.request.path,
|
request.response = response;
|
||||||
body: message.request.body,
|
let callback = request.error;
|
||||||
headers: message.request.headers,
|
if (
|
||||||
id: message.request.id,
|
response.status &&
|
||||||
socket,
|
response.status >= 200 &&
|
||||||
})
|
response.status < 300
|
||||||
);
|
) {
|
||||||
} else if (
|
callback = request.success;
|
||||||
message.type ===
|
|
||||||
window.textsecure.protobuf.WebSocketMessage.Type.RESPONSE &&
|
|
||||||
message.response
|
|
||||||
) {
|
|
||||||
const { response } = message;
|
|
||||||
const request = outgoing[response.id];
|
|
||||||
if (request) {
|
|
||||||
request.response = response;
|
|
||||||
let callback = request.error;
|
|
||||||
if (
|
|
||||||
response.status &&
|
|
||||||
response.status >= 200 &&
|
|
||||||
response.status < 300
|
|
||||||
) {
|
|
||||||
callback = request.success;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof callback === 'function') {
|
|
||||||
callback(response.message, response.status, request);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
`Received response for unknown request ${message.response.id}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (blob instanceof ArrayBuffer) {
|
if (typeof callback === 'function') {
|
||||||
handleArrayBuffer(blob);
|
callback(response.message, response.status, request);
|
||||||
} else {
|
}
|
||||||
const reader = new FileReader();
|
} else {
|
||||||
reader.onload = () => {
|
throw new Error(
|
||||||
handleArrayBuffer(reader.result as ArrayBuffer);
|
`Received response for unknown request ${message.response.id}`
|
||||||
};
|
);
|
||||||
reader.readAsArrayBuffer(blob as any);
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
socket.on('message', onMessage);
|
||||||
|
|
||||||
if (opts.keepalive) {
|
if (opts.keepalive) {
|
||||||
this.keepalive = new KeepAlive(this, {
|
this.keepalive = new KeepAlive(this, {
|
||||||
|
@ -217,15 +207,13 @@ export default class WebSocketResource extends EventTarget {
|
||||||
});
|
});
|
||||||
const resetKeepAliveTimer = this.keepalive.reset.bind(this.keepalive);
|
const resetKeepAliveTimer = this.keepalive.reset.bind(this.keepalive);
|
||||||
|
|
||||||
socket.addEventListener('open', resetKeepAliveTimer);
|
this.keepalive.reset();
|
||||||
socket.addEventListener('message', resetKeepAliveTimer);
|
|
||||||
socket.addEventListener(
|
socket.on('message', resetKeepAliveTimer);
|
||||||
'close',
|
socket.on('close', this.keepalive.stop.bind(this.keepalive));
|
||||||
this.keepalive.stop.bind(this.keepalive)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.addEventListener('close', () => {
|
socket.on('close', () => {
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -242,7 +230,7 @@ export default class WebSocketResource extends EventTarget {
|
||||||
socket.close(code, reason);
|
socket.close(code, reason);
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
socket.onmessage = undefined;
|
socket.removeListener('message', onMessage);
|
||||||
|
|
||||||
// On linux the socket can wait a long time to emit its close event if we've
|
// On linux the socket can wait a long time to emit its close event if we've
|
||||||
// lost the internet connection. On the order of minutes. This speeds that
|
// lost the internet connection. On the order of minutes. This speeds that
|
||||||
|
@ -261,6 +249,13 @@ export default class WebSocketResource extends EventTarget {
|
||||||
}, 5000);
|
}, 5000);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public forceKeepAlive(): void {
|
||||||
|
if (!this.keepalive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.keepalive.send();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type KeepAliveOptionsType = {
|
type KeepAliveOptionsType = {
|
||||||
|
@ -269,15 +264,15 @@ type KeepAliveOptionsType = {
|
||||||
};
|
};
|
||||||
|
|
||||||
class KeepAlive {
|
class KeepAlive {
|
||||||
keepAliveTimer: any;
|
private keepAliveTimer: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
disconnectTimer: any;
|
private disconnectTimer: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
path: string;
|
private path: string;
|
||||||
|
|
||||||
disconnect: boolean;
|
private disconnect: boolean;
|
||||||
|
|
||||||
wsr: WebSocketResource;
|
private wsr: WebSocketResource;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
websocketResource: WebSocketResource,
|
websocketResource: WebSocketResource,
|
||||||
|
@ -292,30 +287,46 @@ class KeepAlive {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
public stop(): void {
|
||||||
clearTimeout(this.keepAliveTimer);
|
this.clearTimers();
|
||||||
clearTimeout(this.disconnectTimer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
public send(): void {
|
||||||
clearTimeout(this.keepAliveTimer);
|
this.clearTimers();
|
||||||
clearTimeout(this.disconnectTimer);
|
|
||||||
this.keepAliveTimer = setTimeout(() => {
|
if (this.disconnect) {
|
||||||
if (this.disconnect) {
|
// automatically disconnect if server doesn't ack
|
||||||
// automatically disconnect if server doesn't ack
|
this.disconnectTimer = setTimeout(() => {
|
||||||
this.disconnectTimer = setTimeout(() => {
|
this.clearTimers();
|
||||||
clearTimeout(this.keepAliveTimer);
|
|
||||||
this.wsr.close(3001, 'No response to keepalive request');
|
this.wsr.close(3001, 'No response to keepalive request');
|
||||||
}, 10000);
|
}, 10000);
|
||||||
} else {
|
} else {
|
||||||
this.reset();
|
this.reset();
|
||||||
}
|
}
|
||||||
window.log.info('Sending a keepalive message');
|
|
||||||
this.wsr.sendRequest({
|
window.log.info('WebSocketResources: Sending a keepalive message');
|
||||||
verb: 'GET',
|
this.wsr.sendRequest({
|
||||||
path: this.path,
|
verb: 'GET',
|
||||||
success: this.reset.bind(this),
|
path: this.path,
|
||||||
});
|
success: this.reset.bind(this),
|
||||||
}, 55000);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this.clearTimers();
|
||||||
|
|
||||||
|
this.keepAliveTimer = setTimeout(() => this.send(), 55000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimers(): void {
|
||||||
|
if (this.keepAliveTimer) {
|
||||||
|
clearTimeout(this.keepAliveTimer);
|
||||||
|
this.keepAliveTimer = undefined;
|
||||||
|
}
|
||||||
|
if (this.disconnectTimer) {
|
||||||
|
clearTimeout(this.disconnectTimer);
|
||||||
|
this.disconnectTimer = undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,8 @@
|
||||||
// Maps to values found here: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState
|
// Maps to values found here: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState
|
||||||
// which are returned by libtextsecure's MessageReceiver
|
// which are returned by libtextsecure's MessageReceiver
|
||||||
export enum SocketStatus {
|
export enum SocketStatus {
|
||||||
CONNECTING,
|
CONNECTING = 'CONNECTING',
|
||||||
OPEN,
|
OPEN = 'OPEN',
|
||||||
CLOSING,
|
CLOSING = 'CLOSING',
|
||||||
CLOSED,
|
CLOSED = 'CLOSED',
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
export class BackOff {
|
||||||
|
private count = 0;
|
||||||
|
|
||||||
|
constructor(private readonly timeouts: ReadonlyArray<number>) {}
|
||||||
|
|
||||||
|
public get(): number {
|
||||||
|
return this.timeouts[this.count];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAndIncrement(): number {
|
||||||
|
const result = this.get();
|
||||||
|
if (!this.isFull()) {
|
||||||
|
this.count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this.count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isFull(): boolean {
|
||||||
|
return this.count === this.timeouts.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getIndex(): number {
|
||||||
|
return this.count;
|
||||||
|
}
|
||||||
|
}
|
|
@ -108,6 +108,7 @@ import { ElectronLocaleType } from './util/mapToSupportLocale';
|
||||||
import { SignalProtocolStore } from './SignalProtocolStore';
|
import { SignalProtocolStore } from './SignalProtocolStore';
|
||||||
import { StartupQueue } from './util/StartupQueue';
|
import { StartupQueue } from './util/StartupQueue';
|
||||||
import * as synchronousCrypto from './util/synchronousCrypto';
|
import * as synchronousCrypto from './util/synchronousCrypto';
|
||||||
|
import { SocketStatus } from './types/SocketStatus';
|
||||||
import SyncRequest from './textsecure/SyncRequest';
|
import SyncRequest from './textsecure/SyncRequest';
|
||||||
import { ConversationColorType, CustomColorType } from './types/Colors';
|
import { ConversationColorType, CustomColorType } from './types/Colors';
|
||||||
|
|
||||||
|
@ -190,7 +191,7 @@ declare global {
|
||||||
getNodeVersion: () => string;
|
getNodeVersion: () => string;
|
||||||
getServerPublicParams: () => string;
|
getServerPublicParams: () => string;
|
||||||
getSfuUrl: () => string;
|
getSfuUrl: () => string;
|
||||||
getSocketStatus: () => number;
|
getSocketStatus: () => SocketStatus;
|
||||||
getSyncRequest: (timeoutMillis?: number) => SyncRequest;
|
getSyncRequest: (timeoutMillis?: number) => SyncRequest;
|
||||||
getTitle: () => string;
|
getTitle: () => string;
|
||||||
waitForEmptyEventQueue: () => Promise<void>;
|
waitForEmptyEventQueue: () => Promise<void>;
|
||||||
|
|
Loading…
Reference in New Issue