Repository: ShareDropio/sharedrop Branch: master Commit: 2b6a9fe5e6ca Files: 93 Total size: 131.4 KB Directory structure: gitextract_vxj0u4do/ ├── .editorconfig ├── .ember-cli ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .template-lintrc.js ├── .travis.yml ├── .watchmanconfig ├── Dockerfile ├── LICENSE ├── Procfile ├── Procfile.dev ├── README.md ├── app/ │ ├── app.js │ ├── components/ │ │ ├── circular-progress.hbs │ │ ├── circular-progress.js │ │ ├── file-field.js │ │ ├── modal-dialog.js │ │ ├── peer-avatar.js │ │ ├── peer-widget.js │ │ ├── popover-confirm.js │ │ ├── room-url.js │ │ └── user-widget.js │ ├── controllers/ │ │ ├── application.js │ │ └── index.js │ ├── helpers/ │ │ └── is-equal.js │ ├── index.html │ ├── initializers/ │ │ └── prerequisites.js │ ├── models/ │ │ ├── peer.js │ │ └── user.js │ ├── router.js │ ├── routes/ │ │ ├── application.js │ │ ├── error.js │ │ ├── index.js │ │ └── room.js │ ├── services/ │ │ ├── analytics.js │ │ ├── avatar.js │ │ ├── file.js │ │ ├── room.js │ │ └── web-rtc.js │ ├── styles/ │ │ ├── app.sass │ │ ├── base/ │ │ │ ├── _element_defaults.sass │ │ │ ├── _glyphicons_filetypes.sass │ │ │ ├── _mixins.sass │ │ │ ├── _reset.sass │ │ │ └── _variables.sass │ │ ├── layout/ │ │ │ ├── _content.sass │ │ │ ├── _footer.sass │ │ │ ├── _header.sass │ │ │ └── _media.sass │ │ └── modules/ │ │ ├── _modal.sass │ │ ├── _modules.sass │ │ ├── _popover.sass │ │ └── _users.sass │ └── templates/ │ ├── about-app.hbs │ ├── about-room.hbs │ ├── about-you.hbs │ ├── application.hbs │ ├── components/ │ │ ├── modal-dialog.hbs │ │ ├── peer-widget.hbs │ │ ├── popover-confirm.hbs │ │ └── user-widget.hbs │ ├── errors/ │ │ ├── browser-unsupported.hbs │ │ ├── filesystem-unavailable.hbs │ │ └── popovers/ │ │ ├── connection-failed.hbs │ │ └── multiple-files.hbs │ └── index.hbs ├── config/ │ ├── dotenv.js │ ├── environment.js │ ├── optional-features.json │ └── targets.js ├── ember-cli-build.js ├── firebase_rules.json ├── lib/ │ └── google-analytics/ │ ├── index.js │ └── package.json ├── newrelic.js ├── package.json ├── prettier.config.js ├── public/ │ ├── .gitkeep │ ├── .well-known/ │ │ └── brave-rewards-verification.txt │ ├── crossdomain.xml │ └── robots.txt ├── server.js ├── sharedrop.crx ├── testem.js ├── tests/ │ ├── helpers/ │ │ └── .gitkeep │ ├── index.html │ ├── integration/ │ │ └── .gitkeep │ ├── test-helper.js │ └── unit/ │ └── .gitkeep └── vendor/ ├── .gitkeep └── peer.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # EditorConfig helps developers define and maintain consistent # coding styles between different editors and IDEs # editorconfig.org root = true [*] end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true indent_style = space indent_size = 2 [*.hbs] insert_final_newline = false [*.{diff,md}] trim_trailing_whitespace = false ================================================ FILE: .ember-cli ================================================ { /** Ember CLI sends analytics information by default. The data is completely anonymous, but there are times when you might want to disable this behavior. Setting `disableAnalytics` to true will prevent any data from being sent. */ "disableAnalytics": false } ================================================ FILE: .eslintignore ================================================ # unconventional js /blueprints/*/files/ /vendor/ # compiled output /dist/ /tmp/ # dependencies /bower_components/ /node_modules/ # misc /coverage/ !.* # ember-try /.node_modules.ember-try/ /bower.json.ember-try /package.json.ember-try ================================================ FILE: .eslintrc.js ================================================ const eslintPluginNode = require('eslint-plugin-node'); module.exports = { root: true, parser: 'babel-eslint', parserOptions: { ecmaVersion: 2018, sourceType: 'module', ecmaFeatures: { legacyDecorators: true, }, }, plugins: ['ember', 'prettier'], extends: ['airbnb-base', 'plugin:ember/recommended', 'prettier'], env: { browser: true, }, rules: { 'func-names': 'off', 'no-console': 'off', 'no-underscore-dangle': 'off', 'import/no-extraneous-dependencies': 'off', 'import/no-unresolved': 'off', 'prettier/prettier': 'error', // TODO: Enable these 'ember/no-jquery': 'off', 'ember/no-observers': 'off', 'ember/no-get': 'off', }, overrides: [ // node files { files: [ '.eslintrc.js', '.template-lintrc.js', 'ember-cli-build.js', 'testem.js', 'blueprints/*/index.js', 'config/**/*.js', 'lib/*/index.js', 'server/**/*.js', ], parserOptions: { sourceType: 'script', }, env: { browser: false, node: true, }, plugins: ['node'], rules: { ...eslintPluginNode.configs.recommended.rules, // add your custom rules and overrides for node files here // this can be removed once the following is fixed // https://github.com/mysticatea/eslint-plugin-node/issues/77 'node/no-unpublished-require': 'off', }, }, ], }; ================================================ FILE: .gitignore ================================================ # See https://help.github.com/ignore-files/ for more about ignoring files. # compiled output /dist/ /tmp/ # dependencies /bower_components/ /node_modules/ # misc /.env* /.pnp* /.sass-cache /connect.lock /coverage/ /libpeerconnection.log /npm-debug.log* /testem.log /yarn-error.log # ember-try /.node_modules.ember-try/ /bower.json.ember-try /package.json.ember-try ================================================ FILE: .prettierignore ================================================ ########## # Common ########## # Duh .git/ # Third party /node_modules/ /vendor/ # Build products /dist/ /tmp/ /coverage/ /.sass-cache/ ================================================ FILE: .template-lintrc.js ================================================ module.exports = { extends: 'octane', // TODO: enable these rules: { 'no-action': false, 'no-curly-component-invocation': false, 'no-implicit-this': false, 'no-partial': false, }, }; ================================================ FILE: .travis.yml ================================================ --- language: node_js node_js: - "12" dist: xenial addons: chrome: stable cache: yarn: true env: global: # See https://git.io/vdao3 for details. - JOBS=1 before_install: - curl -o- -L https://yarnpkg.com/install.sh | bash - export PATH=$HOME/.yarn/bin:$PATH script: - yarn test ================================================ FILE: .watchmanconfig ================================================ { "ignore_dirs": [ "tmp", "dist" ] } ================================================ FILE: Dockerfile ================================================ FROM node:14-buster RUN mkdir -p /srv/app WORKDIR /srv/app COPY package.json yarn.lock ./ RUN yarn --frozen-lockfile --non-interactive COPY . /srv/app EXPOSE 8000 CMD [ "yarn", "develop" ] ================================================ FILE: LICENSE ================================================ The MIT License Copyright (c) 2014-2024 Szymon Nowak Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Procfile ================================================ web: node server.js ================================================ FILE: Procfile.dev ================================================ server: node server.js web: ember build --watch ================================================ FILE: README.md ================================================ ShareDrop # ShareDrop is now LimeWire Dear ShareDrop community, ShareDrop has been acquired by LimeWire, a leading file sharing platform with integrated AI tools. You can continue to share any files between devices, while benefitting from: * sharing files between devices in the same network * anonymous up- & downloads * end-to-end encryption * up to 40GB storage for free users * integrated AI tools for signed-up users Visit [sharedrop.io](https://sharedrop.io) or [limewire.com](https://limewire.com) The Github repository will stay as-is and you can still go ahead and download and run the classic ShareDrop on your own infrastructure. # ShareDrop Classic ShareDrop is a web application inspired by Apple [AirDrop](http://support.apple.com/kb/ht4783) service. It allows you to transfer files directly between devices, without having to upload them to any server first. It uses [WebRTC](http://www.webrtc.org) for secure peer-to-peer file transfer and [Firebase](https://www.firebase.com) for presence management and WebRTC signaling. ShareDrop allows you to send files to other devices in the same local network (i.e. devices with the same public IP address) without any configuration - simply open on all devices and they will see each other. It also allows you to send files between networks - just click the `+` button in the top right corner of the page to create a room with a unique URL and share this URL with other people you want to send a file to. Once they open this page in a browser on their devices, you'll see each other's avatars. The main difference between ShareDrop and AirDrop is that ShareDrop requires Internet connection to discover other devices, while AirDrop doesn't need one, as it creates ad-hoc wireless network between them. On the other hand, ShareDrop allows you to share files between mobile (Android and iOS) and desktop devices and even between networks. ## Supported browsers - Chrome - Edge (Chromium based) - Firefox - Opera - Safari 13+ ## Local development 1. Setup Firebase: 1. [Sign up](https://www.firebase.com) for a Firebase account and create a database. 2. Go to "Security Rules" tab, click "Load Rules" button and select `firebase_rules.json` file. 3. Take note of your database URL and its secret, which can be found in "Secrets" tab. 2. Run `npm install -g ember-cli` to install Ember CLI. 3. Run `yarn` to install app dependencies. 4. Run `cp .env{.sample,}` to create `.env` file. This file will be used by Foreman to set environment variables when running the app locally. - `SECRET` key is used to encrypt cookies and generate room name based on public IP address for `/` route. It can be any random string - you can generate one using e.g. `date | md5sum` - `NEW_RELIC_*` keys are only necessary in production 5. Run `yarn develop` to start the app. ## Deployment ### Heroku Create a new Heroku app: ``` heroku create ``` and push the app to Heroku repo: ``` git push heroku master ``` ================================================ FILE: app/app.js ================================================ import Application from '@ember/application'; import Resolver from 'ember-resolver'; import loadInitializers from 'ember-load-initializers'; import config from 'sharedrop/config/environment'; import * as Sentry from '@sentry/browser'; import { Ember as EmberIntegration } from '@sentry/integrations'; Sentry.init({ dsn: 'https://ba1292a9c759401dbbda4272f183408d@o432021.ingest.sentry.io/5384091', integrations: [new EmberIntegration()], }); export default class App extends Application { modulePrefix = config.modulePrefix; podModulePrefix = config.podModulePrefix; Resolver = Resolver; } loadInitializers(App, config.modulePrefix); ================================================ FILE: app/components/circular-progress.hbs ================================================ ================================================ FILE: app/components/circular-progress.js ================================================ import Component from '@glimmer/component'; import { htmlSafe } from '@ember/template'; const COLORS = { blue: '0, 136, 204', orange: '197, 197, 51', }; export default class CircularProgress extends Component { constructor(owner, args) { super(owner, args); const rgb = COLORS[args.color]; this.style = htmlSafe(`fill: rgba(${rgb}, .5)`); } get path() { const π = Math.PI; const α = this.args.value * 360; const r = (α * π) / 180; const mid = α > 180 ? 1 : 0; const x = Math.sin(r) * 38; const y = Math.cos(r) * -38; return `M 0 0 v -38 A 38 38 1 ${mid} 1 ${x} ${y} z`; } } ================================================ FILE: app/components/file-field.js ================================================ import TextField from '@ember/component/text-field'; import $ from 'jquery'; export default TextField.extend({ type: 'file', classNames: ['invisible'], click(event) { event.stopPropagation(); }, change(event) { const input = event.target; const { files } = input; this.onChange({ files }); this.reset(); }, // Hackish way to reset file input when sender cancels file transfer, // so if sender wants later to send the same file again, // the 'change' event is triggered correctly. reset() { const field = $(this.element); field.wrap('
').closest('form').get(0).reset(); field.unwrap(); }, }); ================================================ FILE: app/components/modal-dialog.js ================================================ import Component from '@ember/component'; export default Component.extend({ actions: { close() { // This sends an action to application route. // eslint-disable-next-line ember/closure-actions return this.onClose(); }, }, }); ================================================ FILE: app/components/peer-avatar.js ================================================ import Component from '@ember/component'; import { alias } from '@ember/object/computed'; import { later } from '@ember/runloop'; import $ from 'jquery'; export default Component.extend({ tagName: 'img', classNames: ['gravatar'], attributeBindings: [ 'src', 'alt', 'title', 'data-sending-progress', 'data-receiving-progress', ], src: alias('peer.avatarUrl'), alt: alias('peer.label'), title: alias('peer.uuid'), 'data-sending-progress': alias('peer.transfer.sendingProgress'), 'data-receiving-progress': alias('peer.transfer.receivingProgress'), toggleTransferCompletedClass() { const className = 'transfer-completed'; later( this, function toggleClass() { $(this.element) .parent('.avatar') .addClass(className) .delay(2000) .queue(function removeClass() { $(this).removeClass(className).dequeue(); }); }, 250, ); }, init(...args) { this._super(args); this.toggleTransferCompletedClass = this.toggleTransferCompletedClass.bind( this, ); }, didInsertElement(...args) { this._super(args); const { peer } = this; peer.on('didReceiveFile', this.toggleTransferCompletedClass); peer.on('didSendFile', this.toggleTransferCompletedClass); }, willDestroyElement(...args) { this._super(args); const { peer } = this; peer.off('didReceiveFile', this.toggleTransferCompletedClass); peer.off('didSendFile', this.toggleTransferCompletedClass); }, // Delegate click to hidden file field in peer template click() { if (this.canSendFile()) { $(this.element).closest('.peer').find('input[type=file]').click(); } }, // Handle drop events dragEnter(event) { this.cancelEvent(event); $(this.element).parent('.avatar').addClass('hover'); }, dragOver(event) { this.cancelEvent(event); }, dragLeave() { $(this.element).parent('.avatar').removeClass('hover'); }, drop(event) { this.cancelEvent(event); $(this.element).parent('.avatar').removeClass('hover'); const { peer } = this; const dt = event.originalEvent.dataTransfer; const { files } = dt; if (this.canSendFile()) { if (!this.isTransferableBundle(files)) { peer.setProperties({ state: 'error', errorCode: 'multiple-files', }); } else { this.onFileDrop({ files }); } } }, cancelEvent(event) { event.stopPropagation(); event.preventDefault(); }, canSendFile() { const { peer } = this; // Can't send files if another file transfer is already in progress return !( peer.get('state') === 'is_preparing_file_transfer' || peer.get('transfer.file') || peer.get('transfer.info') ); }, isTransferableBundle(files) { if (files.length === 1 && files[0] instanceof window.File) return true; const fileSizeLimit = 50 * 1024 * 1024; const bundleSizeLimit = 200 * 1024 * 1024; let aggregatedSize = 0; // eslint-disable-next-line no-restricted-syntax for (const file of files) { if (!(file instanceof window.File)) { return false; } if (file.size > fileSizeLimit) { return false; } aggregatedSize += file.size; if (aggregatedSize > bundleSizeLimit) { return false; } } return true; }, }); ================================================ FILE: app/components/peer-widget.js ================================================ import Component from '@ember/component'; import { computed } from '@ember/object'; import { alias, equal, gt } from '@ember/object/computed'; import JSZip from 'jszip'; export default Component.extend({ classNames: ['peer'], classNameBindings: ['peer.peer.state'], peer: null, hasCustomRoomName: false, webrtc: null, // TODO inject webrtc as a service label: alias('peer.label'), isIdle: equal('peer.state', 'idle'), isPreparingFileTransfer: equal('peer.state', 'is_preparing_file_transfer'), hasSelectedFile: equal('peer.state', 'has_selected_file'), isSendingFileInfo: equal('peer.state', 'sending_file_info'), isAwaitingFileInfo: equal('peer.state', 'awaiting_file_info'), isAwaitingResponse: equal('peer.state', 'awaiting_response'), hasReceivedFileInfo: equal('peer.state', 'received_file_info'), hasDeclinedFileTransfer: equal('peer.state', 'declined_file_transfer'), hasError: equal('peer.state', 'error'), isReceivingFile: gt('peer.transfer.receivingProgress', 0), isSendingFile: gt('peer.transfer.sendingProgress', 0), filename: computed('peer.transfer.{file,info}', function () { const file = this.get('peer.transfer.file'); const info = this.get('peer.transfer.info'); if (file) { return file.name; } if (info) { return info.name; } return null; }), errorTemplateName: computed('peer.errorCode', function () { const errorCode = this.get('peer.errorCode'); return errorCode ? `errors/popovers/${errorCode}` : null; }), actions: { // TODO: rename to something more meaningful (e.g. askIfWantToSendFile) uploadFile(data) { const { peer } = this; const { files } = data; peer.set('state', 'is_preparing_file_transfer'); peer.set('bundlingProgress', 0); // Cache the file, so that it's available // when the response from the recipient comes in this._reduceFiles(files).then((file) => { peer.set('transfer.file', file); peer.set('state', 'has_selected_file'); }); }, sendFileTransferInquiry() { const { webrtc } = this; const { peer } = this; webrtc.connect(peer.get('peer.id')); peer.set('state', 'establishing_connection'); }, cancelFileTransfer() { this._cancelFileTransfer(); }, abortFileTransfer() { this._cancelFileTransfer(); const { webrtc } = this; const connection = this.get('peer.peer.connection'); webrtc.sendCancelRequest(connection); }, acceptFileTransfer() { const { peer } = this; this._sendFileTransferResponse(true); peer.get('peer.connection').on('receiving_progress', (progress) => { peer.set('transfer.receivingProgress', progress); }); peer.set('state', 'sending_file_data'); }, rejectFileTransfer() { const { peer } = this; this._sendFileTransferResponse(false); peer.set('transfer.info', null); peer.set('state', 'idle'); }, }, _cancelFileTransfer() { const { peer } = this; peer.setProperties({ 'transfer.file': null, state: 'idle', }); }, _sendFileTransferResponse(response) { const { webrtc } = this; const { peer } = this; const connection = peer.get('peer.connection'); webrtc.sendFileResponse(connection, response); }, async _reduceFiles(files) { const { peer } = this; if (files.length === 1) { return Promise.resolve(files[0]); } const zip = new JSZip(); Array.prototype.forEach.call(files, (file) => { zip.file(file.name, file); }); const blob = await zip.generateAsync( { type: 'blob', streamFiles: true }, (metadata) => { peer.set('bundlingProgress', metadata.percent / 100); }, ); return new File( [blob], `sharedrop-${new Date() .toISOString() .substring(0, 19) .replace('T', '-')}.zip`, { type: 'application/zip', }, ); }, }); ================================================ FILE: app/components/popover-confirm.js ================================================ import Component from '@ember/component'; import { computed } from '@ember/object'; export default Component.extend({ classNames: ['popover-confirm'], iconClass: computed('filename', function () { const { filename } = this; if (filename) { const regex = /\.([0-9a-z]+)$/i; const match = filename.match(regex); const extension = match && match[1]; if (extension) { return `glyphicon-${extension.toLowerCase()}`; } } return undefined; }), actions: { confirm() { this.onConfirm(); }, cancel() { this.onCancel(); }, }, }); ================================================ FILE: app/components/room-url.js ================================================ import TextField from '@ember/component/text-field'; import $ from 'jquery'; export default TextField.extend({ classNames: ['room-url'], didInsertElement() { $(this.element).focus().select(); }, copyValueToClipboard() { if (window.ClipboardEvent) { const pasteEvent = new window.ClipboardEvent('paste', { dataType: 'text/plain', data: this.element.value, }); document.dispatchEvent(pasteEvent); } }, }); ================================================ FILE: app/components/user-widget.js ================================================ import Component from '@ember/component'; export default Component.extend({ classNames: ['peer'], classNameBindings: ['peer.peer.state'], }); ================================================ FILE: app/controllers/application.js ================================================ import Controller from '@ember/controller'; import { inject as service } from '@ember/service'; import { v4 as uuidv4 } from 'uuid'; import User from '../models/user'; export default Controller.extend({ avatarService: service('avatar'), init(...args) { this._super(args); const id = window.Sharedrop.userId; const ip = window.Sharedrop.publicIp; const avatar = this.avatarService.get(); const you = User.create({ uuid: id, public_ip: ip, avatarUrl: avatar.url, label: avatar.label, }); you.set('peer.id', id); this.set('you', you); }, actions: { redirect() { const uuid = uuidv4(); const key = `show-instructions-for-room-${uuid}`; sessionStorage.setItem(key, 'yup'); this.transitionToRoute('room', uuid); }, }, }); ================================================ FILE: app/controllers/index.js ================================================ import Controller, { inject as controller } from '@ember/controller'; import { alias } from '@ember/object/computed'; import $ from 'jquery'; import WebRTC from '../services/web-rtc'; import Peer from '../models/peer'; export default Controller.extend({ application: controller('application'), you: alias('application.you'), room: null, webrtc: null, _onRoomConnected(event, data) { const { you } = this; const { room } = this; you.get('peer').setProperties(data.peer); // eslint-disable-next-line no-param-reassign delete data.peer; you.setProperties(data); // Initialize WebRTC this.set( 'webrtc', new WebRTC(you.get('uuid'), { room: room.name, firebaseRef: window.Sharedrop.ref, }), ); }, _onRoomDisconnected() { this.model.clear(); this.set('webrtc', null); }, _onRoomUserAdded(event, data) { const { you } = this; if (you.get('uuid') !== data.uuid) { this._addPeer(data); } }, _addPeer(attrs) { const peerAttrs = attrs.peer; // eslint-disable-next-line no-param-reassign delete attrs.peer; const peer = Peer.create(attrs); peer.get('peer').setProperties(peerAttrs); this.model.pushObject(peer); }, _onRoomUserChanged(event, data) { const peers = this.model; const peer = peers.findBy('uuid', data.uuid); const peerAttrs = data.peer; const defaults = { uuid: null, public_ip: null, }; if (peer) { // eslint-disable-next-line no-param-reassign delete data.peer; // Firebase doesn't return keys with null values, // so we have to add them back. peer.setProperties($.extend({}, defaults, data)); peer.get('peer').setProperties(peerAttrs); } }, _onRoomUserRemoved(event, data) { const peers = this.model; const peer = peers.findBy('uuid', data.uuid); peers.removeObject(peer); }, _onPeerP2PIncomingConnection(event, data) { const { connection } = data; const peers = this.model; const peer = peers.findBy('peer.id', connection.peer); // Don't switch to 'connecting' state on incoming connection, // as p2p connection may still fail. peer.set('peer.connection', connection); }, _onPeerDCIncomingConnection(event, data) { const { connection } = data; const peers = this.model; const peer = peers.findBy('peer.id', connection.peer); peer.set('peer.state', 'connected'); }, _onPeerDCIncomingConnectionError(event, data) { const { connection, error } = data; const peers = this.model; const peer = peers.findBy('peer.id', connection.peer); switch (error.type) { case 'failed': peer.setProperties({ 'peer.connection': null, 'peer.state': 'disconnected', state: 'error', errorCode: 'connection-failed', }); break; case 'disconnected': // TODO: notify both sides break; default: break; } }, _onPeerP2POutgoingConnection(event, data) { const { connection } = data; const peers = this.model; const peer = peers.findBy('peer.id', connection.peer); peer.setProperties({ 'peer.connection': connection, 'peer.state': 'connecting', }); }, _onPeerDCOutgoingConnection(event, data) { const { connection } = data; const peers = this.model; const peer = peers.findBy('peer.id', connection.peer); const file = peer.get('transfer.file'); const { webrtc } = this; const info = webrtc.getFileInfo(file); peer.set('peer.state', 'connected'); peer.set('state', 'awaiting_response'); webrtc.sendFileInfo(connection, info); console.log('Sending a file info...', info); }, _onPeerDCOutgoingConnectionError(event, data) { const { connection, error } = data; const peers = this.model; const peer = peers.findBy('peer.id', connection.peer); switch (error.type) { case 'failed': peer.setProperties({ 'peer.connection': null, 'peer.state': 'disconnected', state: 'error', errorCode: 'connection-failed', }); break; default: break; } }, _onPeerP2PDisconnected(event, data) { const { connection } = data; const peers = this.model; const peer = peers.findBy('peer.id', connection.peer); if (peer) { peer.set('peer.connection', null); peer.set('peer.state', 'disconnected'); } }, _onPeerP2PFileInfo(event, data) { console.log('Peer:\t Received file info', data); const { connection, info } = data; const peers = this.model; const peer = peers.findBy('peer.id', connection.peer); peer.set('transfer.info', info); peer.set('state', 'received_file_info'); }, _onPeerP2PFileResponse(event, data) { console.log('Peer:\t Received file response', data); const { connection, response } = data; const peers = this.model; const peer = peers.findBy('peer.id', connection.peer); const { webrtc } = this; if (response) { const file = peer.get('transfer.file'); connection.on('sending_progress', (progress) => { peer.set('transfer.sendingProgress', progress); }); webrtc.sendFile(connection, file); peer.set('state', 'receiving_file_data'); } else { peer.set('state', 'declined_file_transfer'); } }, _onPeerP2PFileCanceled(event, data) { const { connection } = data; const peers = this.model; const peer = peers.findBy('peer.id', connection.peer); connection.close(); peer.set('transfer.receivingProgress', 0); peer.set('transfer.info', null); peer.set('state', 'idle'); }, _onPeerP2PFileReceived(event, data) { console.log('Peer:\t Received file', data); const { connection } = data; const peers = this.model; const peer = peers.findBy('peer.id', connection.peer); connection.close(); peer.set('transfer.receivingProgress', 0); peer.set('transfer.info', null); peer.set('state', 'idle'); peer.trigger('didReceiveFile'); }, _onPeerP2PFileSent(event, data) { console.log('Peer:\t Sent file', data); const { connection } = data; const peers = this.model; const peer = peers.findBy('peer.id', connection.peer); peer.set('transfer.sendingProgress', 0); peer.set('transfer.file', null); peer.set('state', 'idle'); peer.trigger('didSendFile'); }, }); ================================================ FILE: app/helpers/is-equal.js ================================================ import { helper as buildHelper } from '@ember/component/helper'; export default buildHelper(([leftSide, rightSide]) => leftSide === rightSide); ================================================ FILE: app/index.html ================================================ ShareDrop {{content-for "head"}} {{content-for "head-footer"}}
Loading...
{{content-for "body"}} {{content-for "body-footer"}} ================================================ FILE: app/initializers/prerequisites.js ================================================ /* jshint -W030 */ import $ from 'jquery'; import { Promise } from 'rsvp'; import config from 'sharedrop/config/environment'; import FileSystem from '../services/file'; import Analytics from '../services/analytics'; export function initialize(application) { function checkWebRTCSupport() { return new Promise((resolve, reject) => { // window.util is a part of PeerJS library if (window.util.supports.sctp) { resolve(); } else { // eslint-disable-next-line prefer-promise-reject-errors reject('browser-unsupported'); } }); } function clearFileSystem() { return new Promise((resolve, reject) => { // TODO: change File into a service and require it here FileSystem.removeAll() .then(() => { resolve(); }) .catch(() => { // eslint-disable-next-line prefer-promise-reject-errors reject('filesystem-unavailable'); }); }); } function authenticateToFirebase() { return new Promise((resolve, reject) => { const xhr = $.getJSON('/auth'); xhr.then((data) => { const ref = new window.Firebase(config.FIREBASE_URL); // eslint-disable-next-line no-param-reassign application.ref = ref; // eslint-disable-next-line no-param-reassign application.userId = data.id; // eslint-disable-next-line no-param-reassign application.publicIp = data.public_ip; ref.authWithCustomToken(data.token, (error) => { if (error) { reject(error); } else { resolve(); } }); }); }); } // TODO: move it to a separate initializer function trackSizeOfReceivedFiles() { $.subscribe('file_received.p2p', (event, data) => { Analytics.trackEvent('received', { event_category: 'file', event_label: 'size', value: Math.round(data.info.size / 1000), }); }); } application.deferReadiness(); checkWebRTCSupport() .then(clearFileSystem) .catch((error) => { // eslint-disable-next-line no-param-reassign application.error = error; }) .then(authenticateToFirebase) .then(trackSizeOfReceivedFiles) .then(() => { application.advanceReadiness(); }); } export default { name: 'prerequisites', initialize, }; ================================================ FILE: app/models/peer.js ================================================ import EmberObject, { observer } from '@ember/object'; import Evented, { on } from '@ember/object/evented'; export default EmberObject.extend(Evented, { uuid: null, label: null, avatarUrl: null, public_ip: null, peer: null, transfer: null, init(...args) { this._super(args); const initialPeerState = EmberObject.create({ id: null, connection: null, // State of data channel connection. Possible states: // - disconnected // - connecting // - connected state: 'disconnected', }); const initialTransferState = EmberObject.create({ file: null, info: null, sendingProgress: 0, receivingProgress: 0, }); this.set('peer', initialPeerState); this.set('transfer', initialTransferState); }, // Used to display popovers. Possible states: // - idle // - has_selected_file // - establishing_connection // - awaiting_response // - received_file_info // - declined_file_transfer // - receiving_file_data // - sending_file_data // - error state: 'idle', // Used to display error messages in popovers. Possible codes: // - multiple_files errorCode: null, stateChanged: on( 'init', observer('state', function () { console.log('Peer:\t State has changed: ', this.state); // Automatically clear error code if transitioning to a non-error state if (this.state !== 'error') { this.set('errorCode', null); } }), ), }); ================================================ FILE: app/models/user.js ================================================ import Peer from './peer'; const User = Peer.extend({ serialize() { const data = { uuid: this.uuid, public_ip: this.public_ip, label: this.label, avatarUrl: this.avatarUrl, peer: { id: this.get('peer.id'), }, }; return data; }, }); export default User; ================================================ FILE: app/router.js ================================================ import EmberRouter from '@ember/routing/router'; import config from 'sharedrop/config/environment'; export default class Router extends EmberRouter { location = config.locationType; rootURL = config.rootURL; } // eslint-disable-next-line array-callback-return Router.map(function () { this.route('room', { path: '/rooms/:room_id', }); }); ================================================ FILE: app/routes/application.js ================================================ import Route from '@ember/routing/route'; export default Route.extend({ setupController(controller) { controller.set('currentRoute', this); }, actions: { openModal(modalName) { return this.render(modalName, { outlet: 'modal', into: 'application', }); }, closeModal() { return this.disconnectOutlet({ outlet: 'modal', parentView: 'application', }); }, }, }); ================================================ FILE: app/routes/error.js ================================================ import Route from '@ember/routing/route'; export default Route.extend({ renderTemplate(controller, error) { const errors = ['browser-unsupported', 'filesystem-unavailable']; const name = `errors/${error.message}`; if (errors.indexOf(error.message) !== -1) { this.render(name); } }, }); ================================================ FILE: app/routes/index.js ================================================ import Route from '@ember/routing/route'; import $ from 'jquery'; import Room from '../services/room'; export default Route.extend({ beforeModel() { const { error } = window.Sharedrop; if (error) { throw new Error(error); } }, model() { // Get room name from the server return $.getJSON('/room').then((data) => data.name); }, setupController(ctrl, model) { ctrl.set('model', []); ctrl.set('hasCustomRoomName', false); // Handle room events $.subscribe('connected.room', ctrl._onRoomConnected.bind(ctrl)); $.subscribe('disconnected.room', ctrl._onRoomDisconnected.bind(ctrl)); $.subscribe('user_added.room', ctrl._onRoomUserAdded.bind(ctrl)); $.subscribe('user_changed.room', ctrl._onRoomUserChanged.bind(ctrl)); $.subscribe('user_removed.room', ctrl._onRoomUserRemoved.bind(ctrl)); // Handle peer events $.subscribe( 'incoming_peer_connection.p2p', ctrl._onPeerP2PIncomingConnection.bind(ctrl), ); $.subscribe( 'incoming_dc_connection.p2p', ctrl._onPeerDCIncomingConnection.bind(ctrl), ); $.subscribe( 'incoming_dc_connection_error.p2p', ctrl._onPeerDCIncomingConnectionError.bind(ctrl), ); $.subscribe( 'outgoing_peer_connection.p2p', ctrl._onPeerP2POutgoingConnection.bind(ctrl), ); $.subscribe( 'outgoing_dc_connection.p2p', ctrl._onPeerDCOutgoingConnection.bind(ctrl), ); $.subscribe( 'outgoing_dc_connection_error.p2p', ctrl._onPeerDCOutgoingConnectionError.bind(ctrl), ); $.subscribe('disconnected.p2p', ctrl._onPeerP2PDisconnected.bind(ctrl)); $.subscribe('info.p2p', ctrl._onPeerP2PFileInfo.bind(ctrl)); $.subscribe('response.p2p', ctrl._onPeerP2PFileResponse.bind(ctrl)); $.subscribe('file_canceled.p2p', ctrl._onPeerP2PFileCanceled.bind(ctrl)); $.subscribe('file_received.p2p', ctrl._onPeerP2PFileReceived.bind(ctrl)); $.subscribe('file_sent.p2p', ctrl._onPeerP2PFileSent.bind(ctrl)); // Join the room const room = new Room(model, window.Sharedrop.ref); room.join(ctrl.get('you').serialize()); ctrl.set('room', room); }, renderTemplate() { this.render(); this.render('about_you', { into: 'application', outlet: 'about_you', }); const key = 'show-instructions-for-app'; if (!localStorage.getItem(key)) { this.send('openModal', 'about_app'); localStorage.setItem(key, 'yup'); } }, actions: { willTransition() { $.unsubscribe('.room'); $.unsubscribe('.p2p'); // eslint-disable-next-line ember/no-controller-access-in-routes const controller = this.controllerFor('index'); controller.get('room').leave(); return true; }, }, }); ================================================ FILE: app/routes/room.js ================================================ import IndexRoute from './index'; export default IndexRoute.extend({ controllerName: 'index', model(params) { // Get room name from params return params.room_id; }, afterModel(model, transition) { transition.then((route) => { route .controllerFor('application') .set('currentUrl', window.location.href); }); }, setupController(ctrl, model) { // Call this method on "index" controller this._super(ctrl, model); ctrl.set('hasCustomRoomName', true); }, renderTemplate(ctrl) { this.render('index'); this.render('about_you', { into: 'application', outlet: 'about_you', }); const room = ctrl.get('room').name; const key = `show-instructions-for-room-${room}`; if (sessionStorage.getItem(key)) { this.send('openModal', 'about_room'); sessionStorage.removeItem(key); } }, }); ================================================ FILE: app/services/analytics.js ================================================ export default { trackEvent(name, parameters) { if (window.gtag && typeof window.gtag === 'function') { window.gtag('event', name, parameters); } }, }; ================================================ FILE: app/services/avatar.js ================================================ import Service from '@ember/service'; import sample from 'lodash/sample'; import startCase from 'lodash/startCase'; const AVATARS = [ { name: 'Piglet', id: '23', }, { name: 'Cat', id: '36', }, { name: 'Fish', id: '37', }, { name: 'Fox', id: '38', }, { name: 'Chicken', id: '46', }, { name: 'Goat', id: '50', }, { name: 'Ram', id: '51', }, { name: 'Sheep', id: '52', }, { name: 'Bison', id: '59', }, { name: 'Dog', id: '61', }, { name: 'Walrus', id: '62', }, { name: 'Dog', id: '63', }, { name: 'Monkey', id: '64', }, { name: 'Bear', id: '65', }, { name: 'Lion', id: '66', }, { name: 'Zebra', id: '67', }, { name: 'Giraffe', id: '68', }, { name: 'Bear', id: '71', }, { name: 'Wolf', id: '74', }, { name: 'Rhino', id: '86', }, { name: 'Bat', id: '87', }, { name: 'Cat', id: '95', }, { name: 'Penguin', id: '102', }, { name: 'Rhino', id: '109', }, { name: 'Koala', id: '112', }, ]; const PREFIXES = [ 'adventurous', 'affable', 'ambitious', 'amiable ', 'amusing', 'brave', 'bright', 'charming', 'compassionate', 'convivial', 'courageous', 'creative', 'diligent', 'easygoing', 'emotional', 'energetic', 'enthusiastic', 'exuberant', 'fearless', 'friendly', 'funny', 'generous', 'gentle', 'good', 'helpful', 'honest', 'humorous', 'imaginative', 'independent', 'intelligent', 'intuitive', 'inventive', 'kind', 'loving', 'loyal', 'modest', 'neat', 'nice', 'optimistic', 'passionate', 'patient', 'persistent', 'polite', 'practical', 'rational', 'reliable', 'reserved', 'resourceful', 'romantic', 'sensible', 'sensitive', 'sincere', 'sympathetic', 'thoughtful', 'tough', 'understanding', 'versatile', 'warmhearted', ]; const Avatar = Service.extend({ get() { const avatar = sample(AVATARS); const prefix = sample(PREFIXES); return { url: `/assets/images/avatars/${avatar.id}.svg`, label: startCase(`${prefix} ${avatar.name}`), }; }, }); export default Avatar; ================================================ FILE: app/services/file.js ================================================ import { Promise } from 'rsvp'; const File = function (options) { const self = this; this.name = options.name; this.localName = `${new Date().getTime()}-${this.name}`; this.size = options.size; this.type = options.type; this._reset(); return new Promise((resolve, reject) => { const requestFileSystem = window.requestFileSystem || window.webkitRequestFileSystem; requestFileSystem( window.TEMPORARY, options.size, (filesystem) => { self.filesystem = filesystem; resolve(self); }, (error) => { self.errorHandler(error); reject(error); }, ); }); }; File.removeAll = function () { return new Promise((resolve, reject) => { const filer = new window.Filer(); filer.init( { persistent: false }, () => { filer.ls('/', (entries) => { function rm(entry) { if (entry) { filer.rm(entry, () => { rm(entries.pop()); }); } else { resolve(); } } rm(entries.pop()); }); }, (error) => { console.log(error); reject(error); }, ); }); }; File.prototype.append = function (data) { const self = this; const options = { create: this.create, }; return new Promise((resolve, reject) => { self.filesystem.root.getFile( self.localName, options, (fileEntry) => { if (self.create) { self.create = false; } self.fileEntry = fileEntry; fileEntry.createWriter( (writer) => { const blob = new Blob(data, { type: self.type }); // console.log('File: Appending ' + blob.size + ' bytes at ' + self.seek); // eslint-disable-next-line no-param-reassign writer.onwriteend = function () { self.seek += blob.size; resolve(fileEntry); }; // eslint-disable-next-line no-param-reassign writer.onerror = function (error) { self.errorHandler(error); reject(error); }; writer.seek(self.seek); writer.write(blob); }, (error) => { self.errorHandler(error); reject(error); }, ); }, (error) => { self.errorHandler(error); reject(error); }, ); }); }; File.prototype.save = function () { const self = this; console.log('File: Saving file: ', this.fileEntry); const a = document.createElement('a'); a.download = this.name; function finish(link) { document.body.appendChild(a); link.addEventListener('click', () => { // Remove file entry from filesystem. setTimeout(() => { self.remove().then(self._reset.bind(self)); }, 100); // Hack, but otherwise browser doesn't save the file at all. link.parentNode.removeChild(a); }); link.click(); } if (this._isWebKit()) { a.href = this.fileEntry.toURL(); finish(a); } else { this.fileEntry.file((file) => { const URL = window.URL || window.webkitURL; a.href = URL.createObjectURL(file); finish(a); }); } }; File.prototype.errorHandler = function (error) { console.error('File error: ', error); }; File.prototype.remove = function () { const self = this; return new Promise((resolve, reject) => { self.filesystem.root.getFile( self.localName, { create: false }, (fileEntry) => { fileEntry.remove( () => { console.debug(`File: Removed file: ${self.localName}`); resolve(fileEntry); }, (error) => { self.errorHandler(error); reject(error); }, ); }, (error) => { self.errorHandler(error); reject(error); }, ); }); }; File.prototype._reset = function () { this.create = true; this.filesystem = null; this.fileEntry = null; this.seek = 0; }; File.prototype._isWebKit = function () { return !!window.webkitRequestFileSystem; }; export default File; ================================================ FILE: app/services/room.js ================================================ import $ from 'jquery'; // TODO: use Ember.Object.extend() const Room = function (name, firebaseRef) { this._ref = firebaseRef; this.name = name; }; Room.prototype.join = function (user) { const self = this; // Setup Firebase refs self._connectionRef = self._ref.child('.info/connected'); self._roomRef = self._ref.child(`rooms/${this.name}`); self._usersRef = self._roomRef.child('users'); self._userRef = self._usersRef.child(user.uuid); console.log('Room:\t Connecting to: ', this.name); self._connectionRef.on('value', (connectionSnapshot) => { // Once connected (or reconnected) to Firebase if (connectionSnapshot.val() === true) { console.log('Firebase: (Re)Connected'); // Remove yourself from the room when disconnected self._userRef.onDisconnect().remove(); // Join the room self._userRef.set(user, (error) => { if (error) { console.warn('Firebase: Adding user to the room failed: ', error); } else { console.log('Firebase: User added to the room'); // Create a copy of user data, // so that deleting properties won't affect the original variable $.publish('connected.room', $.extend(true, {}, user)); } }); self._usersRef.on('child_added', (userAddedSnapshot) => { const addedUser = userAddedSnapshot.val(); console.log('Room:\t user_added: ', addedUser); $.publish('user_added.room', addedUser); }); self._usersRef.on( 'child_removed', (userRemovedSnapshot) => { const removedUser = userRemovedSnapshot.val(); console.log('Room:\t user_removed: ', removedUser); $.publish('user_removed.room', removedUser); }, () => { // Handle case when the whole room is removed from Firebase $.publish('disconnected.room'); }, ); self._usersRef.on('child_changed', (userChangedSnapshot) => { const changedUser = userChangedSnapshot.val(); console.log('Room:\t user_changed: ', changedUser); $.publish('user_changed.room', changedUser); }); } else { console.log('Firebase: Disconnected'); $.publish('disconnected.room'); self._usersRef.off(); } }); return this; }; Room.prototype.update = function (attrs) { this._userRef.update(attrs); }; Room.prototype.leave = function () { this._userRef.remove(); this._usersRef.off(); }; export default Room; ================================================ FILE: app/services/web-rtc.js ================================================ // TODO: // - provide TURN server config once it's possible to create rooms with custom names // - use Ember.Object.extend() import $ from 'jquery'; import File from './file'; const WebRTC = function (id, options) { const defaults = { config: { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }, debug: 3, }; this.conn = new window.Peer(id, $.extend(defaults, options)); this.files = { outgoing: {}, incoming: {}, }; // Listen for incoming connections this.conn.on('connection', (connection) => { $.publish('incoming_peer_connection.p2p', { connection }); connection.on('open', () => { console.log('Peer:\t Data channel connection opened: ', connection); $.publish('incoming_dc_connection.p2p', { connection }); }); connection.on('error', (error) => { console.log('Peer:\t Data channel connection error', error); $.publish('incoming_dc_connection_error.p2p', { connection, error, }); }); this._onConnection(connection); }); this.conn.on('close', () => { console.log('Peer:\t Connection to server closed.'); }); this.conn.on('error', (error) => { console.log('Peer:\t Error while connecting to server: ', error); }); }; WebRTC.CHUNK_MTU = 16000; WebRTC.CHUNKS_PER_ACK = 64; WebRTC.prototype.connect = function (id) { const connection = this.conn.connect(id, { label: 'file', reliable: true, serialization: 'none', // we handle serialization ourselves }); connection.on('open', () => { console.log('Peer:\t Data channel connection opened: ', connection); $.publish('outgoing_dc_connection.p2p', { connection }); }); connection.on('error', (error) => { console.log('Peer:\t Data channel connection error', error); $.publish('outgoing_dc_connection_error.p2p', { connection, error, }); }); $.publish('outgoing_peer_connection.p2p', { connection }); this._onConnection(connection); }; WebRTC.prototype._onConnection = function (connection) { const self = this; console.log('Peer:\t Opening data channel connection...', connection); connection.on('data', (data) => { // Lame type check if (data.byteLength !== undefined) { // ArrayBuffer self._onBinaryData(data, connection); } else { // JSON string self._onJSONData(JSON.parse(data), connection); } }); connection.on('close', () => { $.publish('disconnected.p2p', { connection }); console.log('Peer:\t P2P connection closed: ', connection); }); }; WebRTC.prototype._onBinaryData = function (data, connection) { const self = this; const incoming = this.files.incoming[connection.peer]; const { info, file, block, receivedChunkNum } = incoming; const chunksPerAck = WebRTC.CHUNKS_PER_ACK; // TODO move it after requesting a new block to speed things up connection.emit( 'receiving_progress', (receivedChunkNum + 1) / info.chunksTotal, ); // console.log('Got chunk no ' + (receivedChunkNum + 1) + ' out of ' + info.chunksTotal); block.push(data); incoming.receivedChunkNum = receivedChunkNum + 1; const nextChunkNum = incoming.receivedChunkNum; const lastChunkInFile = receivedChunkNum === info.chunksTotal - 1; const lastChunkInBlock = receivedChunkNum > 0 && (receivedChunkNum + 1) % chunksPerAck === 0; if (lastChunkInFile || lastChunkInBlock) { file.append(block).then(() => { if (lastChunkInFile) { file.save(); $.publish('file_received.p2p', { blob: file, info, connection, }); } else { // console.log('Requesting block starting at: ' + (nextChunkNum)); incoming.block = []; self._requestFileBlock(connection, nextChunkNum); } }); } }; WebRTC.prototype._onJSONData = function (data, connection) { switch (data.type) { case 'info': { const info = data.payload; $.publish('info.p2p', { connection, info, }); // Store incoming file info for later this.files.incoming[connection.peer] = { info, file: null, block: [], receivedChunkNum: 0, }; console.log('Peer:\t File info: ', data); break; } case 'cancel': { $.publish('file_canceled.p2p', { connection, }); console.log('Peer:\t Sender canceled file transfer'); break; } case 'response': { const response = data.payload; // If recipient rejected the file, delete stored file if (!response) { delete this.files.outgoing[connection.peer]; } $.publish('response.p2p', { connection, response, }); console.log('Peer:\t File response: ', data); break; } case 'block_request': { const { file } = this.files.outgoing[connection.peer]; // console.log('Peer:\t Block request: ', data.payload); this._sendBlock(connection, file, data.payload); break; } default: console.log('Peer:\t Unknown message: ', data); } }; WebRTC.prototype.getFileInfo = function (file) { return { lastModifiedDate: file.lastModifiedDate, name: file.name, size: file.size, type: file.type, chunksTotal: Math.ceil(file.size / WebRTC.CHUNK_MTU), }; }; WebRTC.prototype.sendFileInfo = function (connection, info) { const message = { type: 'info', payload: info, }; connection.send(JSON.stringify(message)); }; WebRTC.prototype.sendCancelRequest = function (connection) { const message = { type: 'cancel', }; connection.send(JSON.stringify(message)); }; WebRTC.prototype.sendFileResponse = function (connection, response) { const message = { type: 'response', payload: response, }; if (response) { // If recipient accepted the file, request required space to store the file on HTML5 filesystem const incoming = this.files.incoming[connection.peer]; const { info } = incoming; new File({ name: info.name, size: info.size, type: info.type }).then( (file) => { incoming.file = file; connection.send(JSON.stringify(message)); }, ); } else { // Otherwise, delete stored file info delete this.files.incoming[connection.peer]; connection.send(JSON.stringify(message)); } }; WebRTC.prototype.sendFile = function (connection, file) { // Save the file for later this.files.outgoing[connection.peer] = { file, info: this.getFileInfo(file), }; // Send the first block. Next ones will be requested by recipient. this._sendBlock(connection, file, 0); }; WebRTC.prototype._requestFileBlock = function (connection, chunkNum) { const message = { type: 'block_request', payload: chunkNum, }; connection.send(JSON.stringify(message)); }; WebRTC.prototype._sendBlock = function (connection, file, beginChunkNum) { const { info } = this.files.outgoing[connection.peer]; const chunkSize = WebRTC.CHUNK_MTU; const chunksPerAck = WebRTC.CHUNKS_PER_ACK; const remainingChunks = info.chunksTotal - beginChunkNum; const chunksToSend = Math.min(remainingChunks, chunksPerAck); const endChunkNum = beginChunkNum + chunksToSend - 1; const blockBegin = beginChunkNum * chunkSize; const blockEnd = endChunkNum * chunkSize + chunkSize; const reader = new FileReader(); let chunkNum; // Read the whole block from file const blockBlob = file.slice(blockBegin, blockEnd); // console.log('Sending block: start chunk: ' + beginChunkNum + ' end chunk: ' + endChunkNum); // console.log('Sending block: start byte : ' + begin + ' end byte : ' + end); reader.onload = function (event) { if (reader.readyState === FileReader.DONE) { const blockBuffer = event.target.result; for ( chunkNum = beginChunkNum; chunkNum < endChunkNum + 1; chunkNum += 1 ) { // Send each chunk (begin index is inclusive, end index is exclusive) const bufferBegin = (chunkNum % chunksPerAck) * chunkSize; const bufferEnd = Math.min( bufferBegin + chunkSize, blockBuffer.byteLength, ); const chunkBuffer = blockBuffer.slice(bufferBegin, bufferEnd); connection.send(chunkBuffer); // console.log('Sent chunk: start byte: ' + begin + ' end byte: ' + end + ' length: ' + chunkBuffer.byteLength); // console.log('Sent chunk no ' + (chunkNum + 1) + ' out of ' + info.chunksTotal); connection.emit('sending_progress', (chunkNum + 1) / info.chunksTotal); } if (endChunkNum === info.chunksTotal - 1) { $.publish('file_sent.p2p', { connection }); } } }; reader.readAsArrayBuffer(blockBlob); }; export default WebRTC; ================================================ FILE: app/styles/app.sass ================================================ @import base/reset @import base/variables @import base/mixins @import base/element_defaults @import base/glyphicons_filetypes @import modules/modules @import modules/modal @import modules/users @import modules/popover @import layout/header @import layout/content @import layout/footer @import layout/media ================================================ FILE: app/styles/base/_element_defaults.sass ================================================ *, *::before, *::after box-sizing: border-box html height: 100% font-family: $font-family font-size: 10px body height: 100% a text-decoration: none b, strong font-weight: bold input, select font-family: inherit padding: 1rem border: 1px solid #ccc width: 100% border-radius: .3rem font-size: 1.4rem .invisible height: 0 width: 0 opacity: 0 ================================================ FILE: app/styles/base/_glyphicons_filetypes.sass ================================================ /* GLYPHICONS FILETYPES 1.8 */ @font-face font-family: "Glyphicons Filetypes" src: url(/assets/fonts/glyphicons/glyphicons-filetypes-regular.woff) format("woff"), url(/assets/fonts/glyphicons/glyphicons-filetypes-regular.ttf) format("truetype"), url(/assets/fonts/glyphicons/glyphicons-filetypes-regular.svg#glyphicons_filetypesregular) format("svg") $icon-prefix: glyphicon [class*="#{$icon-prefix}-"] display: inline-block vertical-align: middle font: family: "Glyphicons Filetypes" style: normal weight: normal size: 1em .#{$icon-prefix}-spin animation: #{$icon-prefix}-spin 2s infinite linear @-moz-keyframes #{$icon-prefix}-spin 0% -moz-transform: rotate(0deg) 100% -moz-transform: rotate(359deg) @-webkit-keyframes #{$icon-prefix}-spin 0% -webkit-transform: rotate(0deg) 100% -webkit-transform: rotate(359deg) @-o-keyframes #{$icon-prefix}-spin 0% -o-transform: rotate(0deg) 100% -o-transform: rotate(359deg) @-ms-keyframes #{$icon-prefix}-spin 0% -ms-transform: rotate(0deg) 100% -ms-transform: rotate(359deg) @keyframes #{$icon-prefix}-spin 0% transform: rotate(0deg) 100% transform: rotate(359deg) [class*="#{$icon-prefix}-"]:before content: "\E120" .#{$icon-prefix}-txt:before content: "\E001" .#{$icon-prefix}-doc:before content: "\E002" .#{$icon-prefix}-rtf:before content: "\E003" .#{$icon-prefix}-log:before content: "\E004" .#{$icon-prefix}-tex:before content: "\E005" .#{$icon-prefix}-msg:before content: "\E006" .#{$icon-prefix}-text:before content: "\E007" .#{$icon-prefix}-wpd:before content: "\E008" .#{$icon-prefix}-wps:before content: "\E009" .#{$icon-prefix}-docx:before content: "\E010" .#{$icon-prefix}-page:before content: "\E011" .#{$icon-prefix}-csv:before content: "\E012" .#{$icon-prefix}-dat:before content: "\E013" .#{$icon-prefix}-tar:before content: "\E014" .#{$icon-prefix}-xml:before content: "\E015" .#{$icon-prefix}-vcf:before content: "\E016" .#{$icon-prefix}-pps:before content: "\E017" .#{$icon-prefix}-key:before content: "\E018" .#{$icon-prefix}-ppt:before content: "\E019" .#{$icon-prefix}-pptx:before content: "\E020" .#{$icon-prefix}-sdf:before content: "\E021" .#{$icon-prefix}-gbr:before content: "\E022" .#{$icon-prefix}-ged:before content: "\E023" .#{$icon-prefix}-mp3:before content: "\E024" .#{$icon-prefix}-m4a:before content: "\E025" .#{$icon-prefix}-waw:before content: "\E026" .#{$icon-prefix}-wma:before content: "\E027" .#{$icon-prefix}-mpa:before content: "\E028" .#{$icon-prefix}-iff:before content: "\E029" .#{$icon-prefix}-aif:before content: "\E030" .#{$icon-prefix}-ra:before content: "\E031" .#{$icon-prefix}-mid:before content: "\E032" .#{$icon-prefix}-m3v:before content: "\E033" .#{$icon-prefix}-e_3gp:before content: "\E034" .#{$icon-prefix}-shf:before content: "\E035" .#{$icon-prefix}-avi:before content: "\E036" .#{$icon-prefix}-asx:before content: "\E037" .#{$icon-prefix}-mp4:before content: "\E038" .#{$icon-prefix}-e_3g2:before content: "\E039" .#{$icon-prefix}-mpg:before content: "\E040" .#{$icon-prefix}-asf:before content: "\E041" .#{$icon-prefix}-vob:before content: "\E042" .#{$icon-prefix}-wmv:before content: "\E043" .#{$icon-prefix}-mov:before content: "\E044" .#{$icon-prefix}-srt:before content: "\E045" .#{$icon-prefix}-m4v:before content: "\E046" .#{$icon-prefix}-flv:before content: "\E047" .#{$icon-prefix}-rm:before content: "\E048" .#{$icon-prefix}-png:before content: "\E049" .#{$icon-prefix}-psd:before content: "\E050" .#{$icon-prefix}-psp:before content: "\E051" .#{$icon-prefix}-jpg:before, .#{$icon-prefix}-jpeg:before content: "\E052" .#{$icon-prefix}-tif:before content: "\E053" .#{$icon-prefix}-tiff:before content: "\E054" .#{$icon-prefix}-gif:before content: "\E055" .#{$icon-prefix}-bmp:before content: "\E056" .#{$icon-prefix}-tga:before content: "\E057" .#{$icon-prefix}-thm:before content: "\E058" .#{$icon-prefix}-yuv:before content: "\E059" .#{$icon-prefix}-dds:before content: "\E060" .#{$icon-prefix}-ai:before content: "\E061" .#{$icon-prefix}-eps:before content: "\E062" .#{$icon-prefix}-ps:before content: "\E063" .#{$icon-prefix}-svg:before content: "\E064" .#{$icon-prefix}-pdf:before content: "\E065" .#{$icon-prefix}-pct:before content: "\E066" .#{$icon-prefix}-indd:before content: "\E067" .#{$icon-prefix}-xlr:before content: "\E068" .#{$icon-prefix}-xls:before content: "\E069" .#{$icon-prefix}-xlsx:before content: "\E070" .#{$icon-prefix}-db:before content: "\E071" .#{$icon-prefix}-dbf:before content: "\E072" .#{$icon-prefix}-mdb:before content: "\E073" .#{$icon-prefix}-pdb:before content: "\E074" .#{$icon-prefix}-sql:before content: "\E075" .#{$icon-prefix}-aacd:before content: "\E076" .#{$icon-prefix}-app:before content: "\E077" .#{$icon-prefix}-exe:before content: "\E078" .#{$icon-prefix}-com:before content: "\E079" .#{$icon-prefix}-bat:before content: "\E080" .#{$icon-prefix}-apk:before content: "\E081" .#{$icon-prefix}-jar:before content: "\E082" .#{$icon-prefix}-hsf:before content: "\E083" .#{$icon-prefix}-pif:before content: "\E084" .#{$icon-prefix}-vb:before content: "\E085" .#{$icon-prefix}-cgi:before content: "\E086" .#{$icon-prefix}-css:before content: "\E087" .#{$icon-prefix}-js:before content: "\E088" .#{$icon-prefix}-php:before content: "\E089" .#{$icon-prefix}-xhtml:before content: "\E090" .#{$icon-prefix}-htm:before content: "\E091" .#{$icon-prefix}-html:before content: "\E092" .#{$icon-prefix}-asp:before content: "\E093" .#{$icon-prefix}-cer:before content: "\E094" .#{$icon-prefix}-jsp:before content: "\E095" .#{$icon-prefix}-cfm:before content: "\E096" .#{$icon-prefix}-aspx:before content: "\E097" .#{$icon-prefix}-rss:before content: "\E098" .#{$icon-prefix}-csr:before content: "\E099" .#{$icon-prefix}-less:before content: "\003C" .#{$icon-prefix}-otf:before content: "\E101" .#{$icon-prefix}-ttf:before content: "\E102" .#{$icon-prefix}-font:before content: "\E103" .#{$icon-prefix}-fnt:before content: "\E104" .#{$icon-prefix}-eot:before content: "\E105" .#{$icon-prefix}-woff:before content: "\E106" .#{$icon-prefix}-zip:before content: "\E107" .#{$icon-prefix}-zipx:before content: "\E108" .#{$icon-prefix}-rar:before content: "\E109" .#{$icon-prefix}-targ:before content: "\E110" .#{$icon-prefix}-sitx:before content: "\E111" .#{$icon-prefix}-deb:before content: "\E112" .#{$icon-prefix}-e_7z:before content: "\E113" .#{$icon-prefix}-pkg:before content: "\E114" .#{$icon-prefix}-rpm:before content: "\E115" .#{$icon-prefix}-cbr:before content: "\E116" .#{$icon-prefix}-gz:before content: "\E117" .#{$icon-prefix}-dmg:before content: "\E118" .#{$icon-prefix}-cue:before content: "\E119" .#{$icon-prefix}-bin:before content: "\E120" .#{$icon-prefix}-iso:before content: "\E121" .#{$icon-prefix}-hdf:before content: "\E122" .#{$icon-prefix}-vcd:before content: "\E123" .#{$icon-prefix}-bak:before content: "\E124" .#{$icon-prefix}-tmp:before content: "\E125" .#{$icon-prefix}-ics:before content: "\E126" .#{$icon-prefix}-msi:before content: "\E127" .#{$icon-prefix}-cfg:before content: "\E128" .#{$icon-prefix}-ini:before content: "\E129" .#{$icon-prefix}-prf:before content: "\E130" ================================================ FILE: app/styles/base/_mixins.sass ================================================ =ellipsis overflow: hidden white-space: nowrap text-overflow: ellipsis =button_reset margin: 0 padding: 0 display: inline-block border: none background: none outline: none width: auto cursor: pointer font-family: inherit =shape($size, $shape) display: inline-block width: $size height: $size line-height: $size text-align: center vertical-align: middle @if $shape == circle border-radius: 50% ================================================ FILE: app/styles/base/_reset.sass ================================================ a,abbr,acronym,address,applet,article,aside,audio,b,big,blockquote,body,canvas,caption,center,cite,code,dd,del,details,dfn,div,dl,dt,em,embed,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,html,i,iframe,img,ins,kbd,label,legend,li,mark,menu,nav,object,ol,output,p,pre,q,ruby,s,samp,section,small,span,strike,strong,sub,summary,sup,table,tbody,td,tfoot,th,thead,time,tr,tt,u,ul,var,video margin: 0 padding: 0 border: 0 font: inherit font-size: 100% vertical-align: baseline html line-height: 1 ol,ul list-style: none table border-collapse: collapse border-spacing: 0 caption,td,th text-align: left font-weight: 400 vertical-align: middle blockquote,q quotes: none blockquote:after,blockquote:before,q:after,q:before content: "" content: none a img border: 0 article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary display: block ================================================ FILE: app/styles/base/_variables.sass ================================================ $font-family: "Helvetica Neue", sans-serif $blue: #0088cc $green: #a4c540 ================================================ FILE: app/styles/layout/_content.sass ================================================ .l-content position: relative height: 100vh min-height: 600px .visually-hidden clip: rect(0 0 0 0) clip-path: inset(50%) height: 1px overflow: hidden position: absolute white-space: nowrap width: 1px .ribbon position: fixed left: -80px bottom: 0px width: 300px height: 64px transform: rotate(45deg) z-index: 999 background: linear-gradient(-180deg, rgb(0, 91, 187) 50%, rgb(255, 213, 0) 50%) opacity: 0.8 ================================================ FILE: app/styles/layout/_footer.sass ================================================ .l-footer position: fixed z-index: 200 bottom: 0 left: 0 right: 0 background-color: rgba(white,.6) text-align: center color: #b0b0b0 a opacity: .6 transition: opacity .2s linear &:hover opacity: 1 > span display: none .about display: inline-block font-size: 1.1rem line-height: 1.4 margin-top: .2em padding: 0 10px .logos display: flex align-items: center justify-content: space-between padding: 10px .left, .right display: flex .github width: 20px height: 20px background: transparent url("../assets/images/github.svg") no-repeat center .twitter width: 80px height: 20px margin-left: 8px .donate img height: 20px ================================================ FILE: app/styles/layout/_header.sass ================================================ .l-header .navbar position: fixed z-index: 10 top: 0 left: 0 width: 100% height: 60px background: transparent color: black user-select: none .logo position: relative height: 38px width: 162px margin: 15px 0 0 15px background: transparent url("../assets/images/sharedrop.svg") no-repeat left top .logo-title display: none .logo-subtitle position: absolute bottom: 0 left: 44px font-size: 1rem font-weight: bold img vertical-align: top .nav position: absolute top: 15px right: 15px > li float: left margin-left: 15px font-size: 1.4rem line-height: 30px a display: inline-block transition: opacity .175s linear color: black img vertical-align: middle &:hover opacity: .6 .icon-create-room font-size: 28px line-height: 24px .icon-help +shape(18px, circle) border: 1px solid black font-size: 1.2rem opacity: .18 margin-top: -2px ================================================ FILE: app/styles/layout/_media.sass ================================================ @media (max-height: 520px) .modal-body height: auto bottom: auto margin: 15px auto @media (max-width: 768px) .preloader width: 240px height: 41px background-size: 240px 41px .modal-body width: 90% height: auto bottom: auto margin: 15px auto .note display: block .l-content padding: 80px 0 115px min-height: inherit height: auto .user .peer position: relative left: auto !important bottom: auto !important height: 106px width: 100% padding: 15px border-bottom: 1px solid #eee margin: 0 !important text-align: left .avatar z-index: 2 .user-info z-index: 1 position: absolute top: 30px left: 0 padding-left: 170px width: 100% .user-email font-size: 1.4rem .user-label font-size: 1.8rem .user-ip font-size: 1.2rem color: #808080 .user-connection-status top: 0 left: -9.2rem margin-top: 0 transform: scale(1.75, 1.75) &.you .peer border-bottom: none .l-header .navbar z-index: 200 background: rgba(white,.7) .email display: none .nav > li margin-left: 10px .l-footer padding-top: 10px .circles display: none .error width: auto font-size: 1.4rem padding: 0 15px ================================================ FILE: app/styles/modules/_modal.sass ================================================ .modal-overlay position: fixed z-index: 300 top: 0 bottom: 0 left: 0 right: 0 background-color: rgba(black,.6) overflow: auto .modal-body position: absolute z-index: 301 top: 10% left: 0 right: 0 margin: auto background-color: white width: 580px padding: 20px margin-bottom: 40px border-radius: 5px h3 font-size: 1.8rem font-weight: bold margin-bottom: 0.5em h4 font-size: 1.5rem font-weight: bold margin-bottom: 0.5em p font-size: 1.4rem line-height: 1.4em margin-bottom: 1.42em &.note font-size: 1.1rem a color: $blue opacity: .6 &:hover opacity: 1 .qr-code div padding: 15px img margin-left: auto margin-right: auto .actions text-align: center button +button_reset font-size: 1.6rem cursor: pointer border-radius: 5px background: rgba($blue,.8) color: #fff padding: 14px 80px text-shadow: rgba(black,.3) 0 -1px 0 transition: background .3s margin-bottom: 20px &:hover background: $blue .logo height: 38px margin-bottom: 20px background: transparent url("../assets/images/sharedrop.svg") no-repeat left span display: none .plus-icon font-weight: bold font-size: 2rem ================================================ FILE: app/styles/modules/_modules.sass ================================================ .preloader position: absolute left: 0 right: 0 top: 0 bottom: 0 margin: auto width: 324px height: 56px background: transparent url("../assets/images/sharedrop.svg") no-repeat center background-size: 324px 56px transition: opacity .2s > span position: absolute text-align: center width: 100% bottom: -18px font-size: 1.4rem .ember-application .preloader opacity: 0 .error position: absolute top: 0 bottom: 0 left: 0 right: 0 margin: auto width: 50rem height: 15rem text-align: center font-size: 1.8rem line-height: 1.5em .circles position: absolute bottom: 0 left: 50% width: 1140px margin-left: -570px height: 700px z-index: -1 transform-origin: 570px 570px animation: grow 1.5s ease-out .circle stroke-width: .4 fill: rgba(0,0,0,0) @keyframes grow 50% transform: scale(1.5, 1.5) opacity: .2 51% transform: scale(0.5, 0.5) opacity: 0 ================================================ FILE: app/styles/modules/_popover.sass ================================================ $popover-border-color: #c0c0c0 .popover position: absolute bottom: 100% left: 50% transform: translateX(-50%) z-index: 10 background-color: #fff border: 1px solid $popover-border-color padding: 10px border-radius: 5px width: 360px box-shadow: rgba(black,.3) 0 1px 3px text-align: left margin-bottom: 5px &::after position: absolute bottom: 0 left: 50% margin: 0 0 -5px -5px content: '' width: 10px height: 10px background: inherit transform: rotate(45deg) border: 1px solid transparent border-right-color: $popover-border-color border-bottom-color: $popover-border-color .popover-body position: relative padding-left: 60px p word-break: break-all overflow: hidden font-size: 12px line-height: 1.4em margin-bottom: 1em min-height: 28px .popover-icon position: absolute left: 0 top: 0 font-size: 50px .popover-buttons text-align: right @media (max-width: 768px) .popover left: 0 right: 0 top: 0 bottom: 0 border: none width: auto box-shadow: none border-radius: 0 margin: 0 transform: none background-color: rgba(#f0f0f0, 0.9) &::after display: none .popover-buttons button font-size: 18px ================================================ FILE: app/styles/modules/_users.sass ================================================ $user-size: 76px .user user-select: none .peer position: absolute left: 50% bottom: 300px width: $user-size height: $user-size margin-left: -$user-size / 2 text-align: center .avatar position: relative width: $user-size height: $user-size transition: all .2s ease-in-out svg top: 0 bottom: 0 z-index: -1 .gravatar position: absolute top: 5px left: 5px z-index: 1 border: 1px solid #c0c0c0 box-shadow: rgba(black, 0.2) 0 0 3px width: $user-size - 10 height: $user-size - 10 border-radius: 50% animation: shadow .8s ease-in transition: all .2s ease-in-out .user-info position: absolute top: $user-size left: 50% width: 140px margin-left: -70px .user-label, .user-email font-weight: bold color: #606060 padding-bottom: .4rem .user-email font-size: 1rem +ellipsis .user-label font-size: 1.4rem .user-ip position: relative display: inline-block font-size: 1rem line-height: 1.2em color: #808080 > strong display: block .user-connection-status position: absolute left: -1rem top: 50% margin-top: -.3rem width: .6rem height: .6rem border-radius: 50% &.disconnected display: none &.connecting background: rgba($blue,.5) animation: blink .75s infinite &.connected background: rgba($green,.8) select appearance: none border: none font-size: 1rem color: #808080 padding-right: 10px outline: none background: transparent url("../images/select-arrow.svg") no-repeat 66px 50% // firefox fix - remove arrow from select text-indent: 0.01px text-overflow: '' &:nth-of-type(2) margin-left: -186px bottom: 225px &:nth-of-type(3) margin-left: 120px bottom: 225px &:nth-of-type(4) margin-left: -186px bottom: 365px &:nth-of-type(5) margin-left: 120px bottom: 365px &:nth-of-type(6) margin-left: -326px bottom: 180px &:nth-of-type(7) margin-left: 260px bottom: 180px &:nth-of-type(8) margin-left: -366px bottom: 320px &:nth-of-type(9) margin-left: 300px bottom: 320px &:nth-of-type(10) margin-left: -436px bottom: 90px &:nth-of-type(11) margin-left: 370px bottom: 90px &:nth-of-type(12) bottom: 400px &:nth-of-type(13) margin-left: -236px bottom: 90px &:nth-of-type(14) margin-left: 170px bottom: 90px &.you .peer bottom: 90px &.others .peer .avatar cursor: pointer &:hover, &.hover transform: scale(1.1, 1.1) .gravatar border-color: rgba($blue,.8) &::after opacity: 0 position: absolute pointer-events: none top: 5px left: 5px z-index: 100 content: "L" color: white font-size: 3rem font-weight: bold background: rgba($green,.8) border: 1px solid white transform: scaleX(-1) rotate(-45deg) // +circle display: inline-block width: 66px height: $user-size - 10 line-height: $user-size - 10 text-align: center vertical-align: middle border-radius: 50% transition: opacity .3s &.transfer-completed &::after opacity: 1 @keyframes blink 0% opacity: 1 50% opacity: 0 @keyframes shadow 0% opacity: 0 50% opacity: 1 box-shadow: rgba(black, .3) 0 0 15px ================================================ FILE: app/templates/about-app.hbs ================================================ {{#modal-dialog onClose=(action "closeModal" target=currentRoute)}}

What is it?

ShareDrop is a free, open-source web app that allows you to easily and securely share files directly between devices without uploading them to any server first.

How to use it?

Sharing files between devices in a local network*

To send a file to another device in the same local network, open this page (i.e. www.sharedrop.io) on both devices. Drag and drop a file directly on another person's avatar or click the avatar and select the file you want to send. The file transfer will start once the recipient accepts the file.

Sharing files between devices in different networks

To send a file to another device in a different network, click + button in the upper right corner of the page and follow further instructions.

VPNs

Sharedrop does not work with VPNs, please deactivate any VPN your device might be using.

Security

ShareDrop uses a secure and encrypted peer-to-peer connection to transfer information about the file (its name and size) and file data itself. This means that this data is never transfered through any intermediate server but directly between the sender and recipient devices. To achieve this, ShareDrop uses a technology called WebRTC (Web Real-Time Communication), which is provided natively by browsers. You can read more about WebRTC security here.

Feedback

Got a problem with using ShareDrop, suggestion how to improve it or just want to say hi? Send an email to support@sharedrop.io or report an issue on GitHub.

*Devices need to have the same public IP to see each other.

{{/modal-dialog}} ================================================ FILE: app/templates/about-room.hbs ================================================ {{#modal-dialog onClose=(action "closeModal" target=currentRoute)}}

Share files between devices in different networks

Copy provided address and send it to the other person...

{{room-url value=currentUrl readonly="readonly" style="display: block; margin: auto;"}}

Or you can scan it on the other device.

{{qr-code text=currentUrl}}

Once the other person open this page in a browser, you'll see each other's avatars.

Drag and drop a file directly on other person's avatar or click the avatar and select the file you want to send. The file transfer will start once the recipient accepts the file.

{{/modal-dialog}} ================================================ FILE: app/templates/about-you.hbs ================================================ ShareDrop lets you share files with others. Other people will see you as {{you.label}}. ================================================ FILE: app/templates/application.hbs ================================================ {{outlet}} {{outlet "modal"}}

{{outlet "about_you"}}

We stand with Ukraine! ================================================ FILE: app/templates/components/modal-dialog.hbs ================================================ {{! template-lint-disable no-invalid-interactive }} {{! template-lint-enable no-invalid-interactive }} ================================================ FILE: app/templates/components/peer-widget.hbs ================================================ {{! Sender related messages }} {{#if hasSelectedFile}} {{#popover-confirm onConfirm=(action "sendFileTransferInquiry") onCancel=(action "cancelFileTransfer") confirmButtonLabel="Send" cancelButtonLabel="Cancel" filename=filename }} Do you want to send "{{filename}}" to "{{label}}"? {{/popover-confirm}} {{/if}} {{#if isAwaitingResponse}} {{#popover-confirm onCancel=(action "abortFileTransfer") cancelButtonLabel="Cancel" filename=filename }} Waiting for "{{label}}" to accept… {{/popover-confirm}} {{/if}} {{#if hasDeclinedFileTransfer}} {{#popover-confirm onConfirm=(action "cancelFileTransfer") confirmButtonLabel="Ok" filename=filename }} "{{label}}" has declined your request. {{/popover-confirm}} {{/if}} {{#if hasError}} {{#popover-confirm onConfirm=(action "cancelFileTransfer") confirmButtonLabel="Ok" filename=filename }} {{partial errorTemplateName}} {{/popover-confirm}} {{/if}} {{! Recipient related popups }} {{#if hasReceivedFileInfo}} {{#popover-confirm onConfirm=(action "acceptFileTransfer") onCancel=(action "rejectFileTransfer") confirmButtonLabel="Save" cancelButtonLabel="Decline" filename=filename }} "{{label}}" wants to send you "{{filename}}". {{/popover-confirm}} {{/if}}
{{#if isPreparingFileTransfer}} {{else if peer.transfer}} {{#if peer.transfer.receivingProgress}} {{else if peer.transfer.sendingProgress}} {{/if}} {{/if}} {{peer-avatar peer=peer onFileDrop=(action "uploadFile")}}
{{file-field multiple=true onChange=(action "uploadFile")}} ================================================ FILE: app/templates/components/popover-confirm.hbs ================================================

{{yield}}

{{#if cancelButtonLabel}} {{/if}} {{#if confirmButtonLabel}} {{/if}}
================================================ FILE: app/templates/components/user-widget.hbs ================================================
{{users.label}}
================================================ FILE: app/templates/errors/browser-unsupported.hbs ================================================

We're really sorry, but your browser is not supported.
Please use the latest desktop or Android version of
Chrome, Opera or Firefox.

================================================ FILE: app/templates/errors/filesystem-unavailable.hbs ================================================

Uh oh. Looks like there's some issue and we won't be able
to save your files.

If you've opened this app in incognito/private window,
try again in a normal one.

================================================ FILE: app/templates/errors/popovers/connection-failed.hbs ================================================ It was not possible to establish direct connection with the other peer. ================================================ FILE: app/templates/errors/popovers/multiple-files.hbs ================================================ The files you have selected exceed the maximum allowed size of 200MB

TIP: You can send single files without size restriction ================================================ FILE: app/templates/index.hbs ================================================
{{#each model as |peer|}} {{peer-widget peer=peer hasCustomRoomName=hasCustomRoomName webrtc=webrtc}} {{/each}}
{{#if you.uuid}}
{{user-widget user=you}}
{{/if}}
================================================ FILE: config/dotenv.js ================================================ module.exports = function () { return { clientAllowedKeys: ['FIREBASE_URL'], // Fail build when there is missing any of clientAllowedKeys environment variables. // By default false. failOnMissingKey: false, }; }; ================================================ FILE: config/environment.js ================================================ module.exports = function (environment) { const ENV = { modulePrefix: 'sharedrop', environment, rootURL: '/', locationType: 'auto', EmberENV: { FEATURES: { // Here you can enable experimental features on an ember canary build // e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true }, EXTEND_PROTOTYPES: { // Prevent Ember Data from overriding Date.parse. Date: false, }, }, APP: { // Here you can pass flags/options to your application instance // when it is created }, FIREBASE_URL: process.env.FIREBASE_URL, exportApplicationGlobal: true, }; if (environment === 'development') { // ENV.APP.LOG_RESOLVER = true; // ENV.APP.LOG_ACTIVE_GENERATION = true; // ENV.APP.LOG_TRANSITIONS = true; // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; // ENV.APP.LOG_VIEW_LOOKUPS = true; } if (environment === 'test') { // Testem prefers this... ENV.locationType = 'none'; // keep test console output quieter ENV.APP.LOG_ACTIVE_GENERATION = false; ENV.APP.LOG_VIEW_LOOKUPS = false; ENV.APP.rootElement = '#ember-testing'; ENV.APP.autoboot = false; } if (environment === 'production') { ENV.googleAnalyticsId = 'UA-41889586-2'; } return ENV; }; ================================================ FILE: config/optional-features.json ================================================ { "application-template-wrapper": false, "default-async-observers": true, "jquery-integration": true, "template-only-glimmer-components": true } ================================================ FILE: config/targets.js ================================================ const browsers = [ 'last 2 Chrome versions', 'last 2 Firefox versions', 'last 2 Safari versions', 'last 2 iOS versions', 'last 2 Edge versions', ]; module.exports = { browsers, }; ================================================ FILE: ember-cli-build.js ================================================ /* eslint-env node */ const EmberApp = require('ember-cli/lib/broccoli/ember-app'); module.exports = function (defaults) { const app = new EmberApp(defaults, { // Don't include SVG files, because of animal icons being loaded dynamically fingerprint: { extensions: ['js', 'css', 'png', 'jpg', 'gif', 'map'], }, // Generate source maps in production as well sourcemaps: { enabled: true }, sassOptions: { extension: 'sass' }, SRI: { enabled: false }, }); // Use `app.import` to add additional libraries to the generated // output files. // // If you need to use different assets in different // environments, specify an object as the first parameter. That // object's keys should be the environment name and the values // should be the asset to use in that environment. // // If the library that you are including contains AMD or ES6 // modules that you would like to import into your application // please specify an object with the list of modules as keys // along with the exports of each module as its value. app.import('vendor/ba-tiny-pubsub.min.js'); app.import('vendor/filer.min.js'); app.import('vendor/idb.filesystem.min.js'); app.import('vendor/peer.js'); return app.toTree(); }; ================================================ FILE: firebase_rules.json ================================================ { "rules": { "rooms": { "$roomid": { // You can see people in the room only if you are in it as well ".read": "auth != null && data.child('users').hasChild(auth.id)", "users": { "$userid": { // You can modify only your own info ".write": "auth != null && $userid == auth.id", // Ensure that all required attributes are there ".validate": "newData.hasChildren(['uuid', 'public_ip', 'peer']) && newData.child('peer').hasChildren(['id'])" } }, "messages": { // You can send message to anybody in the room ".write": "auth != null", "$userid": { // You can read only messages sent to you ".read": "auth != null && $userid == auth.id" } } } } } } ================================================ FILE: lib/google-analytics/index.js ================================================ const { name } = require('./package'); module.exports = { name, isDevelopingAddon() { return true; }, contentFor(type, config) { const id = config.googleAnalyticsId; if (type === 'head' && id) { return ` `; } return ''; }, }; ================================================ FILE: lib/google-analytics/package.json ================================================ { "name": "google-analytics", "keywords": [ "ember-addon" ] } ================================================ FILE: newrelic.js ================================================ /** * New Relic agent configuration. * * See lib/config.defaults.js in the agent distribution for a more complete * description of configuration variables and their potential values. */ exports.config = { /** * Array of application names. */ app_name: ['ShareDrop'], logging: { /** * Level at which to log. 'trace' is most useful to New Relic when diagnosing * issues with the agent, 'info' and higher will impose the least overhead on * production applications. */ level: 'info', }, }; ================================================ FILE: package.json ================================================ { "name": "sharedrop", "version": "1.0.0", "private": true, "description": "P2P file sharing", "license": "MIT", "author": "Szymon Nowak", "directories": { "doc": "doc", "test": "tests" }, "repository": { "type": "git", "url": "https://github.com/szimek/sharedrop.git" }, "scripts": { "build": "ember build --environment=production", "lint": "npm-run-all --aggregate-output --continue-on-error --parallel lint:*", "develop": "yarn install --frozen-lockfile && nf --procfile=Procfile.dev start", "dev": "yarn develop", "lint:hbs": "ember-template-lint .", "lint:js": "eslint .", "start": "ember serve", "test": "npm-run-all lint:* test:*", "test:ember": "ember test" }, "devDependencies": { "@ember/jquery": "^1.1.0", "@ember/optional-features": "^2.0.0", "@glimmer/component": "^1.0.2", "@glimmer/tracking": "^1.0.2", "babel-eslint": "^10.1.0", "broccoli-asset-rev": "^3.0.0", "ember-auto-import": "^1.6.0", "ember-cli": "~3.21.2", "ember-cli-app-version": "^3.2.0", "ember-cli-babel": "^7.22.1", "ember-cli-dependency-checker": "^3.2.0", "ember-cli-dotenv": "^3.1.0", "ember-cli-htmlbars": "^5.3.1", "ember-cli-inject-live-reload": "^2.0.2", "ember-cli-sass": "^10.0.0", "ember-cli-sri": "^2.1.1", "ember-cli-terser": "^4.0.0", "ember-export-application-global": "^2.0.1", "ember-fetch": "^8.0.2", "ember-load-initializers": "^2.1.1", "ember-maybe-import-regenerator": "^0.1.6", "ember-qrcode-shim": "^0.4.0", "ember-qunit": "^4.6.0", "ember-resolver": "^8.0.2", "ember-source": "~3.21.3", "ember-template-lint": "^2.13.0", "eslint": "^7.10.0", "eslint-config-airbnb-base": "^14.1.0", "eslint-config-prettier": "^6.12.0", "eslint-plugin-ember": "^9.2.0", "eslint-plugin-import": "^2.22.1", "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^3.0.1", "foreman": "3.0.1", "husky": "^4.3.0", "lint-staged": "^10.4.0", "loader.js": "^4.7.0", "npm-run-all": "^4.1.5", "prettier": "^2.1.2", "qunit-dom": "^1.5.0", "sass": "^1.26.11" }, "dependencies": { "@sentry/browser": "5.24.2", "@sentry/integrations": "5.24.2", "body-parser": "^1.10.0", "compression": "^1.2.2", "cookie-parser": "^1.3.3", "cookie-session": "^1.1.0", "express": "^4.10.6", "firebase-token-generator": "~2.0.0", "jszip": "^3.5.0", "lodash": "^4.17.20", "morgan": "^1.5.0", "newrelic": "^6.13.1", "stream": "^0.0.2", "uuid": "^8.3.1" }, "husky": { "hooks": { "pre-commit": "lint-staged" } }, "lint-staged": { "*.js": [ "yarn run prettier --write", "yarn run lint:hbs", "yarn run lint:js" ] }, "engines": { "node": "^14.0.0" }, "ember": { "edition": "octane" }, "ember-addon": { "paths": [ "lib/google-analytics" ] } } ================================================ FILE: prettier.config.js ================================================ // https://prettier.io/docs/en/options.html module.exports = { printWidth: 80, tabWidth: 2, useTabs: false, semi: true, singleQuote: true, trailingComma: 'all', bracketSpacing: true, jsxBracketSameLine: false, arrowParens: 'always', }; ================================================ FILE: public/.gitkeep ================================================ ================================================ FILE: public/.well-known/brave-rewards-verification.txt ================================================ This is a Brave Rewards publisher verification file. Domain: sharedrop.io Token: d463c371edd92de3a09b0ccf1ce8143b9ee7f8c5611734f7ab58e2b67934b4bb ================================================ FILE: public/crossdomain.xml ================================================ ================================================ FILE: public/robots.txt ================================================ # http://www.robotstxt.org User-agent: * Disallow: ================================================ FILE: server.js ================================================ /* eslint-env node */ if (process.env.NODE_ENV === 'production') { // eslint-disable-next-line global-require require('newrelic'); } // Room server const http = require('http'); const path = require('path'); const express = require('express'); const logger = require('morgan'); const bodyParser = require('body-parser'); const cookieParser = require('cookie-parser'); const cookieSession = require('cookie-session'); const compression = require('compression'); const { v4: uuidv4 } = require('uuid'); const crypto = require('crypto'); const FirebaseTokenGenerator = require('firebase-token-generator'); const firebaseTokenGenerator = new FirebaseTokenGenerator( process.env.FIREBASE_SECRET, ); const app = express(); const secret = process.env.SECRET; const base = ['dist']; app.enable('trust proxy'); app.use(logger('combined')); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); app.use(cookieParser()); app.use( cookieSession({ cookie: { // secure: true, httpOnly: true, maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days }, secret, proxy: true, }), ); app.use(compression()); // // Web server // base.forEach((dir) => { const subdirs = ['assets', '.well-known']; subdirs.forEach((subdir) => { app.use( `/${subdir}`, express.static(`${dir}/${subdir}`, { maxAge: 31104000000, // ~1 year }), ); }); }); // // API server // app.get('/', (req, res) => { const root = path.join(__dirname, base[0]); console.log({ root }); res.sendFile(`${root}/index.html`); }); app.get('/rooms/:id', (req, res) => { const root = path.join(__dirname, base[0]); res.sendFile(`${root}/index.html`); }); app.get('/room', (req, res) => { const ip = req.headers['cf-connecting-ip'] || req.ip; const name = crypto.createHmac('md5', secret).update(ip).digest('hex'); res.json({ name }); }); app.get('/auth', (req, res) => { const ip = req.headers['cf-connecting-ip'] || req.ip; const uid = uuidv4(); const token = firebaseTokenGenerator.createToken( { uid, id: uid }, // will be available in Firebase security rules as 'auth' { expires: 32503680000 }, // 01.01.3000 00:00 ); res.json({ id: uid, token, public_ip: ip }); }); http .createServer(app) .listen(process.env.PORT) .on('listening', () => { console.log( `Started ShareDrop web server at http://localhost:${process.env.PORT}...`, ); }); ================================================ FILE: sharedrop.crx ================================================ { "name": "ShareDrop", "description": "Simple file sharing", "version": "1", "app": { "urls": [ "https:/www.sharedrop.io/" ], "launch": { "web_url": "https:/www.sharedrop.io/" } }, "icons": { "128": "sharedrop-icon-128.png" }, "permissions": [ "notifications" ] } ================================================ FILE: testem.js ================================================ module.exports = { test_page: 'tests/index.html?hidepassed', disable_watching: true, launch_in_ci: ['Chrome'], launch_in_dev: ['Chrome'], browser_start_timeout: 120, browser_args: { Chrome: { ci: [ // --no-sandbox is needed when running Chrome inside a container process.env.CI ? '--no-sandbox' : null, '--headless', '--disable-dev-shm-usage', '--disable-software-rasterizer', '--mute-audio', '--remote-debugging-port=0', '--window-size=1440,900', ].filter(Boolean), }, }, }; ================================================ FILE: tests/helpers/.gitkeep ================================================ ================================================ FILE: tests/index.html ================================================ EmberCliTest Tests {{content-for "head"}} {{content-for "test-head"}} {{content-for "head-footer"}} {{content-for "test-head-footer"}} {{content-for "body"}} {{content-for "test-body"}} {{content-for "body-footer"}} {{content-for "test-body-footer"}} ================================================ FILE: tests/integration/.gitkeep ================================================ ================================================ FILE: tests/test-helper.js ================================================ /* eslint */ import { setApplication } from '@ember/test-helpers'; import { start } from 'ember-qunit'; import Application from 'sharedrop/app'; import config from 'sharedrop/config/environment'; setApplication(Application.create(config.APP)); start(); ================================================ FILE: tests/unit/.gitkeep ================================================ ================================================ FILE: vendor/.gitkeep ================================================ ================================================ FILE: vendor/peer.js ================================================ /*! peerjs.js build:0.3.7, development. Copyright(c) 2013 Michelle Bu */ (function(exports){ /** * Light EventEmitter. Ported from Node.js/events.js * Eric Zhang */ /** * EventEmitter class * Creates an object with event registering and firing methods */ function EventEmitter() { // Initialise required storage variables this._events = {}; } var isArray = Array.isArray; EventEmitter.prototype.addListener = function(type, listener) { if ('function' !== typeof listener) { throw new Error('addListener only takes instances of Function'); } // To avoid recursion in the case that type == "newListeners"! Before // adding it to the listeners, first emit "newListeners". this.emit('newListener', type, typeof listener.listener === 'function' ? listener.listener : listener); if (!this._events[type]) { // Optimize the case of one listener. Don't need the extra array object. this._events[type] = listener; } else if (isArray(this._events[type])) { // If we've already got an array, just append. this._events[type].push(listener); } else { // Adding the second element, need to change to array. this._events[type] = [this._events[type], listener]; } return this; }; EventEmitter.prototype.on = EventEmitter.prototype.addListener; EventEmitter.prototype.once = function(type, listener) { if ('function' !== typeof listener) { throw new Error('.once only takes instances of Function'); } var self = this; function g() { self.removeListener(type, g); listener.apply(this, arguments); } g.listener = listener; self.on(type, g); return this; }; EventEmitter.prototype.removeListener = function(type, listener) { if ('function' !== typeof listener) { throw new Error('removeListener only takes instances of Function'); } // does not use listeners(), so no side effect of creating _events[type] if (!this._events[type]) return this; var list = this._events[type]; if (isArray(list)) { var position = -1; for (var i = 0, length = list.length; i < length; i++) { if (list[i] === listener || (list[i].listener && list[i].listener === listener)) { position = i; break; } } if (position < 0) return this; list.splice(position, 1); if (list.length === 0) delete this._events[type]; } else if (list === listener || (list.listener && list.listener === listener)) { delete this._events[type]; } return this; }; EventEmitter.prototype.off = EventEmitter.prototype.removeListener; EventEmitter.prototype.removeAllListeners = function(type) { if (arguments.length === 0) { this._events = {}; return this; } // does not use listeners(), so no side effect of creating _events[type] if (type && this._events && this._events[type]) this._events[type] = null; return this; }; EventEmitter.prototype.listeners = function(type) { if (!this._events[type]) this._events[type] = []; if (!isArray(this._events[type])) { this._events[type] = [this._events[type]]; } return this._events[type]; }; EventEmitter.prototype.emit = function(type) { type = arguments[0]; var handler = this._events[type]; var l, args, i; if (!handler) return false; if (typeof handler == 'function') { switch (arguments.length) { // fast cases case 1: handler.call(this); break; case 2: handler.call(this, arguments[1]); break; case 3: handler.call(this, arguments[1], arguments[2]); break; // slower default: l = arguments.length; args = new Array(l - 1); for (i = 1; i < l; i++) args[i - 1] = arguments[i]; handler.apply(this, args); } return true; } else if (isArray(handler)) { l = arguments.length; args = new Array(l - 1); for (i = 1; i < l; i++) args[i - 1] = arguments[i]; var listeners = handler.slice(); for (i = 0, l = listeners.length; i < l; i++) { listeners[i].apply(this, args); } return true; } else { return false; } }; exports.RTCSessionDescription = window.RTCSessionDescription || window.mozRTCSessionDescription; exports.RTCPeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection; exports.RTCIceCandidate = window.RTCIceCandidate || window.mozRTCIceCandidate; var defaultConfig = { 'iceServers': [{ 'urls': 'stun:stun.l.google.com:19302', }] }; var util = { noop: function() {}, // Logging logic logLevel: 0, setLogLevel: function(level) { var debugLevel = parseInt(level, 10); if (!isNaN(parseInt(level, 10))) { util.logLevel = debugLevel; } else { // If they are using truthy/falsy values for debug util.logLevel = level ? 3 : 0; } util.log = util.warn = util.error = util.noop; if (util.logLevel > 0) { util.error = util._printWith('ERROR'); } if (util.logLevel > 1) { util.warn = util._printWith('WARNING'); } if (util.logLevel > 2) { util.log = util._print; } }, setLogFunction: function(fn) { if (fn.constructor !== Function) { util.warn('The log function you passed in is not a function. Defaulting to regular logs.'); } else { util._print = fn; } }, _printWith: function(prefix) { return function() { var copy = Array.prototype.slice.call(arguments); copy.unshift(prefix); util._print.apply(util, copy); }; }, _print: function () { var err = false; var copy = Array.prototype.slice.call(arguments); copy.unshift('PeerJS: '); for (var i = 0, l = copy.length; i < l; i++){ if (copy[i] instanceof Error) { copy[i] = '(' + copy[i].name + ') ' + copy[i].message; err = true; } } err ? console.error.apply(console, copy) : console.log.apply(console, copy); }, // // Returns browser-agnostic default config defaultConfig: defaultConfig, // // Returns the current browser. browser: (function() { if (window.mozRTCPeerConnection) { return 'Firefox'; } else if (window.webkitRTCPeerConnection) { return 'Chrome'; } else if (window.RTCPeerConnection) { return 'Supported'; } else { return 'Unsupported'; } })(), // // Lists which features are supported supports: (function() { if (typeof RTCPeerConnection === 'undefined') { return {}; } var data = true; var audioVideo = true; var binaryBlob = false; var sctp = true; var onnegotiationneeded = !!window.webkitRTCPeerConnection; var pc, dc; try { pc = new RTCPeerConnection(defaultConfig, {optional: [{RtpDataChannels: true}]}); } catch (e) { data = false; audioVideo = false; } if (data) { try { dc = pc.createDataChannel('_PEERJSTEST'); } catch (e) { sctp = false; data = false; } } if (data) { // Binary test try { dc.binaryType = 'blob'; binaryBlob = true; } catch (e) { console.log(e); } } // FIXME: not really the best check... if (audioVideo) { audioVideo = !!pc.addStream; } // FIXME: this is not great because in theory it doesn't work for // av-only browsers (?). if (!onnegotiationneeded && data) { // sync default check. var negotiationPC = new RTCPeerConnection(defaultConfig, {optional: [{RtpDataChannels: true}]}); negotiationPC.onnegotiationneeded = function() { onnegotiationneeded = true; // async check. if (util && util.supports) { util.supports.onnegotiationneeded = true; } }; negotiationPC.createDataChannel('_PEERJSNEGOTIATIONTEST'); setTimeout(function() { negotiationPC.close(); }, 1000); } if (pc) { pc.close(); } return { audioVideo: audioVideo, data: data, binaryBlob: binaryBlob, binary: sctp, // deprecated; sctp implies binary support. reliable: sctp, // deprecated; sctp implies reliable data. sctp: sctp, onnegotiationneeded: onnegotiationneeded }; }()), // // Ensure alphanumeric ids validateId: function(id) { // Allow empty ids return !id || /^[A-Za-z0-9]+(?:[ _-][A-Za-z0-9]+)*$/.exec(id); }, debug: false, inherits: function(ctor, superCtor) { ctor.super_ = superCtor; ctor.prototype = Object.create(superCtor.prototype, { constructor: { value: ctor, enumerable: false, writable: true, configurable: true } }); }, extend: function(dest, source) { for(var key in source) { if(source.hasOwnProperty(key)) { dest[key] = source[key]; } } return dest; }, log: function () { if (util.debug) { var err = false; var copy = Array.prototype.slice.call(arguments); copy.unshift('PeerJS: '); for (var i = 0, l = copy.length; i < l; i++){ if (copy[i] instanceof Error) { copy[i] = '(' + copy[i].name + ') ' + copy[i].message; err = true; } } err ? console.error.apply(console, copy) : console.log.apply(console, copy); } }, setZeroTimeout: (function(global) { var timeouts = []; var messageName = 'zero-timeout-message'; // Like setTimeout, but only takes a function argument. There's // no time argument (always zero) and no arguments (you have to // use a closure). function setZeroTimeoutPostMessage(fn) { timeouts.push(fn); global.postMessage(messageName, '*'); } function handleMessage(event) { if (event.source == global && event.data == messageName) { if (event.stopPropagation) { event.stopPropagation(); } if (timeouts.length) { timeouts.shift()(); } } } if (global.addEventListener) { global.addEventListener('message', handleMessage, true); } else if (global.attachEvent) { global.attachEvent('onmessage', handleMessage); } return setZeroTimeoutPostMessage; }(this)), randomToken: function () { return Math.random().toString(36).substr(2); }, isSecure: function() { return location.protocol === 'https:'; } }; exports.util = util; /** * A peer who can initiate connections with other peers. */ function Peer(id, options) { EventEmitter.call(this); // Configurize options this.options = util.extend({ debug: 0, // 1: Errors, 2: Warnings, 3: All logs config: util.defaultConfig }, options); // Set a custom log function if present if (options.logFunction) { util.setLogFunction(options.logFunction); } util.setLogLevel(options.debug); // Sanity checks // Ensure WebRTC supported if (!util.supports.audioVideo && !util.supports.data ) { this._delayedAbort('browser-incompatible', 'The current browser does not support WebRTC'); return; } // Ensure alphanumeric id if (!util.validateId(id)) { this._delayedAbort('invalid-id', 'ID "' + id + '" is invalid'); return; } // States this.destroyed = false; // Connections have been killed this.disconnected = false; // Connection to PeerServer killed manually but P2P connections still active this.open = false; // Sockets and such are not yet open. // References this.connections = {}; // DataConnections for this peer. this._lostMessages = {}; // src => [list of messages] // Start the connections this._initialize(id); } util.inherits(Peer, EventEmitter); /** Initialize a connection with the server. */ Peer.prototype._initialize = function (id) { this.id = id; // Firebase this._ref = this.options.firebaseRef; this._connectionRef = this._ref.child('.info/connected'); this._messagesRef = this._ref.child('rooms/' + this.options.room + '/messages'); this._receivedMessagesRef = this._messagesRef.child(id); this._connectionRef.on('value', (connectionSnapshot) => { if (connectionSnapshot.val() === true) { // Remove received messages on disconnect this._receivedMessagesRef.onDisconnect().remove(); // Listen to incoming messages this._receivedMessagesRef.on('child_added', (snapshot) => { const message = snapshot.val(); this._handleMessage(message); }); } else { this._receivedMessagesRef.off(); } }); // The connection to the server is open this.emit('open', this.id); this.open = true; }; /** Handles messages from the server. */ Peer.prototype._handleMessage = function (message) { var type = message.type; var payload = message.payload; var peer = message.src; var connectionId, connection; switch (type) { case 'LEAVE': // Another peer has closed its connection to this peer. util.log('Received leave message from', peer); this._cleanupPeer(peer); break; case 'EXPIRE': // The offer sent to a peer has expired without response. this.emit('error', new Error('Could not connect to peer ' + peer)); break; case 'OFFER': // we should consider switching this to CALL/CONNECT, but this is the least breaking option. connectionId = payload.connectionId; connection = this.getConnection(peer, connectionId); if (connection) { util.warn('Offer received for existing Connection ID:', connectionId); //connection.handleMessage(message); } else { // Create a new connection. if (payload.type === 'media') { connection = new MediaConnection(peer, this, { connectionId: connectionId, _payload: payload, metadata: payload.metadata }); this._addConnection(peer, connection); this.emit('call', connection); } else if (payload.type === 'data') { connection = new DataConnection(peer, this, { connectionId: connectionId, _payload: payload, metadata: payload.metadata, label: payload.label, serialization: payload.serialization, reliable: payload.reliable }); this._addConnection(peer, connection); this.emit('connection', connection); } else { util.warn('Received malformed connection type:', payload.type); return; } // Find messages. var messages = this._getMessages(connectionId); for (var i = 0, ii = messages.length; i < ii; i += 1) { connection.handleMessage(messages[i]); } } break; case 'ERROR': connectionId = payload.connectionId; connection = this.getConnection(peer, connectionId); var error = new Error(payload.message); error.type = payload.type; if (connection) { connection.emit('error', error); } break; default: if (!payload) { util.warn('You received a malformed message from ' + peer + ' of type ' + type); return; } var id = payload.connectionId; connection = this.getConnection(peer, id); if (connection && connection.pc) { // Pass it on. connection.handleMessage(message); } else if (id) { // Store for possible later use this._storeMessage(id, message); } else { util.warn('You received an unrecognized message:', message); } break; } }; /** Stores messages without a set up connection, to be claimed later. */ Peer.prototype._storeMessage = function (connectionId, message) { if (!this._lostMessages[connectionId]) { this._lostMessages[connectionId] = []; } this._lostMessages[connectionId].push(message); }; /** Retrieve messages from lost message store */ Peer.prototype._getMessages = function (connectionId) { var messages = this._lostMessages[connectionId]; if (messages) { delete this._lostMessages[connectionId]; return messages; } else { return []; } }; /** * Returns a DataConnection to the specified peer. See documentation for a * complete list of options. */ Peer.prototype.connect = function(peer, options) { if (this.disconnected) { util.warn('You cannot connect to a new Peer because you called ' + '.disconnect() on this Peer and ended your connection with the' + ' server. You can create a new Peer to reconnect.'); this.emit('error', new Error('Cannot connect to new Peer after disconnecting from server.')); return; } var connection = new DataConnection(peer, this, options); this._addConnection(peer, connection); return connection; }; /** * Returns a MediaConnection to the specified peer. See documentation for a * complete list of options. */ Peer.prototype.call = function(peer, stream, options) { if (this.disconnected) { util.warn('You cannot connect to a new Peer because you called ' + '.disconnect() on this Peer and ended your connection with the' + ' server. You can create a new Peer to reconnect.'); this.emit('error', new Error('Cannot connect to new Peer after disconnecting from server.')); return; } if (!stream) { util.error('To call a peer, you must provide a stream from your browser\'s `getUserMedia`.'); return; } options = options || {}; options._stream = stream; var call = new MediaConnection(peer, this, options); this._addConnection(peer, call); return call; }; /** Add a data/media connection to this peer. */ Peer.prototype._addConnection = function(peer, connection) { if (!this.connections[peer]) { this.connections[peer] = []; } this.connections[peer].push(connection); }; /** Retrieve a data/media connection for this peer. */ Peer.prototype.getConnection = function(peer, id) { var connections = this.connections[peer]; if (!connections) { return null; } for (var i = 0, ii = connections.length; i < ii; i++) { if (connections[i].id === id) { return connections[i]; } } return null; }; Peer.prototype._delayedAbort = function(type, message) { var self = this; util.setZeroTimeout(function (){ self._abort(type, message); }); }; /** Destroys the Peer and emits an error message. */ Peer.prototype._abort = function(type, message) { util.error('Aborting. Error:', message); var err = new Error(message); err.type = type; this.destroy(); this.emit('error', err); }; /** * Destroys the Peer: closes all active connections as well as the connection * to the server. * Warning: The peer can no longer create or accept connections after being * destroyed. */ Peer.prototype.destroy = function() { if (!this.destroyed) { this._cleanup(); this.destroyed = true; } }; /** Disconnects every connection on this peer. */ Peer.prototype._cleanup = function() { if (this.connections) { var peers = Object.keys(this.connections); for (var i = 0, ii = peers.length; i < ii; i++) { this._cleanupPeer(peers[i]); } } this.emit('close'); }; /** Closes all connections to this peer. */ Peer.prototype._cleanupPeer = function(peer) { var connections = this.connections[peer]; for (var j = 0, jj = connections.length; j < jj; j += 1) { connections[j].close(); } }; exports.Peer = Peer; /** * Wraps a DataChannel between two Peers. */ function DataConnection(peer, provider, options) { if (!(this instanceof DataConnection)) return new DataConnection(peer, provider, options); EventEmitter.call(this); this.options = util.extend({ serialization: 'binary', reliable: false, metadata: {} // Firebase doesn't allow undefined values }, options); // Connection is not open yet. this.open = false; this.type = 'data'; this.peer = peer; this.provider = provider; this.id = this.options.connectionId || DataConnection._idPrefix + util.randomToken(); this.label = this.options.label || this.id; this.metadata = this.options.metadata; this.serialization = this.options.serialization; this.reliable = this.options.reliable; // Data channel buffering. this._buffer = []; this._buffering = false; this.bufferSize = 0; // For storing large data. this._chunkedData = {}; if (this.options._payload) { this._peerBrowser = this.options._payload.browser; } Negotiator.startConnection( this, this.options._payload || { originator: true } ); } util.inherits(DataConnection, EventEmitter); DataConnection._idPrefix = 'dc_'; /** Called by the Negotiator when the DataChannel is ready. */ DataConnection.prototype.initialize = function(dc) { this._dc = this.dataChannel = dc; this._configureDataChannel(); }; DataConnection.prototype._configureDataChannel = function() { var self = this; if (util.supports.sctp) { this._dc.binaryType = 'arraybuffer'; } this._dc.onopen = function() { util.log('Data channel connection success'); self.open = true; self.emit('open'); }; this._dc.onmessage = function (e) { self._handleDataMessage(e); }; this._dc.onclose = function (e) { util.log('DataChannel closed for:', self.peer); util.log(e); self.close(); }; }; // Handles a DataChannel message. DataConnection.prototype._handleDataMessage = function(e) { var data = e.data; if (data.byteLength !== undefined) { // ArrayBuffer } else { // String (JSON) data = JSON.parse(data); } this.emit('data', data); }; /** * Exposed functionality for users. */ /** Allows user to close connection. */ DataConnection.prototype.close = function() { if (!this.open) { return; } this.open = false; Negotiator.cleanup(this); this.emit('close'); }; /** Allows user to send data. */ DataConnection.prototype.send = function(data) { if (!this.open) { this.emit('error', new Error('Connection is not open. You should listen for the `open` event before sending messages.')); return; } // Lame type check if (data.byteLength === undefined) { // JSON string data = JSON.stringify(data); } this._bufferedSend(data); }; DataConnection.prototype._bufferedSend = function(msg) { if (this._buffering || !this._trySend(msg)) { this._buffer.push(msg); this.bufferSize = this._buffer.length; } }; // Returns true if the send succeeds. DataConnection.prototype._trySend = function(msg) { try { this._dc.send(msg); } catch (e) { this._buffering = true; var self = this; setTimeout(function() { // Try again. self._buffering = false; self._tryBuffer(); }, 100); return false; } return true; }; // Try to send the first message in the buffer. DataConnection.prototype._tryBuffer = function() { if (this._buffer.length === 0) { return; } var msg = this._buffer[0]; if (this._trySend(msg)) { this._buffer.shift(); this.bufferSize = this._buffer.length; this._tryBuffer(); } }; DataConnection.prototype.handleMessage = function(message) { var payload = message.payload; switch (message.type) { case 'ANSWER': this._peerBrowser = payload.browser; // Forward to negotiator Negotiator.handleSDP(message.type, this, payload.sdp); break; case 'CANDIDATE': Negotiator.handleCandidate(this, payload.candidate); break; default: util.warn('Unrecognized message type:', message.type, 'from peer:', this.peer); break; } }; /** * Wraps the streaming interface between two Peers. */ function MediaConnection(peer, provider, options) { if (!(this instanceof MediaConnection)) return new MediaConnection(peer, provider, options); EventEmitter.call(this); this.options = util.extend({}, options); this.open = false; this.type = 'media'; this.peer = peer; this.provider = provider; this.metadata = this.options.metadata; this.localStream = this.options._stream; this.id = this.options.connectionId || MediaConnection._idPrefix + util.randomToken(); if (this.localStream) { Negotiator.startConnection( this, {_stream: this.localStream, originator: true} ); } } util.inherits(MediaConnection, EventEmitter); MediaConnection._idPrefix = 'mc_'; MediaConnection.prototype.addStream = function(remoteStream) { util.log('Receiving stream', remoteStream); this.remoteStream = remoteStream; this.emit('stream', remoteStream); // Should we call this `open`? }; MediaConnection.prototype.handleMessage = function(message) { var payload = message.payload; switch (message.type) { case 'ANSWER': // Forward to negotiator Negotiator.handleSDP(message.type, this, payload.sdp); this.open = true; break; case 'CANDIDATE': Negotiator.handleCandidate(this, payload.candidate); break; default: util.warn('Unrecognized message type:', message.type, 'from peer:', this.peer); break; } }; MediaConnection.prototype.answer = function(stream) { if (this.localStream) { util.warn('Local stream already exists on this MediaConnection. Are you answering a call twice?'); return; } this.options._payload._stream = stream; this.localStream = stream; Negotiator.startConnection( this, this.options._payload ); // Retrieve lost messages stored because PeerConnection not set up. var messages = this.provider._getMessages(this.id); for (var i = 0, ii = messages.length; i < ii; i += 1) { this.handleMessage(messages[i]); } this.open = true; }; /** * Exposed functionality for users. */ /** Allows user to close connection. */ MediaConnection.prototype.close = function() { if (!this.open) { return; } this.open = false; Negotiator.cleanup(this); this.emit('close'); }; /** * Manages all negotiations between Peers. */ var Negotiator = { pcs: { data: {}, media: {} }, // type => {peerId: {pc_id: pc}}. //providers: {}, // provider's id => providers (there may be multiple providers/client. queue: [] // connections that are delayed due to a PC being in use. }; Negotiator._idPrefix = 'pc_'; /** Returns a PeerConnection object set up correctly (for data, media). */ Negotiator.startConnection = function(connection, options) { var pc = Negotiator._getPeerConnection(connection, options); if (connection.type === 'media' && options._stream) { // Add the stream. pc.addStream(options._stream); } // Set the connection's PC. connection.pc = pc; // What do we need to do now? if (options.originator) { if (connection.type === 'data') { // Create the datachannel. var config = {}; // Dropping reliable:false support, since it seems to be crashing // Chrome. /*if (util.supports.sctp && !options.reliable) { // If we have canonical reliable support... config = {maxRetransmits: 0}; }*/ // Fallback to ensure older browsers don't crash. if (!util.supports.sctp) { config = {reliable: options.reliable}; } var dc = pc.createDataChannel(connection.label, config); connection.initialize(dc); } if (!util.supports.onnegotiationneeded) { Negotiator._makeOffer(connection); } } else { Negotiator.handleSDP('OFFER', connection, options.sdp); } }; Negotiator._getPeerConnection = function (connection, options) { if (!Negotiator.pcs[connection.type]) { util.error(connection.type + ' is not a valid connection type. Maybe you overrode the `type` property somewhere.'); } if (!Negotiator.pcs[connection.type][connection.peer]) { Negotiator.pcs[connection.type][connection.peer] = {}; } var pc; if (options.pc) { // Simplest case: PC id already provided for us. pc = Negotiator.pcs[connection.type][connection.peer][options.pc]; } if (!pc || pc.signalingState !== 'stable') { pc = Negotiator._startPeerConnection(connection); } return pc; }; /** Start a PC. */ Negotiator._startPeerConnection = function(connection) { util.log('Creating RTCPeerConnection.'); var id = Negotiator._idPrefix + util.randomToken(); var optional = {}; if (connection.type === 'data' && !util.supports.sctp) { optional = {optional: [{RtpDataChannels: true}]}; } else if (connection.type === 'media') { // Interop req for chrome. optional = {optional: [{DtlsSrtpKeyAgreement: true}]}; } var pc = new RTCPeerConnection(connection.provider.options.config, optional); Negotiator.pcs[connection.type][connection.peer][id] = pc; Negotiator._setupListeners(connection, pc, id); return pc; }; /** Set up various WebRTC listeners. */ Negotiator._setupListeners = function(connection, pc) { var provider = connection.provider, src = provider.id, dst = connection.peer, connectionId = connection.id; // ICE CANDIDATES. util.log('Listening for ICE candidates.'); pc.onicecandidate = function(evt) { if (evt.candidate) { util.log('Received ICE candidates for:', dst); // For some reason Firefox requires toJSON call var c = evt.candidate, candidate = 'toJSON' in c ? c.toJSON() : c; provider._messagesRef.child(dst).push({ type: 'CANDIDATE', payload: { candidate: candidate, type: connection.type, connectionId: connectionId }, src: src, dst: dst }); } }; pc.oniceconnectionstatechange = function() { var error; switch (pc.iceConnectionState) { case 'failed': util.log("ICE connection state is 'failed', notifying " + dst); error = new Error("ICE connection state is 'failed'"); error.type = 'failed'; connection.provider._messagesRef.child(dst).push({ type: 'ERROR', payload: { message: error.message, type: error.type, connectionId: connectionId }, src: src, dst: dst }); connection.emit('error', error); connection.close(); break; case 'disconnected': util.log("ICE connection state is 'disconnected', closing connections to " + dst); error = new Error("ICE connection state is 'disconnected'"); error.type = 'disconnected'; connection.emit('error', error); connection.close(); break; case 'completed': pc.onicecandidate = util.noop; break; } }; // Fallback for older Chrome impls. pc.onicechange = pc.oniceconnectionstatechange; // ONNEGOTIATIONNEEDED (Chrome) util.log('Listening for `negotiationneeded`'); pc.onnegotiationneeded = function() { util.log('`negotiationneeded` triggered'); if (pc.signalingState == 'stable') { Negotiator._makeOffer(connection); } else { util.log('onnegotiationneeded triggered when not stable. Is another connection being established?'); } }; // DATACONNECTION. util.log('Listening for data channel'); // Fired between offer and answer, so options should already be saved // in the options hash. pc.ondatachannel = function(evt) { util.log('Received data channel'); var dc = evt.channel; var connection = provider.getConnection(dst, connectionId); connection.initialize(dc); }; // MEDIACONNECTION. util.log('Listening for remote stream'); pc.onaddstream = function(evt) { util.log('Received remote stream'); var stream = evt.stream; provider.getConnection(dst, connectionId).addStream(stream); }; }; Negotiator.cleanup = function(connection) { util.log('Cleaning up PeerConnection to ' + connection.peer); var pc = connection.pc; if (!!pc && (pc.readyState !== 'closed' || pc.signalingState !== 'closed')) { pc.close(); connection.pc = null; } }; Negotiator._makeOffer = function(connection) { var pc = connection.pc, src = connection.provider.id, dst = connection.peer; pc .createOffer(connection.options.constraints || {}) .then(function (offer) { util.log('Created offer.'); pc .setLocalDescription(offer) .then(function () { // For some reason Firefox requires toJSON call var o = offer; offer = 'toJSON' in o ? o.toJSON() : o; util.log('Set localDescription: offer', 'for:', dst); connection.provider._messagesRef.child(dst).push({ type: 'OFFER', payload: { sdp: offer, type: connection.type, label: connection.label, connectionId: connection.id, reliable: connection.reliable, serialization: connection.serialization, metadata: connection.metadata, browser: util.browser }, src: src, dst: dst }); }) .catch(function (err) { connection.provider.emit('error', err); util.log('Failed to setLocalDescription, ', err); }); }) .catch(function (err) { connection.provider.emit('error', err); util.log('Failed to createOffer, ', err); }); }; Negotiator._makeAnswer = function(connection) { var pc = connection.pc, provider = connection.provider, src = provider.id, dst = connection.peer; pc .createAnswer() .then(function(answer) { util.log('Created answer.'); pc .setLocalDescription(answer) .then(function() { // For some reason Firefox requires toJSON call var a = answer; answer = 'toJSON' in a ? a.toJSON() : a; util.log('Set localDescription: answer', 'for:', dst); provider._messagesRef.child(dst).push({ type: 'ANSWER', payload: { sdp: answer, type: connection.type, connectionId: connection.id, browser: util.browser }, src: src, dst: dst }); }) .catch(function(err) { connection.provider.emit('error', err); util.log('Failed to setLocalDescription, ', err); }); }) .catch(function(err) { connection.provider.emit('error', err); util.log('Failed to create answer, ', err); }); }; /** Handle an SDP. */ Negotiator.handleSDP = function(type, connection, sdp) { sdp = new RTCSessionDescription(sdp); var pc = connection.pc; util.log('Setting remote description', sdp); pc .setRemoteDescription(sdp) .then(function() { util.log('Set remoteDescription:', type, 'for:', connection.peer); if (type === 'OFFER') { Negotiator._makeAnswer(connection); } }) .catch(function(err) { connection.provider.emit('error', err); util.log('Failed to setRemoteDescription, ', err); }); }; /** Handle a candidate. */ Negotiator.handleCandidate = function(connection, ice) { var candidate = ice.candidate; var sdpMLineIndex = ice.sdpMLineIndex; connection.pc.addIceCandidate(new RTCIceCandidate({ sdpMLineIndex: sdpMLineIndex, candidate: candidate })); util.log('Added ICE candidate for:', connection.peer); }; })(this);