Standalone Protocol Buffers (#2347)

This change introduces a standalone module for our protocol buffers as CommonJS
module incl. TypeScript type definitions.

**Rationale:** In order to exclude voice messages from the media gallery,
I needed to get a reference of `AttachmentPointer.Flags.VOICE_MESSAGE`.
Currently, the only way is to use `textsecure.protobuf` which is only accessible
as a global.

* [x] Add `Attachment.isVoiceMessage` as a way to test standalone
      Protocol Buffers.
* [x] Add latest version of `protobufjs`. Leave existing version in place to
      keep this change less disruptive and since it’s been stable. Hopefully we
      can move over to standalone protobufs over time to improve modularity and
      maybe even startup performance.
* [x] Add `yarn build-protobuf` command to compile `SignalService.proto` into
      standalone CommonJS module and accompanying TypeScript definitions.
      ~~Included compiled output for ease of use for other developers.
      Can revisit if changes become more frequent.~~
      Now built as part of `yarn grunt`.
* [x] Update style guide references and make sure they work!
* [x] ⚠️ Change type definition for `Attachment::file` to include `null` as
      that’s apparently a valid value for legacy Android voice messages.
This commit is contained in:
Daniel Gasienica 2018-05-07 21:57:23 -04:00
commit c7a502e2e1
22 changed files with 200 additions and 86 deletions

1
.gitignore vendored
View file

@ -24,3 +24,4 @@ test/test.js
# React / TypeScript
ts/**/*.js
ts/protobuf/*.d.ts

View file

@ -2,14 +2,19 @@
# supports `.gitignore`: https://github.com/prettier/prettier/issues/2294
# Generated files
config/local-*.json
config/local.json
dist/**
js/components.js
js/libsignal-protocol-worker.js
js/libtextsecure.js
libtextsecure/components.js
libtextsecure/test/test.js
stylesheets/*.css
test/test.js
ts/**/*.js
ts/protobuf/*.d.ts
ts/protobuf/*.js
# Third-party files
components/**

View file

@ -7,11 +7,10 @@ dist: trusty
install:
- yarn install --frozen-lockfile
script:
- yarn transpile
- yarn generate
- yarn lint
- yarn test-node
- yarn nsp check
- yarn generate
- yarn prepare-beta-build
- $(yarn bin)/build --config.extraMetadata.environment=$SIGNAL_ENV --config.mac.bundleVersion='$TRAVIS_BUILD_NUMBER' --publish=never
- ./travis.sh

View file

@ -186,17 +186,21 @@ module.exports = function(grunt) {
},
},
watch: {
sass: {
files: ['./stylesheets/*.scss'],
tasks: ['sass'],
dist: {
files: ['<%= dist.src %>', '<%= dist.res %>'],
tasks: ['copy_dist'],
},
libtextsecure: {
files: ['./libtextsecure/*.js', './libtextsecure/storage/*.js'],
tasks: ['concat:libtextsecure'],
},
dist: {
files: ['<%= dist.src %>', '<%= dist.res %>'],
tasks: ['copy_dist'],
protobuf: {
files: ['./protos/SignalService.proto'],
tasks: ['exec:build-protobuf'],
},
sass: {
files: ['./stylesheets/*.scss'],
tasks: ['sass'],
},
scripts: {
files: ['<%= jshint.files %>'],
@ -216,7 +220,10 @@ module.exports = function(grunt) {
cmd: 'tx pull',
},
transpile: {
cmd: 'npm run transpile',
cmd: 'yarn transpile',
},
'build-protobuf': {
cmd: 'yarn build-protobuf',
},
},
'test-release': {
@ -499,10 +506,11 @@ module.exports = function(grunt) {
grunt.registerTask('copy_dist', ['gitinfo', 'copy:res', 'copy:src']);
grunt.registerTask('date', ['gitinfo', 'getExpireTime']);
grunt.registerTask('default', [
'exec:build-protobuf',
'exec:transpile',
'concat',
'copy:deps',
'sass',
'date',
'exec:transpile',
]);
};

View file

@ -12,11 +12,10 @@ install:
- yarn install --frozen-lockfile
build_script:
- yarn transpile
- yarn generate
- yarn lint-windows
- yarn test-node
- yarn nsp check
- yarn generate
- node build\grunt.js
- type package.json | findstr /v certificateSubjectName > temp.json
- move temp.json package.json

View file

@ -1,10 +1,12 @@
/* global _: false */
/* global Backbone: false */
/* global Whisper: false */
/* global textsecure: false */
/* global ConversationController: false */
/* global i18n: false */
/* global getAccountManager: false */
/* global i18n: false */
/* global Signal: false */
/* global textsecure: false */
/* global Whisper: false */
/* eslint-disable more/no-then */
@ -14,8 +16,8 @@
window.Whisper = window.Whisper || {};
const { Message: TypedMessage } = window.Signal.Types;
const { deleteAttachmentData } = window.Signal.Migrations;
const { Message: TypedMessage } = Signal.Types;
const { deleteAttachmentData } = Signal.Migrations;
window.Whisper.Message = Backbone.Model.extend({
database: Whisper.Database,
@ -31,9 +33,6 @@
this.on('change:expireTimer', this.setToExpire);
this.on('unload', this.unload);
this.setToExpire();
this.VOICE_FLAG =
textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE;
},
idForLogging() {
return `${this.get('source')}.${this.get('sourceDevice')} ${this.get(
@ -246,8 +245,7 @@
});
return Object.assign({}, attachment, {
// eslint-disable-next-line no-bitwise
isVoiceMessage: Boolean(attachment.flags & this.VOICE_FLAG),
isVoiceMessage: Signal.Types.Attachment.isVoiceMessage(attachment),
thumbnail: thumbnailWithObjectUrl,
});
},

View file

@ -179,4 +179,5 @@ exports.deleteData = deleteAttachmentData => {
};
};
exports.isVoiceMessage = AttachmentTS.isVoiceMessage;
exports.save = AttachmentTS.save;

View file

@ -5,7 +5,6 @@
/* global i18n: false */
/* global Signal: false */
/* global textsecure: false */
/* global Whisper: false */
// eslint-disable-next-line func-names
@ -119,20 +118,7 @@
Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
},
isVoiceMessage() {
if (
// eslint-disable-next-line no-bitwise
this.model.flags &
textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE
) {
return true;
}
// Support for android legacy voice messages
if (this.isAudio() && this.model.fileName === null) {
return true;
}
return false;
return Signal.Types.Attachment.isVoiceMessage(this.model);
},
isAudio() {
const { contentType } = this.model;

View file

@ -2,7 +2,7 @@
<head>
<meta charset='utf-8'>
<title>libTextSecure test runner</title>
<title>libtextsecure test runner</title>
<link rel="stylesheet" href="../../components/mocha/mocha.css" />
</head>
<body>

View file

@ -15,9 +15,13 @@
"start": "electron .",
"grunt": "grunt",
"icon-gen": "electron-icon-maker --input=images/icon_1024.png --output=./build",
"generate": "npm run icon-gen && grunt",
"generate": "yarn icon-gen && yarn grunt",
"build": "build --config.extraMetadata.environment=$SIGNAL_ENV",
"build-release": "SIGNAL_ENV=production npm run build -- --config.directories.output=release",
"build-module-protobuf": "pbjs --target static-module --wrap commonjs --out ts/protobuf/compiled.js protos/*.proto && pbts --out ts/protobuf/compiled.d.ts ts/protobuf/compiled.js",
"clean-module-protobuf": "rm -f ts/protobuf/compiled.d.ts ts/protobuf/compiled.js",
"build-protobuf": "yarn build-module-protobuf",
"clean-protobuf": "yarn clean-module-protobuf",
"prepare-beta-build": "node prepare_beta_build.js",
"prepare-import-build": "node prepare_import_build.js",
"publish-to-apt": "NAME=$npm_package_name VERSION=$npm_package_version ./aptly.sh",
@ -69,6 +73,7 @@
"node-fetch": "https://github.com/scottnonnenberg/node-fetch.git#3e5f51e08c647ee5f20c43b15cf2d352d61c36b4",
"os-locale": "^2.1.0",
"pify": "^3.0.0",
"protobufjs": "^6.8.6",
"proxy-agent": "^2.1.0",
"react": "^16.2.0",
"react-dom": "^16.2.0",

View file

@ -1,28 +0,0 @@
package signalservice;
option java_package = "org.whispersystems.libsignal.protocol";
option java_outer_classname = "WhisperProtos";
message WhisperMessage {
optional bytes ephemeralKey = 1;
optional uint32 counter = 2;
optional uint32 previousCounter = 3;
optional bytes ciphertext = 4; // PushMessageContent
}
message PreKeyWhisperMessage {
optional uint32 registrationId = 5;
optional uint32 preKeyId = 1;
optional uint32 signedPreKeyId = 6;
optional bytes baseKey = 2;
optional bytes identityKey = 3;
optional bytes message = 4; // WhisperMessage
}
message KeyExchangeMessage {
optional uint32 id = 1;
optional bytes baseKey = 2;
optional bytes ephemeralKey = 3;
optional bytes identityKey = 4;
optional bytes baseKeySignature = 5;
}

View file

@ -597,7 +597,7 @@ const outgoing = new Whisper.Message({
sent_at: Date.now() - 15000,
attachments: [
{
flags: textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE,
flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE,
data: util.mp3,
fileName: 'agnus_dei.mp3',
contentType: 'audio/mp3',

View file

@ -572,7 +572,7 @@ const outgoing = new Whisper.Message({
attachments: [
{
// proposed as of afternoon of 4/6 in Quoted Replies group
flags: textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE,
flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE,
contentType: 'audio/mp3',
fileName: 'agnus_dei.mp4',
},

View file

@ -4,7 +4,7 @@ import moment from 'moment';
import formatFileSize from 'filesize';
interface Props {
fileName?: string;
fileName?: string | null;
fileSize?: number;
i18n: (key: string, values?: Array<string>) => string;
onClick?: () => void;

3
ts/protobuf/README.md Normal file
View file

@ -0,0 +1,3 @@
# Protocol Buffers
Placeholder directory for Protocol Buffers compiled to JavaScript / TypeScript.

3
ts/protobuf/index.ts Normal file
View file

@ -0,0 +1,3 @@
import { signalservice as SignalService } from './compiled';
export { SignalService };

View file

@ -19,7 +19,9 @@ export { BackboneWrapper } from '../components/utility/BackboneWrapper';
import { Quote } from '../components/conversation/Quote';
import * as HTML from '../html';
import * as Attachment from '../../ts/types/Attachment';
import * as MIME from '../../ts/types/MIME';
import { SignalService } from '../../ts/protobuf';
// TypeScript wants two things when you import:
// 1) a normal typescript file
@ -125,10 +127,12 @@ parent.ReactDOM = ReactDOM;
parent.Signal.HTML = HTML;
parent.Signal.Types.MIME = MIME;
parent.Signal.Types.Attachment = Attachment;
parent.Signal.Components = {
Quote,
};
parent.Signal.Util = Util;
parent.SignalService = SignalService;
parent.filesize = filesize;
parent.ConversationController._initialFetchComplete = true;

View file

@ -5,7 +5,8 @@ import 'mocha';
import { assert } from 'chai';
import * as Attachment from '../../types/Attachment';
import { MIMEType } from '../../types/MIME';
import * as MIME from '../../types/MIME';
import { SignalService } from '../../protobuf';
// @ts-ignore
import { stringToArrayBuffer } from '../../../js/modules/string_to_array_buffer';
@ -14,7 +15,7 @@ describe('Attachment', () => {
it('should return file extension from content type', () => {
const input: Attachment.Attachment = {
data: stringToArrayBuffer('foo'),
contentType: 'image/gif' as MIMEType,
contentType: MIME.IMAGE_GIF,
};
assert.strictEqual(Attachment.getFileExtension(input), 'gif');
});
@ -22,7 +23,7 @@ describe('Attachment', () => {
it('should return file extension for QuickTime videos', () => {
const input: Attachment.Attachment = {
data: stringToArrayBuffer('foo'),
contentType: 'video/quicktime' as MIMEType,
contentType: MIME.VIDEO_QUICKTIME,
};
assert.strictEqual(Attachment.getFileExtension(input), 'mov');
});
@ -34,7 +35,7 @@ describe('Attachment', () => {
const attachment: Attachment.Attachment = {
fileName: 'funny-cat.mov',
data: stringToArrayBuffer('foo'),
contentType: 'video/quicktime' as MIMEType,
contentType: MIME.VIDEO_QUICKTIME,
};
const actual = Attachment.getSuggestedFilename({ attachment });
const expected = 'funny-cat.mov';
@ -45,7 +46,7 @@ describe('Attachment', () => {
it('should generate a filename based on timestamp', () => {
const attachment: Attachment.Attachment = {
data: stringToArrayBuffer('foo'),
contentType: 'video/quicktime' as MIMEType,
contentType: MIME.VIDEO_QUICKTIME,
};
const timestamp = new Date(new Date(0).getTimezoneOffset() * 60 * 1000);
const actual = Attachment.getSuggestedFilename({
@ -57,4 +58,34 @@ describe('Attachment', () => {
});
});
});
describe('isVoiceMessage', () => {
it('should return true for voice message attachment', () => {
const attachment: Attachment.Attachment = {
fileName: 'Voice Message.aac',
flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE,
data: stringToArrayBuffer('voice message'),
contentType: MIME.AUDIO_AAC,
};
assert.isTrue(Attachment.isVoiceMessage(attachment));
});
it('should return true for legacy Android voice message attachment', () => {
const attachment: Attachment.Attachment = {
fileName: null,
data: stringToArrayBuffer('voice message'),
contentType: MIME.AUDIO_MP3,
};
assert.isTrue(Attachment.isVoiceMessage(attachment));
});
it('should return false for other attachments', () => {
const attachment: Attachment.Attachment = {
fileName: 'foo.gif',
data: stringToArrayBuffer('foo'),
contentType: MIME.IMAGE_GIF,
};
assert.isFalse(Attachment.isVoiceMessage(attachment));
});
});
});

View file

@ -3,7 +3,7 @@ import { assert } from 'chai';
import * as Message from '../../../../ts/types/message/initializeAttachmentMetadata';
import { IncomingMessage } from '../../../../ts/types/Message';
import { MIMEType } from '../../../../ts/types/MIME';
import * as MIME from '../../../../ts/types/MIME';
// @ts-ignore
import { stringToArrayBuffer } from '../../../../js/modules/string_to_array_buffer';
@ -19,7 +19,7 @@ describe('Message', () => {
sent_at: 1523317140800,
attachments: [
{
contentType: 'image/jpeg' as MIMEType,
contentType: MIME.IMAGE_JPEG,
data: stringToArrayBuffer('foo'),
fileName: 'foo.jpg',
size: 1111,
@ -35,7 +35,7 @@ describe('Message', () => {
sent_at: 1523317140800,
attachments: [
{
contentType: 'image/jpeg' as MIMEType,
contentType: MIME.IMAGE_JPEG,
data: stringToArrayBuffer('foo'),
fileName: 'foo.jpg',
size: 1111,

View file

@ -2,13 +2,15 @@ import is from '@sindresorhus/is';
import moment from 'moment';
import * as GoogleChrome from '../util/GoogleChrome';
import { saveURLAsFile } from '../util/saveURLAsFile';
import * as MIME from './MIME';
import { arrayBufferToObjectURL } from '../util/arrayBufferToObjectURL';
import { MIMEType } from './MIME';
import { saveURLAsFile } from '../util/saveURLAsFile';
import { SignalService } from '../protobuf';
export type Attachment = {
fileName?: string;
contentType?: MIMEType;
fileName?: string | null;
flags?: SignalService.AttachmentPointer.Flags;
contentType?: MIME.MIMEType;
size?: number;
data: ArrayBuffer;
@ -20,15 +22,12 @@ export type Attachment = {
// thumbnail?: ArrayBuffer;
// key?: ArrayBuffer;
// digest?: ArrayBuffer;
// flags?: number;
} & Partial<AttachmentSchemaVersion3>;
interface AttachmentSchemaVersion3 {
path: string;
}
const SAVE_CONTENT_TYPE = 'application/octet-stream' as MIMEType;
export const isVisualMedia = (attachment: Attachment): boolean => {
const { contentType } = attachment;
@ -41,6 +40,26 @@ export const isVisualMedia = (attachment: Attachment): boolean => {
return isSupportedImageType || isSupportedVideoType;
};
export const isVoiceMessage = (attachment: Attachment): boolean => {
const flag = SignalService.AttachmentPointer.Flags.VOICE_MESSAGE;
const hasFlag =
// tslint:disable-next-line no-bitwise
!is.undefined(attachment.flags) && (attachment.flags & flag) === flag;
if (hasFlag) {
return true;
}
const isLegacyAndroidVoiceMessage =
!is.undefined(attachment.contentType) &&
MIME.isAudio(attachment.contentType) &&
attachment.fileName === null;
if (isLegacyAndroidVoiceMessage) {
return true;
}
return false;
};
export const save = ({
attachment,
document,
@ -57,7 +76,7 @@ export const save = ({
? getAbsolutePath(attachment.path)
: arrayBufferToObjectURL({
data: attachment.data,
type: SAVE_CONTENT_TYPE,
type: MIME.APPLICATION_OCTET_STREAM,
});
const filename = getSuggestedFilename({ attachment, timestamp });
saveURLAsFile({ url, filename, document });

View file

@ -1,5 +1,12 @@
export type MIMEType = string & { _mimeTypeBrand: any };
export const APPLICATION_OCTET_STREAM = 'application/octet-stream' as MIMEType;
export const AUDIO_AAC = 'audio/aac' as MIMEType;
export const AUDIO_MP3 = 'audio/mp3' as MIMEType;
export const IMAGE_GIF = 'image/gif' as MIMEType;
export const IMAGE_JPEG = 'image/jpeg' as MIMEType;
export const VIDEO_QUICKTIME = 'video/quicktime' as MIMEType;
export const isJPEG = (value: MIMEType): boolean => value === 'image/jpeg';
export const isImage = (value: MIMEType): boolean => value.startsWith('image/');
export const isVideo = (value: MIMEType): boolean => value.startsWith('video/');

View file

@ -22,6 +22,49 @@
"7zip-bin-mac" "~1.0.1"
"7zip-bin-win" "~2.2.0"
"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf"
"@protobufjs/base64@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735"
"@protobufjs/codegen@^2.0.4":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb"
"@protobufjs/eventemitter@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70"
"@protobufjs/fetch@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45"
dependencies:
"@protobufjs/aspromise" "^1.1.1"
"@protobufjs/inquire" "^1.1.0"
"@protobufjs/float@^1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1"
"@protobufjs/inquire@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089"
"@protobufjs/path@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d"
"@protobufjs/pool@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54"
"@protobufjs/utf8@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
"@sindresorhus/is@^0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd"
@ -56,6 +99,10 @@
version "4.14.106"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.106.tgz#6093e9a02aa567ddecfe9afadca89e53e5dce4dd"
"@types/long@^3.0.32":
version "3.0.32"
resolved "https://registry.yarnpkg.com/@types/long/-/long-3.0.32.tgz#f4e5af31e9e9b196d8e5fca8a5e2e20aa3d60b69"
"@types/mocha@^5.0.0":
version "5.0.0"
resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-5.0.0.tgz#a3014921991066193f6c8e47290d4d598dfd19e6"
@ -68,6 +115,10 @@
version "8.9.4"
resolved "https://registry.yarnpkg.com/@types/node/-/node-8.9.4.tgz#dfd327582a06c114eb6e0441fa3d6fab35edad48"
"@types/node@^8.9.4":
version "8.10.12"
resolved "https://registry.yarnpkg.com/@types/node/-/node-8.10.12.tgz#dcb66f6de39074a296534bd1a256a3c6a1c8f5b5"
"@types/qs@^6.5.1":
version "6.5.1"
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.5.1.tgz#a38f69c62528d56ba7bd1f91335a8004988d72f7"
@ -5326,6 +5377,10 @@ lolex@^2.2.0, lolex@^2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/lolex/-/lolex-2.3.2.tgz#85f9450425103bf9e7a60668ea25dc43274ca807"
long@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28"
longest-streak@^2.0.1:
version "2.0.2"
resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.2.tgz#2421b6ba939a443bb9ffebf596585a50b4c38e2e"
@ -6947,6 +7002,24 @@ prop-types@^15.5.10, prop-types@^15.6.0, prop-types@^15.6.1:
loose-envify "^1.3.1"
object-assign "^4.1.1"
protobufjs@^6.8.6:
version "6.8.6"
resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.8.6.tgz#ce3cf4fff9625b62966c455fc4c15e4331a11ca2"
dependencies:
"@protobufjs/aspromise" "^1.1.2"
"@protobufjs/base64" "^1.1.2"
"@protobufjs/codegen" "^2.0.4"
"@protobufjs/eventemitter" "^1.1.0"
"@protobufjs/fetch" "^1.1.0"
"@protobufjs/float" "^1.0.2"
"@protobufjs/inquire" "^1.1.0"
"@protobufjs/path" "^1.1.2"
"@protobufjs/pool" "^1.1.0"
"@protobufjs/utf8" "^1.1.0"
"@types/long" "^3.0.32"
"@types/node" "^8.9.4"
long "^4.0.0"
proxy-addr@~2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.3.tgz#355f262505a621646b3130a728eb647e22055341"