├── src
├── main
│ ├── windows
│ │ ├── index.js
│ │ └── main.js
│ ├── lib
│ │ ├── Crypto.js
│ │ ├── Server.js
│ │ ├── util.js
│ │ ├── Queue.js
│ │ ├── Wormholes.js
│ │ └── Peers.js
│ ├── menu.js
│ └── index.js
├── consts.js
├── config.js
└── renderer
│ ├── lib
│ ├── util.js
│ └── notifications.js
│ ├── index.js
│ └── components
│ ├── Toolbar.js
│ ├── WormholeModal.js
│ ├── Wormholes.js
│ ├── Drop.js
│ └── App.js
├── static
├── fonts
│ ├── ionicons.woff2
│ ├── ionicons4.woff2
│ └── ionicons5.woff2
├── scss
│ ├── themes
│ │ ├── default
│ │ │ ├── _index.scss
│ │ │ ├── _light.scss
│ │ │ └── _dark.scss
│ │ ├── vibrancy
│ │ │ ├── _index.scss
│ │ │ ├── _dark.scss
│ │ │ └── _light.scss
│ │ └── _index.scss
│ ├── components
│ │ ├── Button.scss
│ │ ├── Notification.scss
│ │ ├── Modal.scss
│ │ └── App.scss
│ ├── index.scss
│ └── ionicons.scss
├── index.html
└── img
│ ├── spacedrop-small.svg
│ └── spacedrop.svg
├── .gitmodules
├── .editorconfig
├── .babelrc
├── .gitignore
├── scripts
└── sass-loader.js
├── gulpfile.js
├── webpack.config.js
├── readme.md
└── package.json
/src/main/windows/index.js:
--------------------------------------------------------------------------------
1 | exports.main = require('./main')
--------------------------------------------------------------------------------
/static/fonts/ionicons.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hr/spacedrop/master/static/fonts/ionicons.woff2
--------------------------------------------------------------------------------
/static/fonts/ionicons4.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hr/spacedrop/master/static/fonts/ionicons4.woff2
--------------------------------------------------------------------------------
/static/fonts/ionicons5.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hr/spacedrop/master/static/fonts/ionicons5.woff2
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "src/main/lib/simple-peer"]
2 | path = src/main/lib/simple-peer
3 | url = https://github.com/HR/simple-peer
4 |
--------------------------------------------------------------------------------
/static/scss/themes/default/_index.scss:
--------------------------------------------------------------------------------
1 | @import 'dark';
2 | @import 'light';
3 |
4 | $theme: (
5 | light: $lightTheme,
6 | dark: $darkTheme,
7 | )
--------------------------------------------------------------------------------
/static/scss/themes/vibrancy/_index.scss:
--------------------------------------------------------------------------------
1 | @import 'dark';
2 | @import 'light';
3 |
4 | $theme: (
5 | light: $lightTheme,
6 | dark: $darkTheme,
7 | )
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 | quote_type = single
11 | max_line_length = 80
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/env", "@babel/react"],
3 | "plugins": [
4 | "@babel/plugin-proposal-class-properties",
5 | [
6 | "@babel/plugin-transform-runtime",
7 | {
8 | "regenerator": true
9 | }
10 | ]
11 | ]
12 | }
--------------------------------------------------------------------------------
/static/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | SpaceDrop
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/static/scss/themes/default/_light.scss:
--------------------------------------------------------------------------------
1 | $lightTheme: (
2 | primaryColor: #e5e5ea,
3 | primaryForegroundColor: #111113,
4 |
5 | primaryBackgroundColor: #fafaff,
6 | secondaryBackgroundColor: #d1d1d6,
7 | thirdlyBackgroundColor: #c7c7cc,
8 |
9 | dangerColor: #dc3545,
10 | onlineColor: #01a452,
11 | );
--------------------------------------------------------------------------------
/src/consts.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /**
3 | * Constants (static)
4 | */
5 |
6 | module.exports = {
7 | DROP_TYPE: {
8 | DOWNLOAD: '↓',
9 | UPLOAD: '↑'
10 | },
11 | DROP_STATUS: {
12 | DONE: 'done',
13 | PAUSED: 'paused',
14 | PENDING: 'pending',
15 | FAILED: 'failed'
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/static/scss/themes/default/_dark.scss:
--------------------------------------------------------------------------------
1 | $darkTheme: (
2 | primaryColor: #2c2c2e,
3 | primaryForegroundColor: #f2f2f7,
4 |
5 | // primaryBackgroundColor: #1c1c1e,
6 | primaryBackgroundColor: #111113,
7 | secondaryBackgroundColor: #3a3a3c,
8 | thirdlyBackgroundColor: #48484a,
9 |
10 | dangerColor: #dc3545,
11 | onlineColor: #01a452
12 | );
--------------------------------------------------------------------------------
/static/scss/themes/vibrancy/_dark.scss:
--------------------------------------------------------------------------------
1 | $darkTheme: (
2 | primaryColor: rgba(44,44,46, 0.8),
3 |
4 | primaryBackgroundColor: rgba(17,17,19, 0.8),
5 |
6 | primaryForegroundColor: rgba(242,242,247, 1),
7 |
8 | // primaryBackgroundColor: #1c1c1e,
9 | secondaryBackgroundColor: #3a3a3c,
10 | thirdlyBackgroundColor: #48484a,
11 |
12 | dangerColor: #dc3545,
13 | onlineColor: #01a452
14 | )
--------------------------------------------------------------------------------
/src/main/lib/Crypto.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /**
3 | * Crypto class
4 | * Manages all keys and provides all crypto functionality
5 | */
6 |
7 | const E2EE = require('./e2ee'),
8 | keytar = require('keytar'),
9 | SERVICE = 'spacedrop'
10 |
11 | module.exports = class Crypto extends E2EE {
12 | constructor (store) {
13 | const getSecretIdentity = publicKey =>
14 | keytar.getPassword(SERVICE, publicKey)
15 | const setSecretIdentity = (publicKey, secretKey) =>
16 | keytar.setPassword(SERVICE, publicKey, secretKey)
17 |
18 | super({ store, getSecretIdentity, setSecretIdentity })
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/static/scss/themes/_index.scss:
--------------------------------------------------------------------------------
1 | @import 'default';
2 |
3 | @mixin themify() {
4 | .theme-light {
5 | $internalTheme: map-get($theme, 'light') !global;
6 | @content;
7 | $internalTheme: null !global;
8 | }
9 |
10 | .theme-dark {
11 | $internalTheme: map-get($theme, 'dark') !global;
12 | @content;
13 | $internalTheme: null !global;
14 | }
15 | }
16 |
17 | @mixin shadow {
18 | box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
19 | 0 4px 6px -2px rgba(0, 0, 0, 0.05);
20 | }
21 |
22 | @function themed($key: 'primaryColor') {
23 | @return map-get($internalTheme, $key);
24 | }
25 |
26 | @function to-string($value) {
27 | @return inspect($value);
28 | }
29 |
30 | $horizontal-spacing: 0.8rem;
31 | $side-margin: 12px;
32 |
--------------------------------------------------------------------------------
/src/config.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /**
3 | * Configuration
4 | */
5 |
6 | const path = require('path'),
7 | { app } = require('electron'),
8 | { is } = require('electron-util')
9 |
10 | const CONFIG = {
11 | LOAD_URL: `file://${path.join(__dirname, '../app/index.html')}`,
12 | WS_URI: 'wss://spacedrop.xyz',
13 | DB_PATH: path.join(app.getPath('userData'), 'spacedrop.db'),
14 | DROPS_DIR: path.join(app.getPath('userData'), 'drops'),
15 | MAIN_WIN_WIDTH: 875,
16 | MAIN_WIN_HEIGHT: 500
17 | }
18 |
19 | const CONFIG_DEV = {
20 | ...CONFIG,
21 | LOAD_URL: 'http://localhost:9000',
22 | WS_URI: 'ws://localhost:7000',
23 | // MAIN_WIN_WIDTH: 875,
24 | MAIN_WIN_WIDTH: 420,
25 | MAIN_WIN_HEIGHT: 500
26 | }
27 |
28 | module.exports = is.development ? CONFIG_DEV : CONFIG
29 |
--------------------------------------------------------------------------------
/static/scss/components/Button.scss:
--------------------------------------------------------------------------------
1 | @import '../themes';
2 |
3 | @include themify() {
4 | button {
5 | display: inline-block;
6 | border: none;
7 | border-radius: 0.25rem;
8 | padding: 5px 20px;
9 | outline: none;
10 | margin: 0;
11 | text-decoration: none;
12 | background: themed('primaryColor');
13 | color: themed('primaryForegroundColor');
14 | font-size: 15px;
15 | font-weight: bold;
16 | text-align: center;
17 | transition: filter 250ms ease-in-out;
18 | }
19 |
20 | button:active, button:hover, button:focus {
21 | background: themed('primaryColor') !important;
22 | border: none;
23 | box-shadow: none !important;
24 | }
25 |
26 | button:active, button:focus {
27 | opacity: 0.6;
28 | }
29 |
30 | button:disabled {
31 | background: themed('primaryColor');
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Ignore build folder
2 | app/
3 |
4 | # Logs
5 | logs
6 | *.log
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 |
13 | # Directory for instrumented libs generated by jscoverage/JSCover
14 | lib-cov
15 |
16 | # Coverage directory used by tools like istanbul
17 | coverage
18 |
19 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
20 | .grunt
21 |
22 | # node-waf configuration
23 | .lock-wscript
24 |
25 | # Compiled binary addons (http://nodejs.org/api/addons.html)
26 | build/Release
27 | .eslintcache
28 |
29 | # Dependency directory
30 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
31 | node_modules
32 |
33 | # OSX
34 | .DS_Store
35 |
36 | # App packaged
37 | release
38 | dist
39 | dll
40 | *.map
41 |
42 | npm-debug.log.*
43 | *.css.d.ts
44 | *.sass.d.ts
45 | *.scss.d.ts
46 |
47 |
48 |
49 | e2ee
--------------------------------------------------------------------------------
/static/scss/themes/vibrancy/_light.scss:
--------------------------------------------------------------------------------
1 | $lightTheme: (
2 | primaryColor: #0084ff,
3 |
4 | primaryBackgroundColor: transparent,
5 | contentBackgroundColor: #fffe,
6 | secondaryBackgroundColor: #fff,
7 | transparentBackgroundColor: #fffe,
8 | overlayBackgroundColor: #eeea,
9 |
10 | primaryForegroundColor: #000000,
11 | primaryColorForegroundColor: #fff,
12 | secondaryForegroundColor: #555,
13 | mutedForegroundColor: #a5a5a5,
14 |
15 | highlightBorderColor: #eee,
16 |
17 | sidebarBackgroundColor: transparent,
18 | sidebarSelectedItemBackgroundColor: #fffe,
19 |
20 | modalBackgroundColor: #fffe,
21 | modalOverlayColor: #1119,
22 | modalShadowColor: #0003,
23 |
24 | toolbarButtonColor: #0084ff,
25 | buttonBackgroundColor: #0084ff,
26 | buttonForegroundColor: #fff,
27 |
28 | dangerColor: #dc3545,
29 | dangerForegroundColor: #fff,
30 | onlineColor: #01A452,
31 | );
--------------------------------------------------------------------------------
/scripts/sass-loader.js:
--------------------------------------------------------------------------------
1 | const path = require('path'),
2 | glob = require('glob')
3 |
4 | module.exports = {
5 | loader: 'sass-loader',
6 | options: {
7 | sassOptions: loaderContext => {
8 | const { resourcePath, rootContext } = loaderContext
9 | const dir = path.dirname(resourcePath)
10 | return {
11 | indentWidth: 4,
12 | webpackImporter: false,
13 | includePaths: ['static/scss'],
14 | importer: function (url, prev, done) {
15 | // Add support for sass @import globs
16 | const absUrl = path.join(dir, url)
17 | globs = absUrl.includes('*') && glob.sync(absUrl)
18 |
19 | if (globs) {
20 | const contents = globs
21 | .map(p => `@import '${p.replace(dir + path.sep, '')}';`)
22 | .join('\n')
23 |
24 | return { contents }
25 | }
26 |
27 | return null
28 | }
29 | }
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
1 | const spawn = require('child_process').spawn
2 | const { watch } = require('gulp')
3 | var p
4 |
5 | const SRC_FILES = [
6 | './src/main/*.js',
7 | './src/main/windows/*.js',
8 | './src/main/lib/*.js'
9 | ]
10 | const ELECTRON = __dirname + '/node_modules/.bin/electron'
11 | const DEBUG = false
12 | let args = ['.']
13 | // Start the electron process.
14 | async function electron () {
15 | // kill previous spawned process
16 | if (p) {
17 | p.kill()
18 | }
19 |
20 | if (DEBUG) args.unshift('--inspect=5858')
21 | // `spawn` a child `gulp` process linked to the parent `stdio`
22 | p = await spawn(ELECTRON, args, {
23 | stdio: 'inherit',
24 | env: {
25 | ...process.env,
26 | DEBUG: 'simple-peer'
27 | }
28 | })
29 | }
30 |
31 | exports.default = () => {
32 | watch(
33 | SRC_FILES,
34 | {
35 | queue: false,
36 | ignoreInitial: false // Execute task on startup
37 | },
38 | electron
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/static/scss/components/Notification.scss:
--------------------------------------------------------------------------------
1 | @import '../themes';
2 |
3 | @include themify() {
4 | .notification-container {
5 | position: absolute;
6 | z-index: 4200;
7 | bottom: 0;
8 | width: 100%;
9 | }
10 |
11 | .notification {
12 | margin: 0 auto;
13 | margin-bottom: 20px;
14 | width: 300px;
15 | background: themed('secondaryBackgroundColor');
16 | backdrop-filter: blur(20px);
17 | text-align: left;
18 | font-size: 14px;
19 | overflow: hidden;
20 | border-radius: 0.25rem;
21 | padding: 10px 20px;
22 | font-weight: bold;
23 | display: flex;
24 | align-items: center;
25 | justify-content: space-between;
26 | justify-items: center;
27 | @include shadow;
28 | }
29 |
30 | .notification > .dismiss:hover {
31 | opacity: 0.8;
32 | }
33 |
34 | .notification > .dismiss {
35 | cursor: pointer;
36 | }
37 |
38 | .error.notification {
39 | background: themed('dangerColor');
40 | color: themed('primaryForegroundColor');
41 |
42 | > .dismiss {
43 | color: themed('primaryForegroundColor');
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/static/scss/components/Modal.scss:
--------------------------------------------------------------------------------
1 | @import '../themes';
2 |
3 | @include themify() {
4 | .modal-dialog {
5 | margin: $side-margin;
6 | }
7 |
8 | .modal-content {
9 | margin: auto;
10 | border-radius: 0.25rem;
11 | padding: 5px;
12 | background: themed('primaryColor');
13 |
14 | button,
15 | button:active,
16 | button:hover,
17 | button:focus {
18 | background: themed('primaryBackgroundColor') !important;
19 | }
20 |
21 | button.close,
22 | button.close:active,
23 | button.close:hover,
24 | button.close:focus {
25 | background: transparent !important;
26 | }
27 |
28 | label {
29 | font-size: 0.9rem;
30 | text-transform: uppercase;
31 | }
32 |
33 | input:disabled {
34 | filter: brightness(60%);
35 | }
36 | }
37 |
38 | .modal-header {
39 | border-bottom: none;
40 | }
41 |
42 | .modal-body input {
43 | background: themed('secondaryBackgroundColor');
44 | color: themed('primaryForegroundColor');
45 | border: none;
46 | }
47 |
48 | // .modal-body textarea::placeholder,
49 | // .modal-body input::placeholder {
50 | // color: themed('primaryForegroundColor');
51 | // }
52 |
53 | .modal-footer {
54 | border-top: none;
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/static/scss/index.scss:
--------------------------------------------------------------------------------
1 | @import '~bootstrap/scss/bootstrap';
2 |
3 | @import 'components/App.scss';
4 | @import 'components/Button.scss';
5 | @import 'components/Modal.scss';
6 | @import 'components/Notification.scss';
7 | @import 'ionicons';
8 | @import 'themes';
9 |
10 | @include themify() {
11 | margin: 0;
12 | padding: 0;
13 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
14 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
15 | sans-serif;
16 | -webkit-font-smoothing: antialiased;
17 | background-color: themed('primaryBackgroundColor');
18 |
19 | code {
20 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
21 | monospace;
22 | }
23 |
24 | * {
25 | -webkit-appearance: none;
26 | -webkit-tap-highlight-color: transparent;
27 | -webkit-touch-callout: none;
28 | -webkit-user-select: none;
29 | user-select: none;
30 | color: themed('primaryForegroundColor');
31 | }
32 |
33 | *:focus {
34 | outline: none;
35 | }
36 |
37 | *::-webkit-scrollbar {
38 | width: 0 !important;
39 | }
40 |
41 | a, .clickable {
42 | cursor: pointer;
43 | }
44 |
45 | .spacedrop-logo {
46 | fill: themed('primaryForegroundColor');
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/renderer/lib/util.js:
--------------------------------------------------------------------------------
1 | // Makes PGP error messages user friendly
2 | export function friendlyError (error) {
3 | return error.message.slice(
4 | error.message.lastIndexOf('Error'),
5 | error.message.length
6 | )
7 | }
8 | // Makes a deep clone of an object
9 | export function clone (obj) {
10 | return JSON.parse(JSON.stringify(obj))
11 | }
12 |
13 | // Generates a hash code for a string
14 | export function hashCode (string) {
15 | for (var i = 0, h = 0; i < string.length; i++)
16 | h = (Math.imul(31, h) + string.charCodeAt(i)) | 0
17 | return h
18 | }
19 |
20 | // Returns the initials of a name
21 | export function initialsise (name) {
22 | let iname = name.toUpperCase().split(' ')
23 | let initials = name[0]
24 | if (iname.length > 1) {
25 | initials += iname[iname.length - 1][0]
26 | }
27 | return initials
28 | }
29 |
30 | // Turns an object into a react clasaName compatible list
31 | export function classList (classes) {
32 | if (!Array.isArray(classes)) {
33 | // Turn into an array if not already
34 | classes = Object.entries(classes)
35 | .filter(entry => entry[1])
36 | .map(entry => entry[0])
37 | }
38 | return classes.join(' ')
39 | }
40 |
41 | // Checks if an object is empty
42 | export function isEmpty (obj) {
43 | return Object.keys(obj).length === 0 && obj.constructor === Object
44 | }
45 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const HtmlWebPackPlugin = require('html-webpack-plugin'),
2 | MiniCssExtractPlugin = require('mini-css-extract-plugin'),
3 | // sassLoader = require('./scripts/sass-loader.js'),
4 | path = require('path')
5 |
6 | module.exports = {
7 | entry: path.resolve(__dirname, 'src/renderer/index.js'),
8 | target: 'electron-renderer',
9 | output: {
10 | publicPath: '',
11 | path: path.resolve(__dirname, 'app'),
12 | filename: 'app.js'
13 | },
14 | devtool: 'eval-cheap-source-map',
15 | module: {
16 | rules: [
17 | {
18 | test: /\.(js|jsx)$/,
19 | exclude: /node_modules/,
20 | use: ['babel-loader']
21 | },
22 | {
23 | test: /\.s[ac]ss$/i,
24 | use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader']
25 | },
26 | {
27 | test: /\.html$/,
28 | use: {
29 | loader: 'html-loader'
30 | }
31 | },
32 | {
33 | test: /\.(woff|woff2|eot|ttf|otf)$/,
34 | use: {
35 | loader: 'file-loader'
36 | }
37 | }
38 | ]
39 | },
40 | plugins: [
41 | new MiniCssExtractPlugin({
42 | filename: 'app.css'
43 | }),
44 | new HtmlWebPackPlugin({
45 | template: './static/index.html',
46 | filename: './index.html'
47 | })
48 | ],
49 | devServer: {
50 | open: false,
51 | port: 9000
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/renderer/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import App from './components/App'
4 | import { NotificationProvider } from './lib/notifications'
5 | import { darkMode } from 'electron-util'
6 | // Show dark mode if it is already enabled in the operating system
7 | let isDarkMode = darkMode.isEnabled
8 |
9 | // Sets the UI theme appropriately
10 | function setTheme () {
11 | if (isDarkMode) {
12 | // Use dark theme
13 | document.body.classList.remove('theme-light')
14 | document.body.classList.add('theme-dark')
15 | } else {
16 | // Use light theme
17 | document.body.classList.remove('theme-dark')
18 | document.body.classList.add('theme-light')
19 | }
20 | }
21 |
22 | // Switches the UI theme between light and dark
23 | function switchTheme () {
24 | isDarkMode = !isDarkMode
25 | setTheme()
26 | }
27 |
28 | // Add a shortcut to switch themes
29 | window.onkeyup = function (e) {
30 | // ctrl + t
31 | if (e.ctrlKey && e.key === 't') {
32 | switchTheme()
33 | }
34 | }
35 |
36 | // Blur after click
37 | document.addEventListener('click', function (e) {
38 | if (document.activeElement.toString() == '[object HTMLButtonElement]') {
39 | document.activeElement.blur()
40 | }
41 | })
42 |
43 | // Set theme accordingly
44 | setTheme()
45 |
46 | // Render the entire UI
47 | ReactDOM.render(
48 |
49 |
50 | ,
51 | document.getElementById('app')
52 | )
53 |
--------------------------------------------------------------------------------
/src/renderer/components/Toolbar.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Row, Col, Button } from 'react-bootstrap'
3 | import { classList } from '../lib/util'
4 |
5 | export default function Toolbar (props) {
6 | return (
7 |
8 |
9 | SpaceDrop
10 |
11 |
12 |
13 |
19 |
20 |
21 |
26 |
27 |
28 |
29 |
30 | {/*
31 |
32 |
33 |
34 |
35 | */}
36 |
42 |
43 |
49 |
50 |
51 |
52 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/src/renderer/components/WormholeModal.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import { Modal, Form, Button } from 'react-bootstrap'
3 |
4 | export default function WormholeModal (props) {
5 | const { state, type, disabledId, onSubmit, ...rprops } = props
6 | const initName = state ? state.name : ''
7 | const initID = state ? state.id : ''
8 | console.log(initName, initID)
9 | const [name, setName] = useState(initName)
10 | const [id, setID] = useState(initID)
11 | // Clear on dismissal
12 | useEffect(() => {
13 | setName(initName)
14 | setID(initID)
15 | }, [props.show])
16 |
17 | return (
18 |
24 |
25 |
26 | {type} a wormhole
27 |
28 |
29 |
30 |
31 | Name
32 | setName(event.target.value)}
37 | />
38 |
39 |
40 | Spacedrop ID
41 | setID(event.target.value)}
47 | />
48 |
49 |
50 |
51 | onSubmit({ name, id })}>{type}
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/src/main/lib/Server.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /**
3 | * Signal class
4 | * Sends and receives signals from peers and signal events from server
5 | */
6 |
7 | const WebSocket = require('ws'),
8 | EventEmitter = require('events'),
9 | { encode } = require('querystring'),
10 | { WS_URI } = require('../../config'),
11 | CONNECTED_STATE = 1
12 |
13 | module.exports = class Server extends EventEmitter {
14 | constructor () {
15 | // Ensure singleton
16 | if (!!Server.instance) {
17 | return Server.instance
18 | }
19 |
20 | // Call EventEmitter constructor
21 | super()
22 |
23 | this._id = null
24 | this._ws = null
25 |
26 | // Bindings
27 | this.connect = this.connect.bind(this)
28 | this._emit = this._emit.bind(this)
29 | this.send = this.send.bind(this)
30 |
31 | Server.instance = this
32 | }
33 |
34 | isConnected () {
35 | return this._ws.readyState === CONNECTED_STATE
36 | }
37 |
38 | setId(userId) {
39 | this._id = userId
40 | }
41 |
42 | // Connects to signal server
43 | connect (authRequest) {
44 | // Build ws uri with authentication querystring data
45 | const wsAuthURI = WS_URI + '?' + encode(authRequest)
46 |
47 | this._ws = new WebSocket(wsAuthURI)
48 | // Add event listeners
49 | this._ws.on('message', this._emit)
50 | this._ws.on('open', () => {
51 | this.emit('connect')
52 | })
53 | this._ws.on('close', () => {
54 | this.emit('disconnect')
55 | })
56 | this._ws.on('error', err => this.emit('error', err))
57 | }
58 |
59 | // Sends signal to a peer (via server)
60 | send (type, extras = {}, cb) {
61 | const msg = JSON.stringify({ type, senderId: this._id, ...extras })
62 | if (!cb) {
63 | return new Promise(resolve => this._ws.send(msg, null, resolve))
64 | }
65 | this._ws.send(msg, null, cb)
66 | }
67 |
68 | // Emits a received signal event
69 | _emit (msg) {
70 | const { event, data } = JSON.parse(msg)
71 | console.log(`Signal event: ${event}`)
72 | this.emit(event, data)
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/main/windows/main.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const main = (module.exports = {
4 | init,
5 | secondInstance,
6 | activate,
7 | send,
8 | win: null
9 | })
10 | const { app, BrowserWindow } = require('electron'),
11 | { waitUntil } = require('../lib/util'),
12 | { LOAD_URL, MAIN_WIN_WIDTH, MAIN_WIN_HEIGHT } = require('../../config')
13 |
14 | // Create and initializes a new main window
15 | async function init () {
16 | let isWindowReady = false
17 | const win = (main.win = new BrowserWindow({
18 | title: app.name,
19 | show: false,
20 | width: MAIN_WIN_WIDTH,
21 | height: MAIN_WIN_HEIGHT,
22 | minWidth: 700,
23 | minHeight: 400,
24 | // secondaryBackgroundColor: '#00FFFFFF',
25 | frame: false,
26 | transparent: true,
27 | // vibrancy: 'appearance-based',
28 | titleBarStyle: 'hiddenInset',
29 | webPreferences: {
30 | nodeIntegration: true,
31 | contextIsolation: false,
32 | enableRemoteModule: true
33 | // webSecurity: false // To allow local image loading
34 | }
35 | }))
36 |
37 | win.on('ready-to-show', () => {
38 | isWindowReady = true
39 | win.show()
40 | })
41 |
42 | win.on('closed', () => {
43 | // Dereference the window
44 | // For multiple windows store them in an array
45 | main.win = undefined
46 | })
47 |
48 | await win.loadURL(LOAD_URL)
49 | // Wait until window has loaded
50 | await waitUntil(() => isWindowReady, 6000)
51 | return win
52 | }
53 |
54 | // Handles second instance of window
55 | function secondInstance () {
56 | if (main.win) {
57 | // Show existing window if it already exists
58 | if (main.win.isMinimized()) {
59 | main.win.restore()
60 | }
61 |
62 | main.win.show()
63 | }
64 | }
65 |
66 | // Activates the window
67 | function activate () {
68 | if (!main.win) {
69 | // Create the main window if it doesn't exist already
70 | main.win = init()
71 | }
72 | }
73 |
74 | // Sends an IPC message to the renderer (the window)
75 | function send (channel, ...args) {
76 | if (main.win) {
77 | main.win.webContents.send(channel, ...args)
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/main/lib/util.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | module.exports = {
4 | waitUntil,
5 | parseAddress,
6 | isEmpty,
7 | chunk,
8 | hexToUint8,
9 | strToUint8,
10 | Uint8ToHex,
11 | isString
12 | }
13 |
14 | // Wait until a condition is true
15 | function waitUntil (conditionFn, timeout, pollInterval = 30) {
16 | const start = Date.now()
17 | return new Promise((resolve, reject) => {
18 | ;(function wait () {
19 | if (conditionFn()) return resolve()
20 | else if (timeout && Date.now() - start >= timeout)
21 | return reject(new Error(`Timeout ${timeout} for waitUntil exceeded`))
22 | else setTimeout(wait, pollInterval)
23 | })()
24 | })
25 | }
26 |
27 | // Parses name/email address of format '[name] <[email]>'
28 | function parseAddress (address) {
29 | // Check if unknown
30 | address = address && address.length ? address[0] : 'Unknown'
31 | // Check if it has an email as well (follows the format)
32 | if (!address.includes('<')) {
33 | return { name: address }
34 | }
35 |
36 | let [name, email] = address.split('<').map(n => n.trim().replace(/>/g, ''))
37 | return { name, email }
38 | }
39 |
40 | // Checks if an object is empty
41 | function isEmpty (obj) {
42 | return Object.keys(obj).length === 0 && obj.constructor === Object
43 | }
44 |
45 | // Splits a buffer into chunks of a given size
46 | function chunk (buffer, chunkSize) {
47 | if (!Buffer.isBuffer(buffer)) throw new Error('Buffer is required')
48 |
49 | let result = [],
50 | i = 0,
51 | len = buffer.length
52 |
53 | while (i < len) {
54 | // If it does not equally divide then set last to whatever remains
55 | result.push(buffer.slice(i, Math.min((i += chunkSize), len)))
56 | }
57 |
58 | return result
59 | }
60 |
61 | // Converts a hex string into a Uint8Array
62 | function hexToUint8 (hex) {
63 | return Uint8Array.from(Buffer.from(hex, 'hex'))
64 | }
65 |
66 | // Converts a string into a Uint8Array
67 | function strToUint8 (hex) {
68 | return Uint8Array.from(Buffer.from(hex))
69 | }
70 |
71 | function Uint8ToHex (uint8) {
72 | return Buffer.from(uint8).toString('hex')
73 | }
74 |
75 | // Checks if the given object is a string
76 | function isString (obj) {
77 | return typeof obj === 'string' || obj instanceof String
78 | }
79 |
--------------------------------------------------------------------------------
/src/renderer/lib/notifications.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { hashCode } from './util'
3 | const Ctx = React.createContext()
4 |
5 | /**
6 | * Components
7 | *****************************/
8 |
9 | const NotificationContainer = props => (
10 |
11 | )
12 | const Notification = ({ children, type, onDismiss, dismissable }) => (
13 |
17 | {children}
18 | {dismissable && }
19 |
20 | )
21 |
22 | /**
23 | * Provider
24 | *****************************/
25 |
26 | export function NotificationProvider ({ children }) {
27 | const [notifications, setNotifications] = React.useState([])
28 |
29 | // Dismiss a notification
30 | const dismiss = id => {
31 | const newNotifications = notifications.filter(n => n.id !== id)
32 | setNotifications(newNotifications)
33 | }
34 |
35 | // Dismiss
36 | const onDismiss = id => () => dismiss(id)
37 |
38 | // Clears all notifications
39 | const clear = () => {
40 | setNotifications([])
41 | }
42 |
43 | // Show a notification
44 | const show = (content, type, dismissable = true, duration) => {
45 | type = type || ''
46 | const id = hashCode(content)
47 | const notification = { id, type, content, dismissable }
48 | setNotifications(notifications => {
49 | const alreadyShowing = notifications.find(n => n.id == id)
50 | // Do not show if already showing
51 | return alreadyShowing ? notifications : [notification, ...notifications]
52 | })
53 |
54 | // Dismiss after duration if specified
55 | if (duration) setTimeout(onDismiss(id), duration)
56 | }
57 |
58 | return (
59 |
60 | {children}
61 |
62 | {notifications.map(({ content, id, ...rest }) => (
63 |
64 | {content}
65 |
66 | ))}
67 |
68 |
69 | )
70 | }
71 |
72 | /**
73 | * Consumer
74 | *****************************/
75 | export const useNotifications = isComponent =>
76 | isComponent ? Ctx : React.useContext(Ctx)
77 |
--------------------------------------------------------------------------------
/src/renderer/components/Wormholes.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react'
2 | import { Col, Row, Nav, Tab } from 'react-bootstrap'
3 | import { classList } from '../lib/util'
4 | import Drop from './Drop'
5 |
6 | export default function Wormholes (props) {
7 | const endRef = useRef(null)
8 |
9 | // Scrolls to the bottom
10 | function scrollToBottom () {
11 | endRef.current.scrollIntoView({ block: 'end', behavior: 'smooth' })
12 | }
13 |
14 | // Scroll to the bottom
15 | // useEffect(scrollToBottom)
16 |
17 | // useEffect(() => )
18 |
19 | const { wormholes } = props
20 |
21 | const tabs = wormholes.map(hole => (
22 |
26 |
27 | {hole.name}
28 |
29 |
30 |
31 | ))
32 |
33 | const tabContent = wormholes.map(hole => {
34 | const drops = Object.values(hole.drops)
35 | return (
36 |
37 | {drops.length ? (
38 | drops.map(drop => (
39 | props.onResumeClick(hole.id, drop.id)}
42 | onPauseClick={() => props.onPauseClick(hole.id, drop.id)}
43 | onDeleteClick={() => props.onDeleteClick(hole.id, drop.id)}
44 | {...drop}
45 | />
46 | ))
47 | ) : (
48 |
49 |
50 |
51 | Create a drop to send a file
52 |
53 | )}
54 |
55 | )
56 | })
57 |
58 | const tabContainer = (
59 |
60 |
61 |
62 | {tabs}
63 |
64 |
65 | {tabContent}
66 |
67 |
68 |
69 | )
70 |
71 | return (
72 |
73 | {wormholes.length ? (
74 | tabContainer
75 | ) : (
76 |
77 |
78 |
79 | Create a new wormhole to send files
80 |
81 | )}
82 |
83 | )
84 | }
85 |
--------------------------------------------------------------------------------
/src/main/lib/Queue.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /**
3 | * Queue class
4 | * (Message) task queue
5 | */
6 | const EventEmitter = require('events')
7 |
8 | module.exports = class Queue extends EventEmitter {
9 | constructor (timeout) {
10 | // Call EventEmitter constructor
11 | super()
12 |
13 | this._queue = []
14 | this._pendingCount = 0
15 | this._processing = -1
16 | this._timeout = timeout
17 | // this._responseTimeout = responseTimeout
18 | this._idle = true
19 | }
20 |
21 | // Adds a task
22 | // TODO: Add timeout interval for task hangup
23 | add (fn, id) {
24 | let timer
25 | let removeWhenDone = true
26 |
27 | if (id) {
28 | // If an id not passed then remove when done (not tracked by caller)
29 | removeWhenDone = false
30 | }
31 |
32 | const run = async () => {
33 | console.log('Running task', id)
34 |
35 | try {
36 | // const promise = fn.apply(null, args)
37 | await fn()
38 | // Set timeout for response
39 | // timer = setTimeout(() => {
40 | // throw new Error('Timeout')
41 | // }, this._timeout)
42 | console.log('Finished task', id)
43 | } catch (error) {
44 | console.error(error)
45 | this._error(id, error)
46 | }
47 |
48 | // Trigger next when done
49 | this._next()
50 | }
51 |
52 | this._queue.push({ id, run, timer, removeWhenDone })
53 | this._pendingCount++
54 |
55 | if (this._idle) {
56 | // Start processing
57 | console.log('Idle, start', id)
58 | this._next()
59 | }
60 | }
61 |
62 | // Remove task by id
63 | remove (id) {
64 | const index = this._queue.findIndex(task => task.id === id)
65 | if (index < 0) return false
66 | return this._remove(index)
67 | }
68 |
69 | // Processes next task in queue
70 | async _next () {
71 | if (!this._pendingCount) return (this._idle = true) // Finished processing
72 | console.log(this._queue, this._processing, this._pendingCount)
73 | this._idle = false
74 | this._pendingCount--
75 | if (this._processing > 0) {
76 | const { removeWhenDone } = this._queue[this._processing]
77 | if (removeWhenDone) this._remove(this._processing)
78 | }
79 | // Run task
80 | this._queue[++this._processing].run()
81 | }
82 |
83 | // Removes task at given position in queue
84 | _remove (index) {
85 | console.log('Removed task at index', index)
86 | // clearTimeout(this._queue[index].timer)
87 | return delete this._queue[index]
88 | }
89 |
90 | // When a task fails
91 | _error (id, error) {
92 | this.emit('error', id, error)
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/src/main/lib/Wormholes.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { DROP_STATUS } = require('../../consts'),
4 | { isEmpty } = require('./util'),
5 | DB_KEY = 'wormholes'
6 |
7 | module.exports = class Wormholes {
8 | constructor (store) {
9 | this._store = store
10 | this._wormholes = this._store.get(DB_KEY, {})
11 | }
12 |
13 | // Wormhole ops
14 |
15 | getAll () {
16 | return this._wormholes
17 | }
18 |
19 | getList () {
20 | return Object.values(this._wormholes)
21 | }
22 |
23 | getActive () {
24 | const arr = Object.values(this._wormholes)
25 | if (!arr.length) return ''
26 | const online = arr.find(w => w.online)
27 | return (online && online.id) || arr[0].id
28 | }
29 |
30 | get (id) {
31 | return this._wormholes[id]
32 | }
33 |
34 | has (id) {
35 | return !!this._wormholes[id]
36 | }
37 |
38 | add (id, name) {
39 | this._wormholes[id] = {
40 | id,
41 | name,
42 | drops: {}
43 | }
44 | this._saveAll()
45 | }
46 |
47 | update (id, state) {
48 | Object.assign(this._wormholes[id], state)
49 | this._saveAll()
50 | }
51 |
52 | delete (id) {
53 | delete this._wormholes[id]
54 | this._saveAll()
55 | }
56 |
57 | getDropList (id) {
58 | return Object.entries(this._wormholes[id].drops)
59 | }
60 |
61 | // Gets a drop
62 | getDrop (id, dropId) {
63 | return this._wormholes[id].drops[dropId]
64 | }
65 |
66 | // Adds a drop
67 | addDrop (id, dropId, drop) {
68 | drop.status = DROP_STATUS.PENDING
69 | this._wormholes[id].drops[dropId] = drop
70 | this._saveAll()
71 | }
72 |
73 | // Deletes a drop
74 | deleteDrop (id, dropId) {
75 | delete this._wormholes[id].drops[dropId]
76 | this._saveAll()
77 | }
78 |
79 | // Find the wormhole id of a drop with its id
80 | findIdByDropId (dropId) {
81 | return Object.entries(this._wormholes).find(w =>
82 | Object.entries(w.drops).includes(dropId)
83 | )
84 | }
85 |
86 | // Updates a drop
87 | updateDrop (id, dropId, updates) {
88 | if (!this._wormholes[id] || isEmpty(updates)) return
89 | if (updates.eta === 0) updates.status = DROP_STATUS.DONE
90 | const drop = Object.assign(this._wormholes[id].drops[dropId], updates)
91 | this._saveAll()
92 | return drop
93 | }
94 |
95 | // Pause all pending drops
96 | pauseDrops () {
97 | for (var w in this._wormholes) {
98 | if (!this._wormholes.hasOwnProperty(w)) continue
99 | for (var d in this._wormholes[w].drops) {
100 | if (
101 | !this._wormholes[w].drops.hasOwnProperty(d) ||
102 | this._wormholes[w].drops[d].status !== DROP_STATUS.PENDING
103 | )
104 | continue
105 |
106 | this._wormholes[w].drops[d].status = DROP_STATUS.PAUSED
107 | }
108 | }
109 | console.log('Pausing drops', JSON.stringify(this._wormholes))
110 | this._saveAll()
111 | }
112 |
113 | clearDrops () {
114 | Object.entries(this._wormholes).map(k => (this._wormholes[k].drops = {}))
115 | }
116 |
117 | // Saves wormhole to the store
118 | _saveAll () {
119 | this._store.set(DB_KEY, this._wormholes)
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/static/img/spacedrop-small.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Shape Copy
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/static/img/spacedrop.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Shape
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/main/menu.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | const path = require('path')
3 | const { app, Menu, shell } = require('electron')
4 | const { DROPS_DIR } = require('../config')
5 | const {
6 | is,
7 | appMenu,
8 | aboutMenuItem,
9 | openUrlMenuItem,
10 | openNewGitHubIssue,
11 | debugInfo
12 | } = require('electron-util')
13 |
14 | const helpSubmenu = [
15 | openUrlMenuItem({
16 | label: 'Website',
17 | url: 'https://github.com/hr/spacedrop'
18 | }),
19 | openUrlMenuItem({
20 | label: 'Source Code',
21 | url: 'https://github.com/hr/spacedrop'
22 | }),
23 | {
24 | label: 'Report an Issue…',
25 | click () {
26 | const body = `
27 |
28 |
29 |
30 | ---
31 |
32 | ${debugInfo()}`
33 |
34 | openNewGitHubIssue({
35 | user: 'hr',
36 | repo: 'spacedrop',
37 | body
38 | })
39 | }
40 | }
41 | ]
42 |
43 | if (!is.macos) {
44 | helpSubmenu.push(
45 | {
46 | type: 'separator'
47 | },
48 | aboutMenuItem({
49 | icon: path.join(__dirname, 'build', 'icon.png'),
50 | text: 'Created by Habib Rehman'
51 | })
52 | )
53 | }
54 |
55 | const debugSubmenu = [
56 | {
57 | label: 'Show App Data',
58 | click () {
59 | shell.openItem(app.getPath('userData'))
60 | }
61 | },
62 | {
63 | type: 'separator'
64 | },
65 | {
66 | label: 'Delete Drops',
67 | click () {
68 | app.emit('delete-drops')
69 | shell.moveItemToTrash(DROPS_DIR)
70 | }
71 | },
72 | {
73 | label: 'Delete App Data',
74 | click () {
75 | shell.moveItemToTrash(app.getPath('userData'))
76 | app.relaunch()
77 | app.quit()
78 | }
79 | }
80 | ]
81 |
82 | const defaultTemplate = [
83 | {
84 | role: 'fileMenu',
85 | submenu: [
86 | {
87 | label: 'New Wormhole',
88 | click () {
89 | app.emit('create-wormhole')
90 | }
91 | },
92 | {
93 | label: 'Send File...',
94 | click () {
95 | app.emit('render-event', 'create-drop')
96 | }
97 | },
98 | {
99 | label: 'Copy Spacedrop ID',
100 | click () {
101 | app.emit('render-event', 'copy-id')
102 | }
103 | },
104 | {
105 | type: 'separator'
106 | },
107 | {
108 | id: 'connect-mothership',
109 | label: 'Connect Mothership',
110 | click () {
111 | app.emit('connect-mothership')
112 | }
113 | },
114 | {
115 | id: 'open-wormholes',
116 | label: 'Open Wormholes',
117 | click () {
118 | app.emit('open-wormholes')
119 | }
120 | },
121 | {
122 | type: 'separator'
123 | },
124 | {
125 | role: 'close'
126 | }
127 | ]
128 | },
129 | {
130 | role: 'editMenu'
131 | },
132 | {
133 | role: 'viewMenu'
134 | },
135 | {
136 | role: 'windowMenu'
137 | },
138 | {
139 | role: 'help',
140 | submenu: helpSubmenu
141 | }
142 | ]
143 |
144 | const macosTemplate = [appMenu(), ...defaultTemplate]
145 |
146 | const template = process.platform === 'darwin' ? macosTemplate : defaultTemplate
147 |
148 | if (is.development) {
149 | template.push({
150 | label: 'Debug',
151 | submenu: debugSubmenu
152 | })
153 | }
154 |
155 | module.exports = Menu.buildFromTemplate(template)
156 |
--------------------------------------------------------------------------------
/static/scss/components/App.scss:
--------------------------------------------------------------------------------
1 | @import '../themes';
2 |
3 | @include themify() {
4 | .container {
5 | overflow: hidden;
6 | padding: 0;
7 | }
8 |
9 | .toolbar {
10 | padding: 10px $side-margin $side-margin $side-margin;
11 |
12 | .title {
13 | text-align: center;
14 | margin-bottom: 14px;
15 | font-size: 13px;
16 | vertical-align: text-top;
17 | }
18 |
19 | .profile {
20 | position: relative;
21 |
22 | .status {
23 | position: absolute;
24 | vertical-align: middle;
25 | margin-left: 8px;
26 | top: -2px;
27 | right: -2px;
28 | width: 8px;
29 | height: 8px;
30 | border-radius: 100%;
31 | background-color: themed('dangerColor');
32 | }
33 |
34 | .status.online {
35 | background-color: themed('onlineColor');
36 | }
37 | }
38 |
39 | .actions {
40 | padding: 0px;
41 |
42 | i {
43 | font-size: 16px;
44 | }
45 |
46 | .icon {
47 | height: 16px;
48 | }
49 | }
50 |
51 | .actions > .col:last-child {
52 | text-align: right;
53 | }
54 |
55 | button {
56 | margin: 0px 3px;
57 | }
58 |
59 | button:first-of-type {
60 | margin-left: 0;
61 | }
62 |
63 | button:last-of-type {
64 | margin-right: 0;
65 | }
66 | }
67 |
68 | .wormholes {
69 | .nav {
70 | flex-wrap: unset;
71 | overflow-x: auto !important;
72 | white-space: nowrap;
73 | margin: $side-margin;
74 | }
75 |
76 | .nav-link {
77 | &:not(.active):hover {
78 | opacity: 0.6;
79 | color: themed('primaryForegroundColor');
80 | }
81 |
82 | &.active {
83 | background-color: themed('primaryColor');
84 | color: themed('primaryForegroundColor');
85 | font-weight: bold;
86 | }
87 | }
88 |
89 | .tab-content {
90 | overflow-y: auto;
91 | height: 69vh;
92 | }
93 |
94 | .noholes {
95 | margin: auto;
96 | text-align: center;
97 | opacity: 0.6;
98 | margin: 5rem;
99 |
100 | i {
101 | font-size: 7rem;
102 | }
103 | }
104 |
105 | .online {
106 | display: inline-block;
107 | vertical-align: middle;
108 | margin-left: 8px;
109 | width: 6px;
110 | height: 6px;
111 | border-radius: 100%;
112 | background-color: themed('onlineColor');
113 | }
114 |
115 | .drop {
116 | margin: $side-margin;
117 | padding-top: $side-margin;
118 | padding-bottom: $side-margin;
119 | border-radius: 0.25rem;
120 | background: themed('primaryColor');
121 |
122 | .progress {
123 | background: themed('secondaryBackgroundColor');
124 | }
125 |
126 | .name-row {
127 | .name {
128 | font-weight: bold;
129 | font-size: 18px;
130 | white-space: nowrap;
131 | overflow: hidden;
132 | text-overflow: ellipsis;
133 | flex-grow: 3;
134 | }
135 |
136 | i {
137 | margin-left: 15px;
138 | cursor: pointer;
139 | }
140 |
141 | i:active,
142 | i:focus {
143 | opacity: 0.6;
144 | }
145 | }
146 |
147 | .status {
148 | margin-top: 4px;
149 | margin-bottom: 8px;
150 | font-size: 13px;
151 | font-weight: 300;
152 | }
153 | }
154 |
155 | .drop:first-child {
156 | margin-top: 0px;
157 | }
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SpaceDrop
6 |
7 |
8 |
9 |
10 | A decentralized end-to-end encrypted file sharing app.
11 |
12 |
13 |
15 |
16 |
17 |
18 | > Spacedrop > Airdrop
19 |
20 | A peer-to-peer end-to-end encrypted file sharing desktop app. Like _Airdrop_
21 | but works over the internet rather than bluetooth/Wi-Fi, hence, _Spacedrop_.
22 |
23 |
24 | Implements the secure [signal
25 | protocol](https://signal.org/docs/specifications/doubleratchet/) to enable the
26 | end-to-end encryption of file transfers with authentication.
27 |
28 |
29 | More info at https://habibrehman.com/work/spacedrop
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | ## Features
43 | - [x] Peer-to-peer file sharing
44 | - [x] End-to-end encryption
45 | - [x] Persistent Wormholes (tunnels/contacts) for recurring shares
46 | - [x] Pause/resume/cancel shares
47 | - [x] Dark Mode
48 | - [ ] Persist unfinished shares
49 | - [ ] Share files from file manager(s)
50 | - [ ] Wi-Fi/LAN file shares
51 | - [ ] Web-service
52 |
53 | You are welcome to open pull requests to help implement the features still to
54 | do!
55 |
56 | ## Install
57 |
58 | _macOS 10.10+, Linux, and Windows 7+ are supported (64-bit only)._
59 |
60 | **macOS**
61 |
62 | [**Download**](https://github.com/hr/spacedrop/releases/latest) the `.dmg` file.
63 |
64 | **Linux**
65 |
66 | [**Download**](https://github.com/hr/spacedrop/releases/latest) the `.AppImage` or `.deb` file.
67 |
68 | _The AppImage needs to be [made executable](http://discourse.appimage.org/t/how-to-make-an-appimage-executable/80) after download._
69 |
70 | **Windows**
71 |
72 | [**Download**](https://github.com/hr/spacedrop/releases/latest) the `.exe` file.
73 |
74 |
75 | ## Dev
76 |
77 | Needs the Spacedrop Server as well (https://github.com/HR/spacedrop-server/)
78 |
79 | ### Setup
80 |
81 | Clone the repos
82 |
83 | ```
84 | $ git clone --recurse-submodules https://github.com/HR/spacedrop.git
85 | $ git clone https://github.com/HR/spacedrop-server.git
86 | ```
87 |
88 | Install deps for both repos
89 |
90 | ```
91 | $ yarn
92 | ```
93 |
94 | ### Run
95 |
96 | For faster dev, run the bundler (webpack)
97 |
98 | ```
99 | $ yarn run bundler
100 | ```
101 |
102 | In a new tty, run the app
103 |
104 | ```
105 | $ gulp
106 | ```
107 |
108 | To test the app locally with another app, just run a second instance in a new
109 | tty
110 |
111 | ```
112 | $ gulp
113 | ```
114 |
115 | N.B. on macOS, you may be prompted to allow incoming connections everytime you
116 | run it. Unfortunately the only way to make that go away currently is to disable
117 | your firewall temporarily.
118 |
119 | ### Publish
120 |
121 | ```
122 | $ npm run release
123 | ```
124 |
125 | After Travis finishes building your app, open the release draft it created and
126 | click "Publish".
127 |
128 |
129 | <> with <3 by Habib Rehman
130 |
--------------------------------------------------------------------------------
/src/renderer/components/Drop.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { basename } from 'path'
3 | import { classList } from '../lib/util'
4 | import { DROP_TYPE, DROP_STATUS } from '../../consts'
5 | import filesize from 'filesize'
6 | import { Row, Col, ProgressBar } from 'react-bootstrap'
7 |
8 | const remote = require('electron').remote
9 | const { shell } = remote
10 |
11 | // Select greatest time unit for eta
12 | function sec2time (sec) {
13 | const time = parseFloat(sec).toFixed(3),
14 | hrs = Math.floor(time / 60 / 60),
15 | mins = Math.floor(time / 60) % 60,
16 | secs = Math.floor(time - mins * 60)
17 |
18 | return (
19 | (hrs && hrs + ' hours') ||
20 | (mins && mins + ' minutes') ||
21 | (secs && secs + ' seconds')
22 | )
23 | }
24 |
25 | export default function Drop (props) {
26 | const {
27 | onResumeClick,
28 | onPauseClick,
29 | onDeleteClick,
30 | id,
31 | name,
32 | type,
33 | path,
34 | percentage,
35 | transferred,
36 | length,
37 | speed,
38 | eta,
39 | status
40 | } = props
41 |
42 | const isDownload = type === DROP_TYPE.DOWNLOAD
43 | const percent = Math.round(percentage || 0)
44 | const etaStr = sec2time(eta || 0)
45 | const tranStr = filesize(transferred || 0)
46 | const lenStr = filesize(length || 0)
47 | const speedStr = filesize(speed || 0)
48 | const typeStr = isDownload ? 'Received' : 'Sent'
49 | let statusStr,
50 | action,
51 | progressVariant = !isDownload && 'success',
52 | isDone = false
53 |
54 | switch (status) {
55 | case DROP_STATUS.DONE:
56 | statusStr = `${type} Finished`
57 | isDone = true
58 | break
59 | case DROP_STATUS.PAUSED:
60 | statusStr = `${type} Paused`
61 | action = !isDownload && (
62 |
67 | )
68 | break
69 | case DROP_STATUS.PENDING:
70 | statusStr =
71 | `${type} ${speedStr}/s ` +
72 | `- ${typeStr} ${tranStr} of ${lenStr} (${percent}%), ${etaStr} left`
73 | action = !isDownload && (
74 |
79 | )
80 | break
81 | case DROP_STATUS.FAILED:
82 | statusStr = `${type} Failed`
83 | isDone = true
84 | progressVariant = 'danger'
85 | break
86 | default:
87 | throw new Error('Unknown drop status')
88 | }
89 |
90 | return (
91 |
92 |
93 |
94 | shell.openPath(path)}
98 | >
99 | {name}
100 |
101 |
102 | shell.showItemInFolder(path)}
105 | title={`Show ${name} in file manager`}
106 | />
107 | {action}
108 |
113 |
114 |
115 |
116 | {statusStr}
117 |
118 |
119 |
120 |
126 |
127 |
128 |
129 |
130 | )
131 | }
132 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "spacedrop",
3 | "productName": "Spacedrop",
4 | "version": "0.0.1",
5 | "private": true,
6 | "description": "A decentralized end-to-end encrypted messaging app",
7 | "license": "MIT",
8 | "repository": "hr/spacedrop",
9 | "homepage": "./",
10 | "author": {
11 | "name": "Habib Rehman",
12 | "email": "h@rehman.email",
13 | "url": "https://github.com/HR"
14 | },
15 | "main": "./src/main/index.js",
16 | "scripts": {
17 | "postinstall": "electron-builder install-app-deps",
18 | "rebuild": "electron-rebuild -f .",
19 | "test": "npm run lint",
20 | "start": "{ npm run bundle & gulp; }",
21 | "bundler": "BROWSER=none webpack serve --mode development --env development",
22 | "bundle": "webpack --mode production",
23 | "electron": "electron .",
24 | "pack": "electron-builder --dir",
25 | "build": "electron-builder --macos --linux --windows",
26 | "build:mac": "electron-builder -m",
27 | "build:lin": "electron-builder -l --x64 --ia32",
28 | "build:win": "electron-builder -w --x64 --ia32",
29 | "release": "np"
30 | },
31 | "dependencies": {
32 | "bootstrap": "^4.6.0",
33 | "brake": "^1.0.1",
34 | "electron-context-menu": "^2.5.0",
35 | "electron-debug": "^3.2.0",
36 | "electron-store": "^7.0.2",
37 | "electron-unhandled": "^3.0.2",
38 | "electron-util": "^0.14.2",
39 | "filesize": "^6.1.0",
40 | "futoin-hkdf": "^1.3.3",
41 | "keytar": "^7.4.0",
42 | "moment": "^2.29.1",
43 | "progress-stream": "^2.0.0",
44 | "react": "^17.0.1",
45 | "react-bootstrap": "^1.5.1",
46 | "react-dom": "^17.0.1",
47 | "tweetnacl": "^1.0.3",
48 | "update-electron-app": "^2.0.1",
49 | "wrtc": "^0.4.7",
50 | "ws": "^7.4.3"
51 | },
52 | "devDependencies": {
53 | "@babel/core": "^7.13.8",
54 | "@babel/plugin-proposal-class-properties": "^7.13.0",
55 | "@babel/plugin-transform-runtime": "^7.13.9",
56 | "@babel/preset-env": "^7.13.9",
57 | "@babel/preset-react": "^7.12.13",
58 | "@babel/runtime": "^7.13.9",
59 | "babel-loader": "^8.2.2",
60 | "css-loader": "^5.1.1",
61 | "electron": "^12.0.0",
62 | "electron-builder": "^22.10.5",
63 | "electron-rebuild": "^2.3.5",
64 | "file-loader": "^6.2.0",
65 | "glob": "^7.1.6",
66 | "gulp": "^4.0.2",
67 | "gulp-watch": "^5.0.1",
68 | "html-loader": "^2.1.1",
69 | "html-webpack-plugin": "^5.2.0",
70 | "mini-css-extract-plugin": "^1.3.9",
71 | "node-sass": "^5.0.0",
72 | "np": "^7.4.0",
73 | "sass-loader": "^11.0.1",
74 | "style-loader": "^2.0.0",
75 | "webpack": "^5.24.2",
76 | "webpack-cli": "^4.5.0",
77 | "webpack-dev-server": "^3.11.2"
78 | },
79 | "eslintConfig": {
80 | "extends": "react-app"
81 | },
82 | "xo": {
83 | "envs": [
84 | "node",
85 | "browser"
86 | ]
87 | },
88 | "np": {
89 | "publish": false,
90 | "releaseDraft": false
91 | },
92 | "build": {
93 | "appId": "com.hr.spacedrop",
94 | "mac": {
95 | "category": "public.app-category.social-networking",
96 | "darkModeSupport": true
97 | },
98 | "dmg": {
99 | "iconSize": 160,
100 | "contents": [
101 | {
102 | "x": 180,
103 | "y": 170
104 | },
105 | {
106 | "x": 480,
107 | "y": 170,
108 | "type": "link",
109 | "path": "/Applications"
110 | }
111 | ]
112 | },
113 | "linux": {
114 | "target": [
115 | "AppImage",
116 | "deb"
117 | ],
118 | "category": "Network;Chat"
119 | }
120 | },
121 | "browserslist": {
122 | "production": [
123 | ">0.2%",
124 | "not dead",
125 | "not op_mini all"
126 | ],
127 | "development": [
128 | "last 1 chrome version",
129 | "last 1 firefox version",
130 | "last 1 safari version"
131 | ]
132 | },
133 | "peerDependencies": {
134 | "bootstrap": "^4.6.0",
135 | "react-bootstrap": "^1.5.1"
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/src/renderer/components/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { ipcRenderer, remote, clipboard } from 'electron'
3 | import { Container } from 'react-bootstrap'
4 | import { useNotifications } from '../lib/notifications'
5 | import { clone } from '../lib/util'
6 | import Toolbar from './Toolbar'
7 | import Wormholes from './Wormholes'
8 | import WormholeModal from './WormholeModal'
9 | import '../../../static/scss/index.scss'
10 |
11 | const { dialog } = remote
12 | // Notification ref
13 | let notifications = null
14 | // Initial modal state used to reset modals
15 | const initModalsState = {
16 | createWormhole: false,
17 | editWormhole: false
18 | }
19 | // Root component
20 | export default class App extends React.Component {
21 | static contextType = useNotifications(true)
22 | constructor (props) {
23 | super(props)
24 | this.state = {
25 | online: false,
26 | identity: '',
27 | wormholes: [],
28 | active: '',
29 | ...clone(initModalsState)
30 | }
31 |
32 | // Bindings
33 | this.activate = this.activate.bind(this)
34 | this.updateState = this.updateState.bind(this)
35 | this.closeModals = this.closeModals.bind(this)
36 | this.openModal = this.openModal.bind(this)
37 | this.dropFileHandler = this.dropFileHandler.bind(this)
38 | this.createWormhole = this.createWormhole.bind(this)
39 | this.editWormhole = this.editWormhole.bind(this)
40 | this.copyIdentity = this.copyIdentity.bind(this)
41 |
42 | // Add event listeners
43 | ipcRenderer.on('open-modal', (event, ...args) => this.openModal(...args))
44 | ipcRenderer.on('create-drop', this.dropFileHandler)
45 | ipcRenderer.on('copy-id', this.copyIdentity)
46 | ipcRenderer.on('update-state', this.updateState)
47 | }
48 |
49 | componentDidMount () {
50 | // Init notifications via the context
51 | notifications = this.context
52 | // Let main process show notifications
53 | ipcRenderer.on('notify', (event, ...args) => notifications.show(...args))
54 | // Load state from main if not already loaded
55 | ipcRenderer.send('do-update-state')
56 | }
57 |
58 | activate (active) {
59 | ipcRenderer.send('activate-wormhole', active)
60 | this.setState({ active })
61 | }
62 |
63 | // Updates internal state thereby updating the UI
64 | updateState (event, state, resetState) {
65 | let newState = { ...state }
66 | if (resetState) {
67 | // Reset state
68 | Object.assign(newState, clone(initModalsState))
69 | notifications && notifications.clear()
70 | }
71 | console.log('updateState', newState, resetState)
72 | this.setState(newState, () => console.log('Updated state', this.state))
73 | }
74 |
75 | // Closes all the modals
76 | closeModals () {
77 | this.setState({
78 | ...clone(initModalsState)
79 | })
80 | }
81 |
82 | // Shows the specified modal
83 | openModal (name, state) {
84 | console.log('Opening modal ' + name)
85 | let newModalState = clone(initModalsState)
86 | newModalState[name] = state ? state : true
87 | this.setState(newModalState)
88 | }
89 |
90 | // Handles sending a file
91 | async dropFileHandler () {
92 | if (!this.state.wormholes.length)
93 | return notifications.show('No wormhole', 'error', true, 3000)
94 | if (!this.state.active)
95 | return notifications.show('No wormhole selected', 'error', true, 3000)
96 |
97 | const wormhole = this.state.wormholes.find(w => w.id === this.state.active)
98 | if (!wormhole.online)
99 | return notifications.show(
100 | 'Wormhole closed (offline)',
101 | 'error',
102 | true,
103 | 3000
104 | )
105 | const title = 'Select the file to send'
106 | // Filter based on type selected
107 | const filters = [{ name: 'All Files', extensions: ['*'] }]
108 | const { canceled, filePaths } = await dialog.showOpenDialog(
109 | remote.getCurrentWindow(),
110 | {
111 | properties: ['openFile'],
112 | title,
113 | filters
114 | }
115 | )
116 | // Ignore if user cancelled
117 | if (canceled || !filePaths) return
118 | console.log(filePaths)
119 | ipcRenderer.send('drop', this.state.active, filePaths[0])
120 | }
121 |
122 | createWormhole ({ id, name }) {
123 | if (!id || !name)
124 | return notifications.show('Details missing', 'error', true, 3000)
125 |
126 | const alreadyExists = this.state.wormholes.find(w => w.id === id)
127 | const isMe = this.state.identity === id
128 | if (alreadyExists || isMe)
129 | return notifications.show('Already added', 'error', true, 3000)
130 |
131 | // Show persistent composing notification
132 | notifications.show('Warping space-time...', null, false)
133 |
134 | ipcRenderer.send('create-wormhole', id, name)
135 | }
136 |
137 | editWormhole ({ id, name }) {
138 | if (!name) return notifications.show('Name missing', 'error', true, 3000)
139 |
140 | // Show persistent composing notification
141 | notifications.show('Rewarping space-time...', null, false)
142 |
143 | ipcRenderer.send('edit-wormhole', id, name)
144 | }
145 |
146 | copyIdentity (e) {
147 | clipboard.writeText(this.state.identity)
148 | notifications.show('Copied Spacedrop ID', null, true, 3000)
149 | if (e) e.preventDefault()
150 | }
151 |
152 | // Render the App UI
153 | render () {
154 | const activeWormholeOnline = Object.values(this.state.wormholes).find(
155 | w => w.id === this.state.active && w.online === true
156 | )
157 | return (
158 |
159 | this.closeModals()}
163 | onSubmit={this.createWormhole}
164 | />
165 | this.closeModals()}
170 | onSubmit={this.editWormhole}
171 | disabledId={true}
172 | />
173 |
174 | this.openModal('createWormhole')}
178 | onSendClick={this.dropFileHandler}
179 | onCopyIdentityClick={this.copyIdentity}
180 | />
181 |
186 | ipcRenderer.send('resume-drop', ...args)
187 | }
188 | onPauseClick={(...args) => ipcRenderer.send('pause-drop', ...args)}
189 | onDeleteClick={(...args) =>
190 | ipcRenderer.send('delete-drop', ...args)
191 | }
192 | />
193 |
194 |
195 | )
196 | }
197 | }
198 |
--------------------------------------------------------------------------------
/src/main/lib/Peers.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /**
3 | * Peers class
4 | * Manages peer connections and communication
5 | */
6 |
7 | const stream = require('stream'),
8 | EventEmitter = require('events'),
9 | path = require('path'),
10 | util = require('util'),
11 | fs = require('fs'),
12 | brake = require('brake'),
13 | wrtc = require('wrtc'),
14 | moment = require('moment'),
15 | progress = require('progress-stream'),
16 | Peer = require('./simple-peer'),
17 | Queue = require('./Queue'),
18 | { DROPS_DIR } = require('../../config'),
19 | // http://viblast.com/blog/2015/2/5/webrtc-data-channel-message-size/
20 | DROP_CHUNK_SIZE = 16 * 1024, // (16kb)
21 | DROP_STREAM_RATE = 50, // ms
22 | DROP_STAT_INTERVAL = 800 // ms
23 |
24 | const { mkdir, stat } = fs.promises
25 | const pipeline = util.promisify(stream.pipeline)
26 |
27 | module.exports = class Peers extends EventEmitter {
28 | constructor (signal, crypto) {
29 | // Ensure singleton
30 | if (!!Peers.instance) {
31 | return Peers.instance
32 | }
33 |
34 | // Call EventEmitter constructor
35 | super()
36 |
37 | this._peers = {}
38 | this._requests = {}
39 | this._transfers = []
40 | this._signal = signal
41 | this._crypto = crypto
42 | this._sendingQueue = new Queue()
43 | this._receivingQueue = new Queue()
44 |
45 | // Bindings
46 | this._addPeer = this._addPeer.bind(this)
47 | this._onSignalRequest = this._onSignalRequest.bind(this)
48 | this._onSignalAccept = this._onSignalAccept.bind(this)
49 | this._onSignal = this._onSignal.bind(this)
50 | this._onSignalReceiverOffline = this._onSignalReceiverOffline.bind(this)
51 |
52 | // Add queue event listeners
53 | this._sendingQueue.on('error', (...args) =>
54 | this.emit('send-error', ...args)
55 | )
56 | this._receivingQueue.on('error', (...args) =>
57 | this.emit('receive-error', ...args)
58 | )
59 |
60 | // Add signal event listeners
61 | this._signal.on('signal-request', this._onSignalRequest)
62 | this._signal.on('signal-accept', this._onSignalAccept)
63 | this._signal.on('signal', this._onSignal)
64 | this._signal.on('not-found', this._onSignalReceiverOffline)
65 |
66 | Peers.instance = this
67 | }
68 |
69 | // Connects to given peer
70 | connect (userId) {
71 | // Start connection
72 | const signalRequest = (this._requests[userId] = {
73 | receiverId: userId,
74 | timestamp: new Date().toISOString()
75 | })
76 | // Send a signal request to peer
77 | this._signal.send('signal-request', signalRequest)
78 | console.log('Connecting with', userId)
79 | }
80 |
81 | // Disconnects from given peer
82 | disconnect (userId) {
83 | this._removePeer(userId)
84 | console.log('Disconnected from', userId)
85 | }
86 |
87 | // Checks if given peer is connected
88 | isConnected (id) {
89 | return this._peers.hasOwnProperty(id)
90 | }
91 |
92 | // Checks if transfer is in progress
93 | hasTransfer (id) {
94 | return this._transfers.includes(id)
95 | }
96 |
97 | // Queues a chat message to be sent to given peer
98 | send (dropId, ...args) {
99 | this._sendingQueue.add(() => this._sendFile(...args), dropId)
100 | }
101 |
102 | // Handles signal requests
103 | _onSignalRequest ({ senderId, timestamp }) {
104 | console.log('Signal request received')
105 |
106 | const request = this._requests[senderId]
107 | // If a request to the sender has not already been sent then just accept it
108 | // Add receiver to receive signal
109 | if (!request) {
110 | this._addReceiver(senderId)
111 | this._signal.send('signal-accept', { receiverId: senderId })
112 | console.log('Signal request not sent to sender so accepted')
113 | return
114 | }
115 |
116 | // Parse request times
117 | const requestTime = moment(request.timestamp)
118 | const receivedRequestTime = moment(timestamp)
119 |
120 | // If received request was sent before own request then accept it
121 | // Add receiver to receive signal and forget own request
122 | // Avoids race condition when both peers send signal-requests
123 | if (receivedRequestTime.isBefore(requestTime)) {
124 | this._addReceiver(senderId)
125 | this._signal.send('signal-accept', { receiverId: senderId })
126 | delete this._requests[senderId]
127 | console.log('Signal request sent before own so accepted')
128 | }
129 |
130 | // Otherwise don't do anything (wait for signal-accept as the sender)
131 | }
132 |
133 | // Handles accepted signal requests
134 | _onSignalAccept ({ senderId }) {
135 | console.log('Signal request accepted')
136 | // Start signalling
137 | this._addSender(senderId)
138 | delete this._requests[senderId]
139 | }
140 |
141 | // Handles new signals
142 | _onSignal ({ senderId, data }) {
143 | // Ensure peer to signal exists
144 | if (!this._peers[senderId]) {
145 | throw new Error(`Peer ${senderId} not yet added`)
146 | }
147 |
148 | this._peers[senderId].signal(data)
149 | }
150 |
151 | // Handles offline receivers
152 | _onSignalReceiverOffline ({ receiverId }) {
153 | if (this._requests[receiverId]) {
154 | console.log('Signal receiver offline')
155 | // Delete request to allow offline peer to connect if it comes online
156 | delete this._requests[receiverId]
157 | }
158 | }
159 |
160 | // Removes given peer by id
161 | _removePeer (id) {
162 | if (this._peers[id]) {
163 | this._peers[id].destroy()
164 | delete this._peers[id]
165 | }
166 | }
167 |
168 | _removeDrop (id) {
169 | this._transfers = this._transfers.filter(t => t !== id)
170 | }
171 |
172 | // Adds sender to initiate a connection with receiving peer
173 | _addSender (...args) {
174 | this._addPeer(true, ...args)
175 | }
176 |
177 | // Adds a receiver to Initiate a connection with sending peer
178 | _addReceiver (...args) {
179 | this._addPeer(false, ...args)
180 | }
181 |
182 | // Initiates a connection with the given peer and sets up communication
183 | _addPeer (initiator, userId) {
184 | const peer = (this._peers[userId] = new Peer({
185 | initiator,
186 | wrtc,
187 | reconnectTimer: 1000
188 | }))
189 | const type = initiator ? 'Sender' : 'Receiver'
190 |
191 | peer.on('signal', data => {
192 | // Trickle signal data to the peer
193 | this._signal.send('signal', {
194 | receiverId: userId,
195 | data
196 | })
197 | console.log(type, 'got signal and sent')
198 | })
199 |
200 | peer.on('connect', async () => {
201 | // Initialises a chat session
202 | const keyMessage = await this._crypto.initSession(userId)
203 | // Send the master secret public key with signature to the user
204 | this._sendMessage('key', userId, keyMessage, false)
205 |
206 | this.emit('connect', userId, initiator)
207 | })
208 |
209 | peer.on('close', () => {
210 | this._removePeer(userId)
211 | this.emit('disconnect', userId)
212 | })
213 |
214 | peer.on('error', err => this.emit('error', userId, err))
215 |
216 | peer.on('data', data =>
217 | // Queue to receive
218 | this._receivingQueue.add(() =>
219 | this._onMessage(userId, data.toString('utf8'))
220 | )
221 | )
222 |
223 | peer.on('datachannel', (datachannel, id) =>
224 | // Queue to receive
225 | this._receivingQueue.add(() =>
226 | this._onDataChannel(userId, datachannel, id)
227 | )
228 | )
229 | }
230 |
231 | // Handles new messages
232 | async _onMessage (userId, data) {
233 | // Try to deserialize message
234 | console.log('------> Got new message', data)
235 | const { type, ...message } = JSON.parse(data)
236 |
237 | if (type === 'key') {
238 | // Start a new crypto session with received key
239 | this._crypto.startSession(userId, message)
240 | return
241 | }
242 | }
243 |
244 | // Handles new data channels (drops/file streams)
245 | async _onDataChannel (userId, receivingStream, rawDrop) {
246 | console.log('------> Received a new drop (datachannel)', rawDrop)
247 | const encDrop = JSON.parse(rawDrop)
248 | let { message, decipher } = await this._crypto.decrypt(
249 | userId,
250 | encDrop,
251 | true
252 | )
253 | // Ignore if validation failed
254 | if (!message) return
255 | const dropDir = path.join(DROPS_DIR, userId)
256 | // Recursively make media directory
257 | await mkdir(dropDir, { recursive: true })
258 | const filePath = path.join(dropDir, message.name)
259 | console.log('Writing to', filePath)
260 |
261 | this.emit('drop', userId, { path: filePath, ...message })
262 |
263 | const fileWriteStream = fs.createWriteStream(filePath)
264 | const tracker = progress({
265 | length: message.size,
266 | time: DROP_STAT_INTERVAL
267 | })
268 |
269 | tracker.on('progress', progress =>
270 | this.emit('progress', userId, message.id, progress)
271 | )
272 | // Stream content
273 | await pipeline(receivingStream, decipher, tracker, fileWriteStream)
274 | }
275 |
276 | // Sends a message to given peer
277 | _sendMessage (type, receiverId, message) {
278 | // TODO: Queue message if not connected / no session for later
279 | if (!this.isConnected(receiverId)) return false
280 |
281 | const peer = this._peers[receiverId]
282 |
283 | const serializedMessage = JSON.stringify({
284 | type,
285 | ...message
286 | })
287 |
288 | // Simply send message if no file to stream
289 | peer.write(serializedMessage)
290 | console.log(type, 'sent', message)
291 | }
292 |
293 | // Sends a file to given peer
294 | async _sendFile (receiverId, drop, filePath) {
295 | // TODO: Queue message if not connected / no session for later
296 | if (!this.isConnected(receiverId)) return false
297 |
298 | const peer = this._peers[receiverId]
299 |
300 | if (!drop.size) {
301 | // Add size
302 | const { size } = await stat(filePath)
303 | drop.size = size
304 | }
305 |
306 | // Encrypt message
307 | const {packet, cipher} = await this._crypto.encrypt(receiverId, drop, true)
308 |
309 | const serializedDrop = JSON.stringify(packet)
310 |
311 | // Stream file
312 | console.log('Streaming', drop, filePath)
313 | // Resume transfer is drop already exists
314 | let opts = drop.transferred ? { start: drop.transferred } : {}
315 | const fileReadStream = fs.createReadStream(filePath, opts)
316 | const sendingStream = peer.createDataChannel(serializedDrop)
317 | const tracker = progress({
318 | length: drop.size,
319 | time: DROP_STAT_INTERVAL
320 | })
321 |
322 | tracker.on('progress', progress =>
323 | this.emit('progress', receiverId, drop.id, progress)
324 | )
325 |
326 | this._transfers.push(drop.id)
327 |
328 | const pipe = pipeline(
329 | fileReadStream,
330 | cipher,
331 | // Throttle stream (backpressure)
332 | brake(DROP_CHUNK_SIZE, { period: DROP_STREAM_RATE }),
333 | tracker,
334 | sendingStream
335 | )
336 |
337 | this.on('pause-drop', dropId => {
338 | if (dropId === drop.id) {
339 | console.log('Pausing drop', drop)
340 | fileReadStream.unpipe(cipher)
341 | console.log('Stream paused?', fileReadStream.isPaused())
342 | }
343 | })
344 |
345 | this.on('resume-drop', dropId => {
346 | if (dropId === drop.id) {
347 | console.log('Resuming drop', drop)
348 | fileReadStream.pipe(cipher)
349 | }
350 | })
351 |
352 | this.on('destroy-drop', dropId => {
353 | if (dropId === drop.id) {
354 | pipe.destroy()
355 | }
356 | })
357 |
358 | await pipe
359 | }
360 | }
361 |
--------------------------------------------------------------------------------
/src/main/index.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | /**
3 | * Main App
4 | *****************************/
5 | const { app, Menu, ipcMain, screen } = require('electron'),
6 | { basename } = require('path'),
7 | { is, openNewGitHubIssue, debugInfo } = require('electron-util'),
8 | debug = require('electron-debug'),
9 | unhandled = require('electron-unhandled'),
10 | contextMenu = require('electron-context-menu'),
11 | packageJson = require('../../package.json'),
12 | { DROP_TYPE, DROP_STATUS } = require('../consts'),
13 | Store = require('electron-store'),
14 | Crypto = require('./lib/Crypto'),
15 | Server = require('./lib/Server'),
16 | Peers = require('./lib/Peers'),
17 | Wormholes = require('./lib/Wormholes'),
18 | menu = require('./menu'),
19 | windows = require('./windows')
20 | let db = 'launch',
21 | secondWin = false
22 |
23 | unhandled({
24 | reportButton: error => {
25 | openNewGitHubIssue({
26 | user: 'hr',
27 | repo: 'spacedrop',
28 | body: `\`\`\`\n${error.stack}\n\`\`\`\n\n---\n\n${debugInfo()}`
29 | })
30 | }
31 | })
32 | // debug()
33 | app.setAppUserModelId(packageJson.build.appId)
34 |
35 | if (!is.development) {
36 | // Prevent multiple instances of the app
37 | if (!app.requestSingleInstanceLock()) {
38 | app.quit()
39 | }
40 |
41 | // Someone tried to run a second instance, so focus the main window
42 | app.on('second-instance', windows.main.secondInstance)
43 | } else {
44 | // Allow multiple instances of the app in dev
45 | if (!app.requestSingleInstanceLock()) {
46 | console.info('Second instance')
47 | db += '2'
48 | secondWin = true
49 | }
50 | }
51 |
52 | app.on('window-all-closed', () => {
53 | if (!is.macos) {
54 | app.quit()
55 | }
56 | })
57 |
58 | app.on('activate', windows.main.activate)
59 |
60 | /**
61 | * Main
62 | *****************************/
63 | ;(async () => {
64 | console.log('\n\n\n**********************************************> New run\n')
65 |
66 | await app.whenReady()
67 | Menu.setApplicationMenu(menu)
68 |
69 | const store = new Store({ name: db })
70 | const crypto = new Crypto(store)
71 | const server = new Server()
72 | const peers = new Peers(server, crypto)
73 | const wormholes = new Wormholes(store)
74 |
75 | initContextMenu()
76 |
77 | /**
78 | * App events
79 | *****************************/
80 | app.on('will-quit', () => {
81 | console.log('Clnosing Spacedrop...')
82 | wormholes.pauseDrops()
83 | })
84 |
85 | app.on('delete-drops', () => {
86 | wormholes.clearDrops()
87 | updateState()
88 | })
89 | app.on('connect-mothership', () => connectMothership())
90 | app.on('open-wormholes', () => {
91 | if (!server.isConnected())
92 | notifyError(`Connect with Mothership to open wormholes`)
93 | openWormholes()
94 | })
95 | app.on('create-wormhole', () =>
96 | windows.main.send('open-modal', 'createWormhole')
97 | )
98 | app.on('render-event', event => {
99 | windows.main.send(event)
100 | })
101 |
102 | /**
103 | * Server events
104 | *****************************/
105 | server.on('connect', () => {
106 | openWormholes()
107 | updateState({ online: true })
108 | })
109 | server.on('disconnect', () => {
110 | notifyError('Connection with Mothership failed')
111 | updateState({ online: false })
112 | })
113 | server.on('error', err => {
114 | if (err.code !== 'ECONNREFUSED')
115 | notifyError(`Connection error: ${err.message}`)
116 | })
117 |
118 | /**
119 | * IPC events
120 | *****************************/
121 | // When a wormhole is created by user
122 | ipcMain.on('create-wormhole', createWormholeHandler)
123 | ipcMain.on('edit-wormhole', editWormholeHandler)
124 | // When a wormhole is selected by user
125 | ipcMain.on('activate-wormhole', (e, id) => store.set('state.lastActive', id))
126 | // When a file is sent by user
127 | ipcMain.on('drop', dropHandler)
128 | ipcMain.on('resume-drop', resumeDropHandler)
129 | ipcMain.on('pause-drop', pauseDropHandler)
130 | ipcMain.on('delete-drop', deleteDropHandler)
131 |
132 | /**
133 | * Peers events
134 | *****************************/
135 | // When a new connection with a user is established
136 | peers.on('connect', peerConnectHandler)
137 | // When a connection with a user is closed
138 | peers.on('disconnect', peerDisconnectHandler)
139 | // When a connection error with a user occurs
140 | peers.on('error', peerErrorHandler)
141 | // When a new message from a user is received
142 | peers.on('drop', peerDropHandler)
143 | // When a new progress update for a drop is received
144 | peers.on('progress', peerDropProgressHandler)
145 | // Failed to send a drop
146 | peers.on('send-error', peerDropSendFailHandler)
147 | // Failed to receive a drop
148 | peers.on('receive-error', peerDropReceiveFailHandler)
149 |
150 | /**
151 | * Init
152 | *****************************/
153 | // Init crypto and main window
154 | const [identity] = await Promise.all([crypto.init(), windows.main.init()])
155 | console.log(identity)
156 |
157 | // TODO: remove in prod
158 | if (secondWin) {
159 | const displays = screen.getAllDisplays()
160 | const display = displays[displays.length - 1]
161 | console.log(display)
162 | const { x, y, width, height } = display.bounds
163 | const win = windows.main.win.getBounds()
164 | windows.main.win.setPosition(
165 | Math.round(x + (width - win.width) / 2),
166 | Math.round(y + (height - win.height) / 2)
167 | )
168 | }
169 |
170 | let lastActive = store.get('state.lastActive', false)
171 |
172 | if (!lastActive) {
173 | lastActive = wormholes.getActive()
174 | store.set('state.lastActive', lastActive)
175 | }
176 |
177 | // Populate UI
178 | updateState({ identity: identity.publicKey, active: lastActive })
179 | ipcMain.on('do-update-state', () =>
180 | updateState({
181 | identity: identity.publicKey,
182 | active: store.get('state.lastActive', '')
183 | })
184 | )
185 |
186 | server.setId(identity.publicKey)
187 | // Establish connection with the Mothership (signalling server)
188 | connectMothership()
189 |
190 | /**
191 | * Handlers
192 | *****************************/
193 |
194 | function initContextMenu () {
195 | contextMenu({
196 | menu: actions => [],
197 | prepend: (defaultActions, params, browserWindow) => {
198 | console.log('Params:', params)
199 | const wormholeId =
200 | params.linkURL.includes('#wormhole') &&
201 | params.linkURL.split('#').pop()
202 | return [
203 | {
204 | label: 'Edit wormhole',
205 | // Only show it when right-clicking text
206 | visible: wormholeId,
207 | click: () =>
208 | windows.main.send(
209 | 'open-modal',
210 | 'editWormhole',
211 | wormholes.get(wormholeId)
212 | )
213 | },
214 | {
215 | label: 'Delete wormhole',
216 | // Only show it when right-clicking text
217 | visible: wormholeId,
218 | click: () => {
219 | // Cancel all pending drops
220 | wormholes
221 | .getDropList(wormholeId)
222 | .filter(drop => drop.status === DROP_STATUS.PENDING)
223 | .forEach(drop => peers.emit('destroy-drop', drop.id))
224 | wormholes.delete(wormholeId)
225 | updateState()
226 | console.log('Deleted wormhole', wormholeId)
227 | }
228 | }
229 | ]
230 | }
231 | })
232 | }
233 |
234 | function notifyError (message) {
235 | windows.main.send('notify', message, 'error', true, 4000)
236 | }
237 |
238 | async function connectMothership () {
239 | const authRequest = crypto.generateAuthRequest()
240 | server.connect(authRequest)
241 | }
242 |
243 | function openWormholes () {
244 | wormholes
245 | .getList()
246 | .filter(wormhole => !peers.isConnected(wormhole.id)) // Ignore ones already connecting to
247 | .forEach(wormhole => peers.connect(wormhole.id))
248 | }
249 |
250 | /* IPC handlers */
251 | function updateState (extraState, reset = false) {
252 | let state = {
253 | wormholes: wormholes
254 | .getList()
255 | .map(w => Object.assign(w, { online: peers.isConnected(w.id) }))
256 | }
257 | if (extraState) Object.assign(state, extraState)
258 | console.log(state)
259 | windows.main.send('update-state', state, reset)
260 | }
261 |
262 | function createWormholeHandler (event, id, name) {
263 | wormholes.add(id, name)
264 | updateState(null, true)
265 | if (!server.isConnected())
266 | return notifyError('Connect with Mothership to open wormhole')
267 | // open wormhole
268 | peers.connect(id)
269 | }
270 |
271 | function editWormholeHandler (event, id, name) {
272 | wormholes.update(id, { name })
273 | updateState(null, true)
274 | }
275 |
276 | async function dropHandler (event, id, filePath) {
277 | if (!peers.isConnected(id)) return notifyError('Wormhole closed (offline)')
278 | // Construct message
279 | let drop = {
280 | name: basename(filePath),
281 | timestamp: Date.now()
282 | }
283 |
284 | // Set the id of the message to its hash
285 | drop.id = crypto.hash(JSON.stringify(drop))
286 | console.log('Dropping ', drop, ' to ', id)
287 | // Optimistically update UI
288 | wormholes.addDrop(id, drop.id, {
289 | type: DROP_TYPE.UPLOAD,
290 | path: filePath,
291 | ...drop
292 | })
293 | updateState()
294 |
295 | // Send the message
296 | peers.send(drop.id, id, drop, filePath)
297 | }
298 |
299 | function peerDropSendFailHandler (dropId) {
300 | const wId = wormholes.findIdByDropId(dropId)
301 | const drop = wormholes.updateDrop(wId, dropId, {
302 | status: DROP_STATUS.FAILED
303 | })
304 | notifyError(`Failed to send ${drop.name} :(`)
305 | updateState()
306 | }
307 |
308 | function peerDropReceiveFailHandler (dropId) {
309 | const wId = wormholes.findIdByDropId(dropId)
310 | const drop = wormholes.updateDrop(wId, dropId, {
311 | status: DROP_STATUS.FAILED
312 | })
313 | notifyError(`Failed to receive ${drop.name} :(`)
314 | updateState()
315 | }
316 |
317 | function pauseDropHandler (e, holeId, dropId) {
318 | peers.emit('pause-drop', dropId)
319 | wormholes.updateDrop(holeId, dropId, { status: DROP_STATUS.PAUSED })
320 | updateState()
321 | }
322 |
323 | function resumeDropHandler (e, holeId, dropId) {
324 | if (!peers.isConnected(holeId))
325 | return notifyError('Wormhole closed (offline)')
326 |
327 | if (!peers.hasTransfer(dropId)) {
328 | // Re-initiate transfer
329 | const drop = wormholes.getDrop(holeId, dropId)
330 | peers.send(dropId, holeId, drop, drop.path)
331 | } else {
332 | peers.emit('resume-drop', dropId)
333 | }
334 |
335 | wormholes.updateDrop(holeId, dropId, { status: DROP_STATUS.PENDING })
336 | updateState()
337 | }
338 |
339 | function deleteDropHandler (e, holeId, dropId) {
340 | console.log('Deleting', holeId, dropId)
341 | peers.emit('destroy-drop', dropId)
342 | wormholes.deleteDrop(holeId, dropId)
343 | updateState()
344 | }
345 |
346 | /* Peers handlers */
347 | function peerConnectHandler (userId) {
348 | console.log('Connected with', userId)
349 |
350 | // New wormhole
351 | if (!wormholes.has(userId)) {
352 | wormholes.add(userId, userId.slice(0, 6) + '...')
353 | }
354 |
355 | // Update UI
356 | updateState()
357 | }
358 | function peerDisconnectHandler (userId) {
359 | console.log('Disconnected with', userId)
360 | // Update UI
361 | updateState()
362 | }
363 | function peerErrorHandler (userId, err) {
364 | console.log('Error connecting with peer', userId)
365 | console.error(err)
366 | }
367 | function peerDropHandler (senderId, drop) {
368 | console.log('Got drop', drop)
369 | wormholes.addDrop(senderId, drop.id, {
370 | type: DROP_TYPE.DOWNLOAD,
371 | ...drop
372 | })
373 | updateState()
374 | }
375 |
376 | function peerDropProgressHandler (receiverId, dropId, progress) {
377 | // console.log('Got progress', receiverId, dropId, progress)
378 | wormholes.updateDrop(receiverId, dropId, progress)
379 | updateState()
380 | }
381 | })()
382 |
--------------------------------------------------------------------------------
/static/scss/ionicons.scss:
--------------------------------------------------------------------------------
1 | @charset "UTF-8";
2 | /*!
3 | Ionicons, v4.6.3
4 | Created by Ben Sperry for the Ionic Framework, http://ionicons.com/
5 | https://twitter.com/benjsperry https://twitter.com/ionicframework
6 | MIT License: https://github.com/driftyco/ionicons
7 |
8 | Android-style icons originally built by Google’s
9 | Material Design Icons: https://github.com/google/material-design-icons
10 | used under CC BY http://creativecommons.org/licenses/by/4.0/
11 | Modified icons to fit ionicon’s grid from original.
12 | https://cdnjs.cloudflare.com/ajax/libs/ionicons/4.6.3/css/ionicons.css
13 | */
14 | @font-face {
15 | font-family: "Ionicons";
16 | src: url("../fonts/ionicons.woff2") format("woff2");
17 | font-weight: normal;
18 | font-style: normal;
19 | }
20 | .ion, .ionicons,
21 | .ion-ios-add:before,
22 | .ion-ios-add-circle:before,
23 | .ion-ios-add-circle-outline:before,
24 | .ion-ios-airplane:before,
25 | .ion-ios-alarm:before,
26 | .ion-ios-albums:before,
27 | .ion-ios-alert:before,
28 | .ion-ios-american-football:before,
29 | .ion-ios-analytics:before,
30 | .ion-ios-aperture:before,
31 | .ion-ios-apps:before,
32 | .ion-ios-appstore:before,
33 | .ion-ios-archive:before,
34 | .ion-ios-arrow-back:before,
35 | .ion-ios-arrow-down:before,
36 | .ion-ios-arrow-dropdown:before,
37 | .ion-ios-arrow-dropdown-circle:before,
38 | .ion-ios-arrow-dropleft:before,
39 | .ion-ios-arrow-dropleft-circle:before,
40 | .ion-ios-arrow-dropright:before,
41 | .ion-ios-arrow-dropright-circle:before,
42 | .ion-ios-arrow-dropup:before,
43 | .ion-ios-arrow-dropup-circle:before,
44 | .ion-ios-arrow-forward:before,
45 | .ion-ios-arrow-round-back:before,
46 | .ion-ios-arrow-round-down:before,
47 | .ion-ios-arrow-round-forward:before,
48 | .ion-ios-arrow-round-up:before,
49 | .ion-ios-arrow-up:before,
50 | .ion-ios-at:before,
51 | .ion-ios-attach:before,
52 | .ion-ios-backspace:before,
53 | .ion-ios-barcode:before,
54 | .ion-ios-baseball:before,
55 | .ion-ios-basket:before,
56 | .ion-ios-basketball:before,
57 | .ion-ios-battery-charging:before,
58 | .ion-ios-battery-dead:before,
59 | .ion-ios-battery-full:before,
60 | .ion-ios-beaker:before,
61 | .ion-ios-bed:before,
62 | .ion-ios-beer:before,
63 | .ion-ios-bicycle:before,
64 | .ion-ios-bluetooth:before,
65 | .ion-ios-boat:before,
66 | .ion-ios-body:before,
67 | .ion-ios-bonfire:before,
68 | .ion-ios-book:before,
69 | .ion-ios-bookmark:before,
70 | .ion-ios-bookmarks:before,
71 | .ion-ios-bowtie:before,
72 | .ion-ios-briefcase:before,
73 | .ion-ios-browsers:before,
74 | .ion-ios-brush:before,
75 | .ion-ios-bug:before,
76 | .ion-ios-build:before,
77 | .ion-ios-bulb:before,
78 | .ion-ios-bus:before,
79 | .ion-ios-business:before,
80 | .ion-ios-cafe:before,
81 | .ion-ios-calculator:before,
82 | .ion-ios-calendar:before,
83 | .ion-ios-call:before,
84 | .ion-ios-camera:before,
85 | .ion-ios-car:before,
86 | .ion-ios-card:before,
87 | .ion-ios-cart:before,
88 | .ion-ios-cash:before,
89 | .ion-ios-cellular:before,
90 | .ion-ios-chatboxes:before,
91 | .ion-ios-chatbubbles:before,
92 | .ion-ios-checkbox:before,
93 | .ion-ios-checkbox-outline:before,
94 | .ion-ios-checkmark:before,
95 | .ion-ios-checkmark-circle:before,
96 | .ion-ios-checkmark-circle-outline:before,
97 | .ion-ios-clipboard:before,
98 | .ion-ios-clock:before,
99 | .ion-ios-close:before,
100 | .ion-ios-close-circle:before,
101 | .ion-ios-close-circle-outline:before,
102 | .ion-ios-cloud:before,
103 | .ion-ios-cloud-circle:before,
104 | .ion-ios-cloud-done:before,
105 | .ion-ios-cloud-download:before,
106 | .ion-ios-cloud-outline:before,
107 | .ion-ios-cloud-upload:before,
108 | .ion-ios-cloudy:before,
109 | .ion-ios-cloudy-night:before,
110 | .ion-ios-code:before,
111 | .ion-ios-code-download:before,
112 | .ion-ios-code-working:before,
113 | .ion-ios-cog:before,
114 | .ion-ios-color-fill:before,
115 | .ion-ios-color-filter:before,
116 | .ion-ios-color-palette:before,
117 | .ion-ios-color-wand:before,
118 | .ion-ios-compass:before,
119 | .ion-ios-construct:before,
120 | .ion-ios-contact:before,
121 | .ion-ios-contacts:before,
122 | .ion-ios-contract:before,
123 | .ion-ios-contrast:before,
124 | .ion-ios-copy:before,
125 | .ion-ios-create:before,
126 | .ion-ios-crop:before,
127 | .ion-ios-cube:before,
128 | .ion-ios-cut:before,
129 | .ion-ios-desktop:before,
130 | .ion-ios-disc:before,
131 | .ion-ios-document:before,
132 | .ion-ios-done-all:before,
133 | .ion-ios-download:before,
134 | .ion-ios-easel:before,
135 | .ion-ios-egg:before,
136 | .ion-ios-exit:before,
137 | .ion-ios-expand:before,
138 | .ion-ios-eye:before,
139 | .ion-ios-eye-off:before,
140 | .ion-ios-fastforward:before,
141 | .ion-ios-female:before,
142 | .ion-ios-filing:before,
143 | .ion-ios-film:before,
144 | .ion-ios-finger-print:before,
145 | .ion-ios-fitness:before,
146 | .ion-ios-flag:before,
147 | .ion-ios-flame:before,
148 | .ion-ios-flash:before,
149 | .ion-ios-flash-off:before,
150 | .ion-ios-flashlight:before,
151 | .ion-ios-flask:before,
152 | .ion-ios-flower:before,
153 | .ion-ios-folder:before,
154 | .ion-ios-folder-open:before,
155 | .ion-ios-football:before,
156 | .ion-ios-funnel:before,
157 | .ion-ios-gift:before,
158 | .ion-ios-git-branch:before,
159 | .ion-ios-git-commit:before,
160 | .ion-ios-git-compare:before,
161 | .ion-ios-git-merge:before,
162 | .ion-ios-git-network:before,
163 | .ion-ios-git-pull-request:before,
164 | .ion-ios-glasses:before,
165 | .ion-ios-globe:before,
166 | .ion-ios-grid:before,
167 | .ion-ios-hammer:before,
168 | .ion-ios-hand:before,
169 | .ion-ios-happy:before,
170 | .ion-ios-headset:before,
171 | .ion-ios-heart:before,
172 | .ion-ios-heart-dislike:before,
173 | .ion-ios-heart-empty:before,
174 | .ion-ios-heart-half:before,
175 | .ion-ios-help:before,
176 | .ion-ios-help-buoy:before,
177 | .ion-ios-help-circle:before,
178 | .ion-ios-help-circle-outline:before,
179 | .ion-ios-home:before,
180 | .ion-ios-hourglass:before,
181 | .ion-ios-ice-cream:before,
182 | .ion-ios-image:before,
183 | .ion-ios-images:before,
184 | .ion-ios-infinite:before,
185 | .ion-ios-information:before,
186 | .ion-ios-information-circle:before,
187 | .ion-ios-information-circle-outline:before,
188 | .ion-ios-jet:before,
189 | .ion-ios-journal:before,
190 | .ion-ios-key:before,
191 | .ion-ios-keypad:before,
192 | .ion-ios-laptop:before,
193 | .ion-ios-leaf:before,
194 | .ion-ios-link:before,
195 | .ion-ios-list:before,
196 | .ion-ios-list-box:before,
197 | .ion-ios-locate:before,
198 | .ion-ios-lock:before,
199 | .ion-ios-log-in:before,
200 | .ion-ios-log-out:before,
201 | .ion-ios-magnet:before,
202 | .ion-ios-mail:before,
203 | .ion-ios-mail-open:before,
204 | .ion-ios-mail-unread:before,
205 | .ion-ios-male:before,
206 | .ion-ios-man:before,
207 | .ion-ios-map:before,
208 | .ion-ios-medal:before,
209 | .ion-ios-medical:before,
210 | .ion-ios-medkit:before,
211 | .ion-ios-megaphone:before,
212 | .ion-ios-menu:before,
213 | .ion-ios-mic:before,
214 | .ion-ios-mic-off:before,
215 | .ion-ios-microphone:before,
216 | .ion-ios-moon:before,
217 | .ion-ios-more:before,
218 | .ion-ios-move:before,
219 | .ion-ios-musical-note:before,
220 | .ion-ios-musical-notes:before,
221 | .ion-ios-navigate:before,
222 | .ion-ios-notifications:before,
223 | .ion-ios-notifications-off:before,
224 | .ion-ios-notifications-outline:before,
225 | .ion-ios-nuclear:before,
226 | .ion-ios-nutrition:before,
227 | .ion-ios-open:before,
228 | .ion-ios-options:before,
229 | .ion-ios-outlet:before,
230 | .ion-ios-paper:before,
231 | .ion-ios-paper-plane:before,
232 | .ion-ios-partly-sunny:before,
233 | .ion-ios-pause:before,
234 | .ion-ios-paw:before,
235 | .ion-ios-people:before,
236 | .ion-ios-person:before,
237 | .ion-ios-person-add:before,
238 | .ion-ios-phone-landscape:before,
239 | .ion-ios-phone-portrait:before,
240 | .ion-ios-photos:before,
241 | .ion-ios-pie:before,
242 | .ion-ios-pin:before,
243 | .ion-ios-pint:before,
244 | .ion-ios-pizza:before,
245 | .ion-ios-planet:before,
246 | .ion-ios-play:before,
247 | .ion-ios-play-circle:before,
248 | .ion-ios-podium:before,
249 | .ion-ios-power:before,
250 | .ion-ios-pricetag:before,
251 | .ion-ios-pricetags:before,
252 | .ion-ios-print:before,
253 | .ion-ios-pulse:before,
254 | .ion-ios-qr-scanner:before,
255 | .ion-ios-quote:before,
256 | .ion-ios-radio:before,
257 | .ion-ios-radio-button-off:before,
258 | .ion-ios-radio-button-on:before,
259 | .ion-ios-rainy:before,
260 | .ion-ios-recording:before,
261 | .ion-ios-redo:before,
262 | .ion-ios-refresh:before,
263 | .ion-ios-refresh-circle:before,
264 | .ion-ios-remove:before,
265 | .ion-ios-remove-circle:before,
266 | .ion-ios-remove-circle-outline:before,
267 | .ion-ios-reorder:before,
268 | .ion-ios-repeat:before,
269 | .ion-ios-resize:before,
270 | .ion-ios-restaurant:before,
271 | .ion-ios-return-left:before,
272 | .ion-ios-return-right:before,
273 | .ion-ios-reverse-camera:before,
274 | .ion-ios-rewind:before,
275 | .ion-ios-ribbon:before,
276 | .ion-ios-rocket:before,
277 | .ion-ios-rose:before,
278 | .ion-ios-sad:before,
279 | .ion-ios-save:before,
280 | .ion-ios-school:before,
281 | .ion-ios-search:before,
282 | .ion-ios-send:before,
283 | .ion-ios-settings:before,
284 | .ion-ios-share:before,
285 | .ion-ios-share-alt:before,
286 | .ion-ios-shirt:before,
287 | .ion-ios-shuffle:before,
288 | .ion-ios-skip-backward:before,
289 | .ion-ios-skip-forward:before,
290 | .ion-ios-snow:before,
291 | .ion-ios-speedometer:before,
292 | .ion-ios-square:before,
293 | .ion-ios-square-outline:before,
294 | .ion-ios-star:before,
295 | .ion-ios-star-half:before,
296 | .ion-ios-star-outline:before,
297 | .ion-ios-stats:before,
298 | .ion-ios-stopwatch:before,
299 | .ion-ios-subway:before,
300 | .ion-ios-sunny:before,
301 | .ion-ios-swap:before,
302 | .ion-ios-switch:before,
303 | .ion-ios-sync:before,
304 | .ion-ios-tablet-landscape:before,
305 | .ion-ios-tablet-portrait:before,
306 | .ion-ios-tennisball:before,
307 | .ion-ios-text:before,
308 | .ion-ios-thermometer:before,
309 | .ion-ios-thumbs-down:before,
310 | .ion-ios-thumbs-up:before,
311 | .ion-ios-thunderstorm:before,
312 | .ion-ios-time:before,
313 | .ion-ios-timer:before,
314 | .ion-ios-today:before,
315 | .ion-ios-train:before,
316 | .ion-ios-transgender:before,
317 | .ion-ios-trash:before,
318 | .ion-ios-trending-down:before,
319 | .ion-ios-trending-up:before,
320 | .ion-ios-trophy:before,
321 | .ion-ios-tv:before,
322 | .ion-ios-umbrella:before,
323 | .ion-ios-undo:before,
324 | .ion-ios-unlock:before,
325 | .ion-ios-videocam:before,
326 | .ion-ios-volume-high:before,
327 | .ion-ios-volume-low:before,
328 | .ion-ios-volume-mute:before,
329 | .ion-ios-volume-off:before,
330 | .ion-ios-walk:before,
331 | .ion-ios-wallet:before,
332 | .ion-ios-warning:before,
333 | .ion-ios-watch:before,
334 | .ion-ios-water:before,
335 | .ion-ios-wifi:before,
336 | .ion-ios-wine:before,
337 | .ion-ios-woman:before,
338 | .ion-logo-android:before,
339 | .ion-logo-angular:before,
340 | .ion-logo-apple:before,
341 | .ion-logo-bitbucket:before,
342 | .ion-logo-bitcoin:before,
343 | .ion-logo-buffer:before,
344 | .ion-logo-chrome:before,
345 | .ion-logo-closed-captioning:before,
346 | .ion-logo-codepen:before,
347 | .ion-logo-css3:before,
348 | .ion-logo-designernews:before,
349 | .ion-logo-dribbble:before,
350 | .ion-logo-dropbox:before,
351 | .ion-logo-euro:before,
352 | .ion-logo-facebook:before,
353 | .ion-logo-flickr:before,
354 | .ion-logo-foursquare:before,
355 | .ion-logo-freebsd-devil:before,
356 | .ion-logo-game-controller-a:before,
357 | .ion-logo-game-controller-b:before,
358 | .ion-logo-github:before,
359 | .ion-logo-google:before,
360 | .ion-logo-googleplus:before,
361 | .ion-logo-hackernews:before,
362 | .ion-logo-html5:before,
363 | .ion-logo-instagram:before,
364 | .ion-logo-ionic:before,
365 | .ion-logo-ionitron:before,
366 | .ion-logo-javascript:before,
367 | .ion-logo-linkedin:before,
368 | .ion-logo-markdown:before,
369 | .ion-logo-model-s:before,
370 | .ion-logo-no-smoking:before,
371 | .ion-logo-nodejs:before,
372 | .ion-logo-npm:before,
373 | .ion-logo-octocat:before,
374 | .ion-logo-pinterest:before,
375 | .ion-logo-playstation:before,
376 | .ion-logo-polymer:before,
377 | .ion-logo-python:before,
378 | .ion-logo-reddit:before,
379 | .ion-logo-rss:before,
380 | .ion-logo-sass:before,
381 | .ion-logo-skype:before,
382 | .ion-logo-slack:before,
383 | .ion-logo-snapchat:before,
384 | .ion-logo-steam:before,
385 | .ion-logo-tumblr:before,
386 | .ion-logo-tux:before,
387 | .ion-logo-twitch:before,
388 | .ion-logo-twitter:before,
389 | .ion-logo-usd:before,
390 | .ion-logo-vimeo:before,
391 | .ion-logo-vk:before,
392 | .ion-logo-whatsapp:before,
393 | .ion-logo-windows:before,
394 | .ion-logo-wordpress:before,
395 | .ion-logo-xbox:before,
396 | .ion-logo-xing:before,
397 | .ion-logo-yahoo:before,
398 | .ion-logo-yen:before,
399 | .ion-logo-youtube:before,
400 | .ion-spacedrop:before,
401 | .ion-spacedrop-small:before,
402 | .ion-md-add:before,
403 | .ion-md-add-circle:before,
404 | .ion-md-add-circle-outline:before,
405 | .ion-md-airplane:before,
406 | .ion-md-alarm:before,
407 | .ion-md-albums:before,
408 | .ion-md-alert:before,
409 | .ion-md-american-football:before,
410 | .ion-md-analytics:before,
411 | .ion-md-aperture:before,
412 | .ion-md-apps:before,
413 | .ion-md-appstore:before,
414 | .ion-md-archive:before,
415 | .ion-md-arrow-back:before,
416 | .ion-md-arrow-down:before,
417 | .ion-md-arrow-dropdown:before,
418 | .ion-md-arrow-dropdown-circle:before,
419 | .ion-md-arrow-dropleft:before,
420 | .ion-md-arrow-dropleft-circle:before,
421 | .ion-md-arrow-dropright:before,
422 | .ion-md-arrow-dropright-circle:before,
423 | .ion-md-arrow-dropup:before,
424 | .ion-md-arrow-dropup-circle:before,
425 | .ion-md-arrow-forward:before,
426 | .ion-md-arrow-round-back:before,
427 | .ion-md-arrow-round-down:before,
428 | .ion-md-arrow-round-forward:before,
429 | .ion-md-arrow-round-up:before,
430 | .ion-md-arrow-up:before,
431 | .ion-md-at:before,
432 | .ion-md-attach:before,
433 | .ion-md-backspace:before,
434 | .ion-md-barcode:before,
435 | .ion-md-baseball:before,
436 | .ion-md-basket:before,
437 | .ion-md-basketball:before,
438 | .ion-md-battery-charging:before,
439 | .ion-md-battery-dead:before,
440 | .ion-md-battery-full:before,
441 | .ion-md-beaker:before,
442 | .ion-md-bed:before,
443 | .ion-md-beer:before,
444 | .ion-md-bicycle:before,
445 | .ion-md-bluetooth:before,
446 | .ion-md-boat:before,
447 | .ion-md-body:before,
448 | .ion-md-bonfire:before,
449 | .ion-md-book:before,
450 | .ion-md-bookmark:before,
451 | .ion-md-bookmarks:before,
452 | .ion-md-bowtie:before,
453 | .ion-md-briefcase:before,
454 | .ion-md-browsers:before,
455 | .ion-md-brush:before,
456 | .ion-md-bug:before,
457 | .ion-md-build:before,
458 | .ion-md-bulb:before,
459 | .ion-md-bus:before,
460 | .ion-md-business:before,
461 | .ion-md-cafe:before,
462 | .ion-md-calculator:before,
463 | .ion-md-calendar:before,
464 | .ion-md-call:before,
465 | .ion-md-camera:before,
466 | .ion-md-car:before,
467 | .ion-md-card:before,
468 | .ion-md-cart:before,
469 | .ion-md-cash:before,
470 | .ion-md-cellular:before,
471 | .ion-md-chatboxes:before,
472 | .ion-md-chatbubbles:before,
473 | .ion-md-checkbox:before,
474 | .ion-md-checkbox-outline:before,
475 | .ion-md-checkmark:before,
476 | .ion-md-checkmark-circle:before,
477 | .ion-md-checkmark-circle-outline:before,
478 | .ion-md-clipboard:before,
479 | .ion-md-clock:before,
480 | .ion-md-close:before,
481 | .ion-md-close-circle:before,
482 | .ion-md-close-circle-outline:before,
483 | .ion-md-cloud:before,
484 | .ion-md-cloud-circle:before,
485 | .ion-md-cloud-done:before,
486 | .ion-md-cloud-download:before,
487 | .ion-md-cloud-outline:before,
488 | .ion-md-cloud-upload:before,
489 | .ion-md-cloudy:before,
490 | .ion-md-cloudy-night:before,
491 | .ion-md-code:before,
492 | .ion-md-code-download:before,
493 | .ion-md-code-working:before,
494 | .ion-md-cog:before,
495 | .ion-md-color-fill:before,
496 | .ion-md-color-filter:before,
497 | .ion-md-color-palette:before,
498 | .ion-md-color-wand:before,
499 | .ion-md-compass:before,
500 | .ion-md-construct:before,
501 | .ion-md-contact:before,
502 | .ion-md-contacts:before,
503 | .ion-md-contract:before,
504 | .ion-md-contrast:before,
505 | .ion-md-copy:before,
506 | .ion-md-create:before,
507 | .ion-md-crop:before,
508 | .ion-md-cube:before,
509 | .ion-md-cut:before,
510 | .ion-md-desktop:before,
511 | .ion-md-disc:before,
512 | .ion-md-document:before,
513 | .ion-md-done-all:before,
514 | .ion-md-download:before,
515 | .ion-md-easel:before,
516 | .ion-md-egg:before,
517 | .ion-md-exit:before,
518 | .ion-md-expand:before,
519 | .ion-md-eye:before,
520 | .ion-md-eye-off:before,
521 | .ion-md-fastforward:before,
522 | .ion-md-female:before,
523 | .ion-md-filing:before,
524 | .ion-md-film:before,
525 | .ion-md-finger-print:before,
526 | .ion-md-fitness:before,
527 | .ion-md-flag:before,
528 | .ion-md-flame:before,
529 | .ion-md-flash:before,
530 | .ion-md-flash-off:before,
531 | .ion-md-flashlight:before,
532 | .ion-md-flask:before,
533 | .ion-md-flower:before,
534 | .ion-md-folder:before,
535 | .ion-md-folder-open:before,
536 | .ion-md-football:before,
537 | .ion-md-funnel:before,
538 | .ion-md-gift:before,
539 | .ion-md-git-branch:before,
540 | .ion-md-git-commit:before,
541 | .ion-md-git-compare:before,
542 | .ion-md-git-merge:before,
543 | .ion-md-git-network:before,
544 | .ion-md-git-pull-request:before,
545 | .ion-md-glasses:before,
546 | .ion-md-globe:before,
547 | .ion-md-grid:before,
548 | .ion-md-hammer:before,
549 | .ion-md-hand:before,
550 | .ion-md-happy:before,
551 | .ion-md-headset:before,
552 | .ion-md-heart:before,
553 | .ion-md-heart-dislike:before,
554 | .ion-md-heart-empty:before,
555 | .ion-md-heart-half:before,
556 | .ion-md-help:before,
557 | .ion-md-help-buoy:before,
558 | .ion-md-help-circle:before,
559 | .ion-md-help-circle-outline:before,
560 | .ion-md-home:before,
561 | .ion-md-hourglass:before,
562 | .ion-md-ice-cream:before,
563 | .ion-md-image:before,
564 | .ion-md-images:before,
565 | .ion-md-infinite:before,
566 | .ion-md-information:before,
567 | .ion-md-information-circle:before,
568 | .ion-md-information-circle-outline:before,
569 | .ion-md-jet:before,
570 | .ion-md-journal:before,
571 | .ion-md-key:before,
572 | .ion-md-keypad:before,
573 | .ion-md-laptop:before,
574 | .ion-md-leaf:before,
575 | .ion-md-link:before,
576 | .ion-md-list:before,
577 | .ion-md-list-box:before,
578 | .ion-md-locate:before,
579 | .ion-md-lock:before,
580 | .ion-md-log-in:before,
581 | .ion-md-log-out:before,
582 | .ion-md-magnet:before,
583 | .ion-md-mail:before,
584 | .ion-md-mail-open:before,
585 | .ion-md-mail-unread:before,
586 | .ion-md-male:before,
587 | .ion-md-man:before,
588 | .ion-md-map:before,
589 | .ion-md-medal:before,
590 | .ion-md-medical:before,
591 | .ion-md-medkit:before,
592 | .ion-md-megaphone:before,
593 | .ion-md-menu:before,
594 | .ion-md-mic:before,
595 | .ion-md-mic-off:before,
596 | .ion-md-microphone:before,
597 | .ion-md-moon:before,
598 | .ion-md-more:before,
599 | .ion-md-move:before,
600 | .ion-md-musical-note:before,
601 | .ion-md-musical-notes:before,
602 | .ion-md-navigate:before,
603 | .ion-md-notifications:before,
604 | .ion-md-notifications-off:before,
605 | .ion-md-notifications-outline:before,
606 | .ion-md-nuclear:before,
607 | .ion-md-nutrition:before,
608 | .ion-md-open:before,
609 | .ion-md-options:before,
610 | .ion-md-outlet:before,
611 | .ion-md-paper:before,
612 | .ion-md-paper-plane:before,
613 | .ion-md-partly-sunny:before,
614 | .ion-md-pause:before,
615 | .ion-md-paw:before,
616 | .ion-md-people:before,
617 | .ion-md-person:before,
618 | .ion-md-person-add:before,
619 | .ion-md-phone-landscape:before,
620 | .ion-md-phone-portrait:before,
621 | .ion-md-photos:before,
622 | .ion-md-pie:before,
623 | .ion-md-pin:before,
624 | .ion-md-pint:before,
625 | .ion-md-pizza:before,
626 | .ion-md-planet:before,
627 | .ion-md-play:before,
628 | .ion-md-play-circle:before,
629 | .ion-md-podium:before,
630 | .ion-md-power:before,
631 | .ion-md-pricetag:before,
632 | .ion-md-pricetags:before,
633 | .ion-md-print:before,
634 | .ion-md-pulse:before,
635 | .ion-md-qr-scanner:before,
636 | .ion-md-quote:before,
637 | .ion-md-radio:before,
638 | .ion-md-radio-button-off:before,
639 | .ion-md-radio-button-on:before,
640 | .ion-md-rainy:before,
641 | .ion-md-recording:before,
642 | .ion-md-redo:before,
643 | .ion-md-refresh:before,
644 | .ion-md-refresh-circle:before,
645 | .ion-md-remove:before,
646 | .ion-md-remove-circle:before,
647 | .ion-md-remove-circle-outline:before,
648 | .ion-md-reorder:before,
649 | .ion-md-repeat:before,
650 | .ion-md-resize:before,
651 | .ion-md-restaurant:before,
652 | .ion-md-return-left:before,
653 | .ion-md-return-right:before,
654 | .ion-md-reverse-camera:before,
655 | .ion-md-rewind:before,
656 | .ion-md-ribbon:before,
657 | .ion-md-rocket:before,
658 | .ion-md-rose:before,
659 | .ion-md-sad:before,
660 | .ion-md-save:before,
661 | .ion-md-school:before,
662 | .ion-md-search:before,
663 | .ion-md-send:before,
664 | .ion-md-settings:before,
665 | .ion-md-share:before,
666 | .ion-md-share-alt:before,
667 | .ion-md-shirt:before,
668 | .ion-md-shuffle:before,
669 | .ion-md-skip-backward:before,
670 | .ion-md-skip-forward:before,
671 | .ion-md-snow:before,
672 | .ion-md-speedometer:before,
673 | .ion-md-square:before,
674 | .ion-md-square-outline:before,
675 | .ion-md-star:before,
676 | .ion-md-star-half:before,
677 | .ion-md-star-outline:before,
678 | .ion-md-stats:before,
679 | .ion-md-stopwatch:before,
680 | .ion-md-subway:before,
681 | .ion-md-sunny:before,
682 | .ion-md-swap:before,
683 | .ion-md-switch:before,
684 | .ion-md-sync:before,
685 | .ion-md-tablet-landscape:before,
686 | .ion-md-tablet-portrait:before,
687 | .ion-md-tennisball:before,
688 | .ion-md-text:before,
689 | .ion-md-thermometer:before,
690 | .ion-md-thumbs-down:before,
691 | .ion-md-thumbs-up:before,
692 | .ion-md-thunderstorm:before,
693 | .ion-md-time:before,
694 | .ion-md-timer:before,
695 | .ion-md-today:before,
696 | .ion-md-train:before,
697 | .ion-md-transgender:before,
698 | .ion-md-trash:before,
699 | .ion-md-trending-down:before,
700 | .ion-md-trending-up:before,
701 | .ion-md-trophy:before,
702 | .ion-md-tv:before,
703 | .ion-md-umbrella:before,
704 | .ion-md-undo:before,
705 | .ion-md-unlock:before,
706 | .ion-md-videocam:before,
707 | .ion-md-volume-high:before,
708 | .ion-md-volume-low:before,
709 | .ion-md-volume-mute:before,
710 | .ion-md-volume-off:before,
711 | .ion-md-walk:before,
712 | .ion-md-wallet:before,
713 | .ion-md-warning:before,
714 | .ion-md-watch:before,
715 | .ion-md-water:before,
716 | .ion-md-wifi:before,
717 | .ion-md-wine:before,
718 | .ion-md-woman:before {
719 | display: inline-block;
720 | font-family: "Ionicons";
721 | speak: none;
722 | font-style: normal;
723 | font-weight: normal;
724 | font-variant: normal;
725 | text-transform: none;
726 | text-rendering: auto;
727 | line-height: 1;
728 | -webkit-font-smoothing: antialiased;
729 | -moz-osx-font-smoothing: grayscale;
730 | }
731 |
732 | .ion-spacedrop:before {
733 | content: "ﺢ";
734 | }
735 |
736 | .ion-spacedrop-small:before {
737 | content: "ﺡ";
738 | }
739 |
740 | .ion-ios-add:before {
741 | content: "";
742 | }
743 |
744 | .ion-ios-add-circle:before {
745 | content: "";
746 | }
747 |
748 | .ion-ios-add-circle-outline:before {
749 | content: "";
750 | }
751 |
752 | .ion-ios-airplane:before {
753 | content: "";
754 | }
755 |
756 | .ion-ios-alarm:before {
757 | content: "";
758 | }
759 |
760 | .ion-ios-albums:before {
761 | content: "";
762 | }
763 |
764 | .ion-ios-alert:before {
765 | content: "";
766 | }
767 |
768 | .ion-ios-american-football:before {
769 | content: "";
770 | }
771 |
772 | .ion-ios-analytics:before {
773 | content: "";
774 | }
775 |
776 | .ion-ios-aperture:before {
777 | content: "";
778 | }
779 |
780 | .ion-ios-apps:before {
781 | content: "";
782 | }
783 |
784 | .ion-ios-appstore:before {
785 | content: "";
786 | }
787 |
788 | .ion-ios-archive:before {
789 | content: "";
790 | }
791 |
792 | .ion-ios-arrow-back:before {
793 | content: "";
794 | }
795 |
796 | .ion-ios-arrow-down:before {
797 | content: "";
798 | }
799 |
800 | .ion-ios-arrow-dropdown:before {
801 | content: "";
802 | }
803 |
804 | .ion-ios-arrow-dropdown-circle:before {
805 | content: "";
806 | }
807 |
808 | .ion-ios-arrow-dropleft:before {
809 | content: "";
810 | }
811 |
812 | .ion-ios-arrow-dropleft-circle:before {
813 | content: "";
814 | }
815 |
816 | .ion-ios-arrow-dropright:before {
817 | content: "";
818 | }
819 |
820 | .ion-ios-arrow-dropright-circle:before {
821 | content: "";
822 | }
823 |
824 | .ion-ios-arrow-dropup:before {
825 | content: "";
826 | }
827 |
828 | .ion-ios-arrow-dropup-circle:before {
829 | content: "";
830 | }
831 |
832 | .ion-ios-arrow-forward:before {
833 | content: "";
834 | }
835 |
836 | .ion-ios-arrow-round-back:before {
837 | content: "";
838 | }
839 |
840 | .ion-ios-arrow-round-down:before {
841 | content: "";
842 | }
843 |
844 | .ion-ios-arrow-round-forward:before {
845 | content: "";
846 | }
847 |
848 | .ion-ios-arrow-round-up:before {
849 | content: "";
850 | }
851 |
852 | .ion-ios-arrow-up:before {
853 | content: "";
854 | }
855 |
856 | .ion-ios-at:before {
857 | content: "";
858 | }
859 |
860 | .ion-ios-attach:before {
861 | content: "";
862 | }
863 |
864 | .ion-ios-backspace:before {
865 | content: "";
866 | }
867 |
868 | .ion-ios-barcode:before {
869 | content: "";
870 | }
871 |
872 | .ion-ios-baseball:before {
873 | content: "";
874 | }
875 |
876 | .ion-ios-basket:before {
877 | content: "";
878 | }
879 |
880 | .ion-ios-basketball:before {
881 | content: "";
882 | }
883 |
884 | .ion-ios-battery-charging:before {
885 | content: "";
886 | }
887 |
888 | .ion-ios-battery-dead:before {
889 | content: "";
890 | }
891 |
892 | .ion-ios-battery-full:before {
893 | content: "";
894 | }
895 |
896 | .ion-ios-beaker:before {
897 | content: "";
898 | }
899 |
900 | .ion-ios-bed:before {
901 | content: "";
902 | }
903 |
904 | .ion-ios-beer:before {
905 | content: "";
906 | }
907 |
908 | .ion-ios-bicycle:before {
909 | content: "";
910 | }
911 |
912 | .ion-ios-bluetooth:before {
913 | content: "";
914 | }
915 |
916 | .ion-ios-boat:before {
917 | content: "";
918 | }
919 |
920 | .ion-ios-body:before {
921 | content: "";
922 | }
923 |
924 | .ion-ios-bonfire:before {
925 | content: "";
926 | }
927 |
928 | .ion-ios-book:before {
929 | content: "";
930 | }
931 |
932 | .ion-ios-bookmark:before {
933 | content: "";
934 | }
935 |
936 | .ion-ios-bookmarks:before {
937 | content: "";
938 | }
939 |
940 | .ion-ios-bowtie:before {
941 | content: "";
942 | }
943 |
944 | .ion-ios-briefcase:before {
945 | content: "";
946 | }
947 |
948 | .ion-ios-browsers:before {
949 | content: "";
950 | }
951 |
952 | .ion-ios-brush:before {
953 | content: "";
954 | }
955 |
956 | .ion-ios-bug:before {
957 | content: "";
958 | }
959 |
960 | .ion-ios-build:before {
961 | content: "";
962 | }
963 |
964 | .ion-ios-bulb:before {
965 | content: "";
966 | }
967 |
968 | .ion-ios-bus:before {
969 | content: "";
970 | }
971 |
972 | .ion-ios-business:before {
973 | content: "";
974 | }
975 |
976 | .ion-ios-cafe:before {
977 | content: "";
978 | }
979 |
980 | .ion-ios-calculator:before {
981 | content: "";
982 | }
983 |
984 | .ion-ios-calendar:before {
985 | content: "";
986 | }
987 |
988 | .ion-ios-call:before {
989 | content: "";
990 | }
991 |
992 | .ion-ios-camera:before {
993 | content: "";
994 | }
995 |
996 | .ion-ios-car:before {
997 | content: "";
998 | }
999 |
1000 | .ion-ios-card:before {
1001 | content: "";
1002 | }
1003 |
1004 | .ion-ios-cart:before {
1005 | content: "";
1006 | }
1007 |
1008 | .ion-ios-cash:before {
1009 | content: "";
1010 | }
1011 |
1012 | .ion-ios-cellular:before {
1013 | content: "";
1014 | }
1015 |
1016 | .ion-ios-chatboxes:before {
1017 | content: "";
1018 | }
1019 |
1020 | .ion-ios-chatbubbles:before {
1021 | content: "";
1022 | }
1023 |
1024 | .ion-ios-checkbox:before {
1025 | content: "";
1026 | }
1027 |
1028 | .ion-ios-checkbox-outline:before {
1029 | content: "";
1030 | }
1031 |
1032 | .ion-ios-checkmark:before {
1033 | content: "";
1034 | }
1035 |
1036 | .ion-ios-checkmark-circle:before {
1037 | content: "";
1038 | }
1039 |
1040 | .ion-ios-checkmark-circle-outline:before {
1041 | content: "";
1042 | }
1043 |
1044 | .ion-ios-clipboard:before {
1045 | content: "";
1046 | }
1047 |
1048 | .ion-ios-clock:before {
1049 | content: "";
1050 | }
1051 |
1052 | .ion-ios-close:before {
1053 | content: "";
1054 | }
1055 |
1056 | .ion-ios-close-circle:before {
1057 | content: "";
1058 | }
1059 |
1060 | .ion-ios-close-circle-outline:before {
1061 | content: "";
1062 | }
1063 |
1064 | .ion-ios-cloud:before {
1065 | content: "";
1066 | }
1067 |
1068 | .ion-ios-cloud-circle:before {
1069 | content: "";
1070 | }
1071 |
1072 | .ion-ios-cloud-done:before {
1073 | content: "";
1074 | }
1075 |
1076 | .ion-ios-cloud-download:before {
1077 | content: "";
1078 | }
1079 |
1080 | .ion-ios-cloud-outline:before {
1081 | content: "";
1082 | }
1083 |
1084 | .ion-ios-cloud-upload:before {
1085 | content: "";
1086 | }
1087 |
1088 | .ion-ios-cloudy:before {
1089 | content: "";
1090 | }
1091 |
1092 | .ion-ios-cloudy-night:before {
1093 | content: "";
1094 | }
1095 |
1096 | .ion-ios-code:before {
1097 | content: "";
1098 | }
1099 |
1100 | .ion-ios-code-download:before {
1101 | content: "";
1102 | }
1103 |
1104 | .ion-ios-code-working:before {
1105 | content: "";
1106 | }
1107 |
1108 | .ion-ios-cog:before {
1109 | content: "";
1110 | }
1111 |
1112 | .ion-ios-color-fill:before {
1113 | content: "";
1114 | }
1115 |
1116 | .ion-ios-color-filter:before {
1117 | content: "";
1118 | }
1119 |
1120 | .ion-ios-color-palette:before {
1121 | content: "";
1122 | }
1123 |
1124 | .ion-ios-color-wand:before {
1125 | content: "";
1126 | }
1127 |
1128 | .ion-ios-compass:before {
1129 | content: "";
1130 | }
1131 |
1132 | .ion-ios-construct:before {
1133 | content: "";
1134 | }
1135 |
1136 | .ion-ios-contact:before {
1137 | content: "";
1138 | }
1139 |
1140 | .ion-ios-contacts:before {
1141 | content: "";
1142 | }
1143 |
1144 | .ion-ios-contract:before {
1145 | content: "";
1146 | }
1147 |
1148 | .ion-ios-contrast:before {
1149 | content: "";
1150 | }
1151 |
1152 | .ion-ios-copy:before {
1153 | content: "";
1154 | }
1155 |
1156 | .ion-ios-create:before {
1157 | content: "";
1158 | }
1159 |
1160 | .ion-ios-crop:before {
1161 | content: "";
1162 | }
1163 |
1164 | .ion-ios-cube:before {
1165 | content: "";
1166 | }
1167 |
1168 | .ion-ios-cut:before {
1169 | content: "";
1170 | }
1171 |
1172 | .ion-ios-desktop:before {
1173 | content: "";
1174 | }
1175 |
1176 | .ion-ios-disc:before {
1177 | content: "";
1178 | }
1179 |
1180 | .ion-ios-document:before {
1181 | content: "";
1182 | }
1183 |
1184 | .ion-ios-done-all:before {
1185 | content: "";
1186 | }
1187 |
1188 | .ion-ios-download:before {
1189 | content: "";
1190 | }
1191 |
1192 | .ion-ios-easel:before {
1193 | content: "";
1194 | }
1195 |
1196 | .ion-ios-egg:before {
1197 | content: "";
1198 | }
1199 |
1200 | .ion-ios-exit:before {
1201 | content: "";
1202 | }
1203 |
1204 | .ion-ios-expand:before {
1205 | content: "";
1206 | }
1207 |
1208 | .ion-ios-eye:before {
1209 | content: "";
1210 | }
1211 |
1212 | .ion-ios-eye-off:before {
1213 | content: "";
1214 | }
1215 |
1216 | .ion-ios-fastforward:before {
1217 | content: "";
1218 | }
1219 |
1220 | .ion-ios-female:before {
1221 | content: "";
1222 | }
1223 |
1224 | .ion-ios-filing:before {
1225 | content: "";
1226 | }
1227 |
1228 | .ion-ios-film:before {
1229 | content: "";
1230 | }
1231 |
1232 | .ion-ios-finger-print:before {
1233 | content: "";
1234 | }
1235 |
1236 | .ion-ios-fitness:before {
1237 | content: "";
1238 | }
1239 |
1240 | .ion-ios-flag:before {
1241 | content: "";
1242 | }
1243 |
1244 | .ion-ios-flame:before {
1245 | content: "";
1246 | }
1247 |
1248 | .ion-ios-flash:before {
1249 | content: "";
1250 | }
1251 |
1252 | .ion-ios-flash-off:before {
1253 | content: "";
1254 | }
1255 |
1256 | .ion-ios-flashlight:before {
1257 | content: "";
1258 | }
1259 |
1260 | .ion-ios-flask:before {
1261 | content: "";
1262 | }
1263 |
1264 | .ion-ios-flower:before {
1265 | content: "";
1266 | }
1267 |
1268 | .ion-ios-folder:before {
1269 | content: "";
1270 | }
1271 |
1272 | .ion-ios-folder-open:before {
1273 | content: "";
1274 | }
1275 |
1276 | .ion-ios-football:before {
1277 | content: "";
1278 | }
1279 |
1280 | .ion-ios-funnel:before {
1281 | content: "";
1282 | }
1283 |
1284 | .ion-ios-gift:before {
1285 | content: "";
1286 | }
1287 |
1288 | .ion-ios-git-branch:before {
1289 | content: "";
1290 | }
1291 |
1292 | .ion-ios-git-commit:before {
1293 | content: "";
1294 | }
1295 |
1296 | .ion-ios-git-compare:before {
1297 | content: "";
1298 | }
1299 |
1300 | .ion-ios-git-merge:before {
1301 | content: "";
1302 | }
1303 |
1304 | .ion-ios-git-network:before {
1305 | content: "";
1306 | }
1307 |
1308 | .ion-ios-git-pull-request:before {
1309 | content: "";
1310 | }
1311 |
1312 | .ion-ios-glasses:before {
1313 | content: "";
1314 | }
1315 |
1316 | .ion-ios-globe:before {
1317 | content: "";
1318 | }
1319 |
1320 | .ion-ios-grid:before {
1321 | content: "";
1322 | }
1323 |
1324 | .ion-ios-hammer:before {
1325 | content: "";
1326 | }
1327 |
1328 | .ion-ios-hand:before {
1329 | content: "";
1330 | }
1331 |
1332 | .ion-ios-happy:before {
1333 | content: "";
1334 | }
1335 |
1336 | .ion-ios-headset:before {
1337 | content: "";
1338 | }
1339 |
1340 | .ion-ios-heart:before {
1341 | content: "";
1342 | }
1343 |
1344 | .ion-ios-heart-dislike:before {
1345 | content: "";
1346 | }
1347 |
1348 | .ion-ios-heart-empty:before {
1349 | content: "";
1350 | }
1351 |
1352 | .ion-ios-heart-half:before {
1353 | content: "";
1354 | }
1355 |
1356 | .ion-ios-help:before {
1357 | content: "";
1358 | }
1359 |
1360 | .ion-ios-help-buoy:before {
1361 | content: "";
1362 | }
1363 |
1364 | .ion-ios-help-circle:before {
1365 | content: "";
1366 | }
1367 |
1368 | .ion-ios-help-circle-outline:before {
1369 | content: "";
1370 | }
1371 |
1372 | .ion-ios-home:before {
1373 | content: "";
1374 | }
1375 |
1376 | .ion-ios-hourglass:before {
1377 | content: "";
1378 | }
1379 |
1380 | .ion-ios-ice-cream:before {
1381 | content: "";
1382 | }
1383 |
1384 | .ion-ios-image:before {
1385 | content: "";
1386 | }
1387 |
1388 | .ion-ios-images:before {
1389 | content: "";
1390 | }
1391 |
1392 | .ion-ios-infinite:before {
1393 | content: "";
1394 | }
1395 |
1396 | .ion-ios-information:before {
1397 | content: "";
1398 | }
1399 |
1400 | .ion-ios-information-circle:before {
1401 | content: "";
1402 | }
1403 |
1404 | .ion-ios-information-circle-outline:before {
1405 | content: "";
1406 | }
1407 |
1408 | .ion-ios-jet:before {
1409 | content: "";
1410 | }
1411 |
1412 | .ion-ios-journal:before {
1413 | content: "";
1414 | }
1415 |
1416 | .ion-ios-key:before {
1417 | content: "";
1418 | }
1419 |
1420 | .ion-ios-keypad:before {
1421 | content: "";
1422 | }
1423 |
1424 | .ion-ios-laptop:before {
1425 | content: "";
1426 | }
1427 |
1428 | .ion-ios-leaf:before {
1429 | content: "";
1430 | }
1431 |
1432 | .ion-ios-link:before {
1433 | content: "";
1434 | }
1435 |
1436 | .ion-ios-list:before {
1437 | content: "";
1438 | }
1439 |
1440 | .ion-ios-list-box:before {
1441 | content: "";
1442 | }
1443 |
1444 | .ion-ios-locate:before {
1445 | content: "";
1446 | }
1447 |
1448 | .ion-ios-lock:before {
1449 | content: "";
1450 | }
1451 |
1452 | .ion-ios-log-in:before {
1453 | content: "";
1454 | }
1455 |
1456 | .ion-ios-log-out:before {
1457 | content: "";
1458 | }
1459 |
1460 | .ion-ios-magnet:before {
1461 | content: "";
1462 | }
1463 |
1464 | .ion-ios-mail:before {
1465 | content: "";
1466 | }
1467 |
1468 | .ion-ios-mail-open:before {
1469 | content: "";
1470 | }
1471 |
1472 | .ion-ios-mail-unread:before {
1473 | content: "";
1474 | }
1475 |
1476 | .ion-ios-male:before {
1477 | content: "";
1478 | }
1479 |
1480 | .ion-ios-man:before {
1481 | content: "";
1482 | }
1483 |
1484 | .ion-ios-map:before {
1485 | content: "";
1486 | }
1487 |
1488 | .ion-ios-medal:before {
1489 | content: "";
1490 | }
1491 |
1492 | .ion-ios-medical:before {
1493 | content: "";
1494 | }
1495 |
1496 | .ion-ios-medkit:before {
1497 | content: "";
1498 | }
1499 |
1500 | .ion-ios-megaphone:before {
1501 | content: "";
1502 | }
1503 |
1504 | .ion-ios-menu:before {
1505 | content: "";
1506 | }
1507 |
1508 | .ion-ios-mic:before {
1509 | content: "";
1510 | }
1511 |
1512 | .ion-ios-mic-off:before {
1513 | content: "";
1514 | }
1515 |
1516 | .ion-ios-microphone:before {
1517 | content: "";
1518 | }
1519 |
1520 | .ion-ios-moon:before {
1521 | content: "";
1522 | }
1523 |
1524 | .ion-ios-more:before {
1525 | content: "";
1526 | }
1527 |
1528 | .ion-ios-move:before {
1529 | content: "";
1530 | }
1531 |
1532 | .ion-ios-musical-note:before {
1533 | content: "";
1534 | }
1535 |
1536 | .ion-ios-musical-notes:before {
1537 | content: "";
1538 | }
1539 |
1540 | .ion-ios-navigate:before {
1541 | content: "";
1542 | }
1543 |
1544 | .ion-ios-notifications:before {
1545 | content: "";
1546 | }
1547 |
1548 | .ion-ios-notifications-off:before {
1549 | content: "";
1550 | }
1551 |
1552 | .ion-ios-notifications-outline:before {
1553 | content: "";
1554 | }
1555 |
1556 | .ion-ios-nuclear:before {
1557 | content: "";
1558 | }
1559 |
1560 | .ion-ios-nutrition:before {
1561 | content: "";
1562 | }
1563 |
1564 | .ion-ios-open:before {
1565 | content: "";
1566 | }
1567 |
1568 | .ion-ios-options:before {
1569 | content: "";
1570 | }
1571 |
1572 | .ion-ios-outlet:before {
1573 | content: "";
1574 | }
1575 |
1576 | .ion-ios-paper:before {
1577 | content: "";
1578 | }
1579 |
1580 | .ion-ios-paper-plane:before {
1581 | content: "";
1582 | }
1583 |
1584 | .ion-ios-partly-sunny:before {
1585 | content: "";
1586 | }
1587 |
1588 | .ion-ios-pause:before {
1589 | content: "";
1590 | }
1591 |
1592 | .ion-ios-paw:before {
1593 | content: "";
1594 | }
1595 |
1596 | .ion-ios-people:before {
1597 | content: "";
1598 | }
1599 |
1600 | .ion-ios-person:before {
1601 | content: "";
1602 | }
1603 |
1604 | .ion-ios-person-add:before {
1605 | content: "";
1606 | }
1607 |
1608 | .ion-ios-phone-landscape:before {
1609 | content: "";
1610 | }
1611 |
1612 | .ion-ios-phone-portrait:before {
1613 | content: "";
1614 | }
1615 |
1616 | .ion-ios-photos:before {
1617 | content: "";
1618 | }
1619 |
1620 | .ion-ios-pie:before {
1621 | content: "";
1622 | }
1623 |
1624 | .ion-ios-pin:before {
1625 | content: "";
1626 | }
1627 |
1628 | .ion-ios-pint:before {
1629 | content: "";
1630 | }
1631 |
1632 | .ion-ios-pizza:before {
1633 | content: "";
1634 | }
1635 |
1636 | .ion-ios-planet:before {
1637 | content: "";
1638 | }
1639 |
1640 | .ion-ios-play:before {
1641 | content: "";
1642 | }
1643 |
1644 | .ion-ios-play-circle:before {
1645 | content: "";
1646 | }
1647 |
1648 | .ion-ios-podium:before {
1649 | content: "";
1650 | }
1651 |
1652 | .ion-ios-power:before {
1653 | content: "";
1654 | }
1655 |
1656 | .ion-ios-pricetag:before {
1657 | content: "";
1658 | }
1659 |
1660 | .ion-ios-pricetags:before {
1661 | content: "";
1662 | }
1663 |
1664 | .ion-ios-print:before {
1665 | content: "";
1666 | }
1667 |
1668 | .ion-ios-pulse:before {
1669 | content: "";
1670 | }
1671 |
1672 | .ion-ios-qr-scanner:before {
1673 | content: "";
1674 | }
1675 |
1676 | .ion-ios-quote:before {
1677 | content: "";
1678 | }
1679 |
1680 | .ion-ios-radio:before {
1681 | content: "";
1682 | }
1683 |
1684 | .ion-ios-radio-button-off:before {
1685 | content: "";
1686 | }
1687 |
1688 | .ion-ios-radio-button-on:before {
1689 | content: "";
1690 | }
1691 |
1692 | .ion-ios-rainy:before {
1693 | content: "";
1694 | }
1695 |
1696 | .ion-ios-recording:before {
1697 | content: "";
1698 | }
1699 |
1700 | .ion-ios-redo:before {
1701 | content: "";
1702 | }
1703 |
1704 | .ion-ios-refresh:before {
1705 | content: "";
1706 | }
1707 |
1708 | .ion-ios-refresh-circle:before {
1709 | content: "";
1710 | }
1711 |
1712 | .ion-ios-remove:before {
1713 | content: "";
1714 | }
1715 |
1716 | .ion-ios-remove-circle:before {
1717 | content: "";
1718 | }
1719 |
1720 | .ion-ios-remove-circle-outline:before {
1721 | content: "";
1722 | }
1723 |
1724 | .ion-ios-reorder:before {
1725 | content: "";
1726 | }
1727 |
1728 | .ion-ios-repeat:before {
1729 | content: "";
1730 | }
1731 |
1732 | .ion-ios-resize:before {
1733 | content: "";
1734 | }
1735 |
1736 | .ion-ios-restaurant:before {
1737 | content: "";
1738 | }
1739 |
1740 | .ion-ios-return-left:before {
1741 | content: "";
1742 | }
1743 |
1744 | .ion-ios-return-right:before {
1745 | content: "";
1746 | }
1747 |
1748 | .ion-ios-reverse-camera:before {
1749 | content: "";
1750 | }
1751 |
1752 | .ion-ios-rewind:before {
1753 | content: "";
1754 | }
1755 |
1756 | .ion-ios-ribbon:before {
1757 | content: "";
1758 | }
1759 |
1760 | .ion-ios-rocket:before {
1761 | content: "";
1762 | }
1763 |
1764 | .ion-ios-rose:before {
1765 | content: "";
1766 | }
1767 |
1768 | .ion-ios-sad:before {
1769 | content: "";
1770 | }
1771 |
1772 | .ion-ios-save:before {
1773 | content: "";
1774 | }
1775 |
1776 | .ion-ios-school:before {
1777 | content: "";
1778 | }
1779 |
1780 | .ion-ios-search:before {
1781 | content: "";
1782 | }
1783 |
1784 | .ion-ios-send:before {
1785 | content: "";
1786 | }
1787 |
1788 | .ion-ios-settings:before {
1789 | content: "";
1790 | }
1791 |
1792 | .ion-ios-share:before {
1793 | content: "";
1794 | }
1795 |
1796 | .ion-ios-share-alt:before {
1797 | content: "";
1798 | }
1799 |
1800 | .ion-ios-shirt:before {
1801 | content: "";
1802 | }
1803 |
1804 | .ion-ios-shuffle:before {
1805 | content: "";
1806 | }
1807 |
1808 | .ion-ios-skip-backward:before {
1809 | content: "";
1810 | }
1811 |
1812 | .ion-ios-skip-forward:before {
1813 | content: "";
1814 | }
1815 |
1816 | .ion-ios-snow:before {
1817 | content: "";
1818 | }
1819 |
1820 | .ion-ios-speedometer:before {
1821 | content: "";
1822 | }
1823 |
1824 | .ion-ios-square:before {
1825 | content: "";
1826 | }
1827 |
1828 | .ion-ios-square-outline:before {
1829 | content: "";
1830 | }
1831 |
1832 | .ion-ios-star:before {
1833 | content: "";
1834 | }
1835 |
1836 | .ion-ios-star-half:before {
1837 | content: "";
1838 | }
1839 |
1840 | .ion-ios-star-outline:before {
1841 | content: "";
1842 | }
1843 |
1844 | .ion-ios-stats:before {
1845 | content: "";
1846 | }
1847 |
1848 | .ion-ios-stopwatch:before {
1849 | content: "";
1850 | }
1851 |
1852 | .ion-ios-subway:before {
1853 | content: "";
1854 | }
1855 |
1856 | .ion-ios-sunny:before {
1857 | content: "";
1858 | }
1859 |
1860 | .ion-ios-swap:before {
1861 | content: "";
1862 | }
1863 |
1864 | .ion-ios-switch:before {
1865 | content: "";
1866 | }
1867 |
1868 | .ion-ios-sync:before {
1869 | content: "";
1870 | }
1871 |
1872 | .ion-ios-tablet-landscape:before {
1873 | content: "";
1874 | }
1875 |
1876 | .ion-ios-tablet-portrait:before {
1877 | content: "";
1878 | }
1879 |
1880 | .ion-ios-tennisball:before {
1881 | content: "";
1882 | }
1883 |
1884 | .ion-ios-text:before {
1885 | content: "";
1886 | }
1887 |
1888 | .ion-ios-thermometer:before {
1889 | content: "";
1890 | }
1891 |
1892 | .ion-ios-thumbs-down:before {
1893 | content: "";
1894 | }
1895 |
1896 | .ion-ios-thumbs-up:before {
1897 | content: "";
1898 | }
1899 |
1900 | .ion-ios-thunderstorm:before {
1901 | content: "";
1902 | }
1903 |
1904 | .ion-ios-time:before {
1905 | content: "";
1906 | }
1907 |
1908 | .ion-ios-timer:before {
1909 | content: "";
1910 | }
1911 |
1912 | .ion-ios-today:before {
1913 | content: "";
1914 | }
1915 |
1916 | .ion-ios-train:before {
1917 | content: "";
1918 | }
1919 |
1920 | .ion-ios-transgender:before {
1921 | content: "";
1922 | }
1923 |
1924 | .ion-ios-trash:before {
1925 | content: "";
1926 | }
1927 |
1928 | .ion-ios-trending-down:before {
1929 | content: "";
1930 | }
1931 |
1932 | .ion-ios-trending-up:before {
1933 | content: "";
1934 | }
1935 |
1936 | .ion-ios-trophy:before {
1937 | content: "";
1938 | }
1939 |
1940 | .ion-ios-tv:before {
1941 | content: "";
1942 | }
1943 |
1944 | .ion-ios-umbrella:before {
1945 | content: "";
1946 | }
1947 |
1948 | .ion-ios-undo:before {
1949 | content: "";
1950 | }
1951 |
1952 | .ion-ios-unlock:before {
1953 | content: "";
1954 | }
1955 |
1956 | .ion-ios-videocam:before {
1957 | content: "";
1958 | }
1959 |
1960 | .ion-ios-volume-high:before {
1961 | content: "";
1962 | }
1963 |
1964 | .ion-ios-volume-low:before {
1965 | content: "";
1966 | }
1967 |
1968 | .ion-ios-volume-mute:before {
1969 | content: "";
1970 | }
1971 |
1972 | .ion-ios-volume-off:before {
1973 | content: "";
1974 | }
1975 |
1976 | .ion-ios-walk:before {
1977 | content: "";
1978 | }
1979 |
1980 | .ion-ios-wallet:before {
1981 | content: "";
1982 | }
1983 |
1984 | .ion-ios-warning:before {
1985 | content: "";
1986 | }
1987 |
1988 | .ion-ios-watch:before {
1989 | content: "";
1990 | }
1991 |
1992 | .ion-ios-water:before {
1993 | content: "";
1994 | }
1995 |
1996 | .ion-ios-wifi:before {
1997 | content: "";
1998 | }
1999 |
2000 | .ion-ios-wine:before {
2001 | content: "";
2002 | }
2003 |
2004 | .ion-ios-woman:before {
2005 | content: "";
2006 | }
2007 |
2008 | .ion-logo-android:before {
2009 | content: "";
2010 | }
2011 |
2012 | .ion-logo-angular:before {
2013 | content: "";
2014 | }
2015 |
2016 | .ion-logo-apple:before {
2017 | content: "";
2018 | }
2019 |
2020 | .ion-logo-bitbucket:before {
2021 | content: "";
2022 | }
2023 |
2024 | .ion-logo-bitcoin:before {
2025 | content: "";
2026 | }
2027 |
2028 | .ion-logo-buffer:before {
2029 | content: "";
2030 | }
2031 |
2032 | .ion-logo-chrome:before {
2033 | content: "";
2034 | }
2035 |
2036 | .ion-logo-closed-captioning:before {
2037 | content: "";
2038 | }
2039 |
2040 | .ion-logo-codepen:before {
2041 | content: "";
2042 | }
2043 |
2044 | .ion-logo-css3:before {
2045 | content: "";
2046 | }
2047 |
2048 | .ion-logo-designernews:before {
2049 | content: "";
2050 | }
2051 |
2052 | .ion-logo-dribbble:before {
2053 | content: "";
2054 | }
2055 |
2056 | .ion-logo-dropbox:before {
2057 | content: "";
2058 | }
2059 |
2060 | .ion-logo-euro:before {
2061 | content: "";
2062 | }
2063 |
2064 | .ion-logo-facebook:before {
2065 | content: "";
2066 | }
2067 |
2068 | .ion-logo-flickr:before {
2069 | content: "";
2070 | }
2071 |
2072 | .ion-logo-foursquare:before {
2073 | content: "";
2074 | }
2075 |
2076 | .ion-logo-freebsd-devil:before {
2077 | content: "";
2078 | }
2079 |
2080 | .ion-logo-game-controller-a:before {
2081 | content: "";
2082 | }
2083 |
2084 | .ion-logo-game-controller-b:before {
2085 | content: "";
2086 | }
2087 |
2088 | .ion-logo-github:before {
2089 | content: "";
2090 | }
2091 |
2092 | .ion-logo-google:before {
2093 | content: "";
2094 | }
2095 |
2096 | .ion-logo-googleplus:before {
2097 | content: "";
2098 | }
2099 |
2100 | .ion-logo-hackernews:before {
2101 | content: "";
2102 | }
2103 |
2104 | .ion-logo-html5:before {
2105 | content: "";
2106 | }
2107 |
2108 | .ion-logo-instagram:before {
2109 | content: "";
2110 | }
2111 |
2112 | .ion-logo-ionic:before {
2113 | content: "";
2114 | }
2115 |
2116 | .ion-logo-ionitron:before {
2117 | content: "";
2118 | }
2119 |
2120 | .ion-logo-javascript:before {
2121 | content: "";
2122 | }
2123 |
2124 | .ion-logo-linkedin:before {
2125 | content: "";
2126 | }
2127 |
2128 | .ion-logo-markdown:before {
2129 | content: "";
2130 | }
2131 |
2132 | .ion-logo-model-s:before {
2133 | content: "";
2134 | }
2135 |
2136 | .ion-logo-no-smoking:before {
2137 | content: "";
2138 | }
2139 |
2140 | .ion-logo-nodejs:before {
2141 | content: "";
2142 | }
2143 |
2144 | .ion-logo-npm:before {
2145 | content: "";
2146 | }
2147 |
2148 | .ion-logo-octocat:before {
2149 | content: "";
2150 | }
2151 |
2152 | .ion-logo-pinterest:before {
2153 | content: "";
2154 | }
2155 |
2156 | .ion-logo-playstation:before {
2157 | content: "";
2158 | }
2159 |
2160 | .ion-logo-polymer:before {
2161 | content: "";
2162 | }
2163 |
2164 | .ion-logo-python:before {
2165 | content: "";
2166 | }
2167 |
2168 | .ion-logo-reddit:before {
2169 | content: "";
2170 | }
2171 |
2172 | .ion-logo-rss:before {
2173 | content: "";
2174 | }
2175 |
2176 | .ion-logo-sass:before {
2177 | content: "";
2178 | }
2179 |
2180 | .ion-logo-skype:before {
2181 | content: "";
2182 | }
2183 |
2184 | .ion-logo-slack:before {
2185 | content: "";
2186 | }
2187 |
2188 | .ion-logo-snapchat:before {
2189 | content: "";
2190 | }
2191 |
2192 | .ion-logo-steam:before {
2193 | content: "";
2194 | }
2195 |
2196 | .ion-logo-tumblr:before {
2197 | content: "";
2198 | }
2199 |
2200 | .ion-logo-tux:before {
2201 | content: "";
2202 | }
2203 |
2204 | .ion-logo-twitch:before {
2205 | content: "";
2206 | }
2207 |
2208 | .ion-logo-twitter:before {
2209 | content: "";
2210 | }
2211 |
2212 | .ion-logo-usd:before {
2213 | content: "";
2214 | }
2215 |
2216 | .ion-logo-vimeo:before {
2217 | content: "";
2218 | }
2219 |
2220 | .ion-logo-vk:before {
2221 | content: "";
2222 | }
2223 |
2224 | .ion-logo-whatsapp:before {
2225 | content: "";
2226 | }
2227 |
2228 | .ion-logo-windows:before {
2229 | content: "";
2230 | }
2231 |
2232 | .ion-logo-wordpress:before {
2233 | content: "";
2234 | }
2235 |
2236 | .ion-logo-xbox:before {
2237 | content: "";
2238 | }
2239 |
2240 | .ion-logo-xing:before {
2241 | content: "";
2242 | }
2243 |
2244 | .ion-logo-yahoo:before {
2245 | content: "";
2246 | }
2247 |
2248 | .ion-logo-yen:before {
2249 | content: "";
2250 | }
2251 |
2252 | .ion-logo-youtube:before {
2253 | content: "";
2254 | }
2255 |
2256 | .ion-md-add:before {
2257 | content: "";
2258 | }
2259 |
2260 | .ion-md-add-circle:before {
2261 | content: "";
2262 | }
2263 |
2264 | .ion-md-add-circle-outline:before {
2265 | content: "";
2266 | }
2267 |
2268 | .ion-md-airplane:before {
2269 | content: "";
2270 | }
2271 |
2272 | .ion-md-alarm:before {
2273 | content: "";
2274 | }
2275 |
2276 | .ion-md-albums:before {
2277 | content: "";
2278 | }
2279 |
2280 | .ion-md-alert:before {
2281 | content: "";
2282 | }
2283 |
2284 | .ion-md-american-football:before {
2285 | content: "";
2286 | }
2287 |
2288 | .ion-md-analytics:before {
2289 | content: "";
2290 | }
2291 |
2292 | .ion-md-aperture:before {
2293 | content: "";
2294 | }
2295 |
2296 | .ion-md-apps:before {
2297 | content: "";
2298 | }
2299 |
2300 | .ion-md-appstore:before {
2301 | content: "";
2302 | }
2303 |
2304 | .ion-md-archive:before {
2305 | content: "";
2306 | }
2307 |
2308 | .ion-md-arrow-back:before {
2309 | content: "";
2310 | }
2311 |
2312 | .ion-md-arrow-down:before {
2313 | content: "";
2314 | }
2315 |
2316 | .ion-md-arrow-dropdown:before {
2317 | content: "";
2318 | }
2319 |
2320 | .ion-md-arrow-dropdown-circle:before {
2321 | content: "";
2322 | }
2323 |
2324 | .ion-md-arrow-dropleft:before {
2325 | content: "";
2326 | }
2327 |
2328 | .ion-md-arrow-dropleft-circle:before {
2329 | content: "";
2330 | }
2331 |
2332 | .ion-md-arrow-dropright:before {
2333 | content: "";
2334 | }
2335 |
2336 | .ion-md-arrow-dropright-circle:before {
2337 | content: "";
2338 | }
2339 |
2340 | .ion-md-arrow-dropup:before {
2341 | content: "";
2342 | }
2343 |
2344 | .ion-md-arrow-dropup-circle:before {
2345 | content: "";
2346 | }
2347 |
2348 | .ion-md-arrow-forward:before {
2349 | content: "";
2350 | }
2351 |
2352 | .ion-md-arrow-round-back:before {
2353 | content: "";
2354 | }
2355 |
2356 | .ion-md-arrow-round-down:before {
2357 | content: "";
2358 | }
2359 |
2360 | .ion-md-arrow-round-forward:before {
2361 | content: "";
2362 | }
2363 |
2364 | .ion-md-arrow-round-up:before {
2365 | content: "";
2366 | }
2367 |
2368 | .ion-md-arrow-up:before {
2369 | content: "";
2370 | }
2371 |
2372 | .ion-md-at:before {
2373 | content: "";
2374 | }
2375 |
2376 | .ion-md-attach:before {
2377 | content: "";
2378 | }
2379 |
2380 | .ion-md-backspace:before {
2381 | content: "";
2382 | }
2383 |
2384 | .ion-md-barcode:before {
2385 | content: "";
2386 | }
2387 |
2388 | .ion-md-baseball:before {
2389 | content: "";
2390 | }
2391 |
2392 | .ion-md-basket:before {
2393 | content: "";
2394 | }
2395 |
2396 | .ion-md-basketball:before {
2397 | content: "";
2398 | }
2399 |
2400 | .ion-md-battery-charging:before {
2401 | content: "";
2402 | }
2403 |
2404 | .ion-md-battery-dead:before {
2405 | content: "";
2406 | }
2407 |
2408 | .ion-md-battery-full:before {
2409 | content: "";
2410 | }
2411 |
2412 | .ion-md-beaker:before {
2413 | content: "";
2414 | }
2415 |
2416 | .ion-md-bed:before {
2417 | content: "";
2418 | }
2419 |
2420 | .ion-md-beer:before {
2421 | content: "";
2422 | }
2423 |
2424 | .ion-md-bicycle:before {
2425 | content: "";
2426 | }
2427 |
2428 | .ion-md-bluetooth:before {
2429 | content: "";
2430 | }
2431 |
2432 | .ion-md-boat:before {
2433 | content: "";
2434 | }
2435 |
2436 | .ion-md-body:before {
2437 | content: "";
2438 | }
2439 |
2440 | .ion-md-bonfire:before {
2441 | content: "";
2442 | }
2443 |
2444 | .ion-md-book:before {
2445 | content: "";
2446 | }
2447 |
2448 | .ion-md-bookmark:before {
2449 | content: "";
2450 | }
2451 |
2452 | .ion-md-bookmarks:before {
2453 | content: "";
2454 | }
2455 |
2456 | .ion-md-bowtie:before {
2457 | content: "";
2458 | }
2459 |
2460 | .ion-md-briefcase:before {
2461 | content: "";
2462 | }
2463 |
2464 | .ion-md-browsers:before {
2465 | content: "";
2466 | }
2467 |
2468 | .ion-md-brush:before {
2469 | content: "";
2470 | }
2471 |
2472 | .ion-md-bug:before {
2473 | content: "";
2474 | }
2475 |
2476 | .ion-md-build:before {
2477 | content: "";
2478 | }
2479 |
2480 | .ion-md-bulb:before {
2481 | content: "";
2482 | }
2483 |
2484 | .ion-md-bus:before {
2485 | content: "";
2486 | }
2487 |
2488 | .ion-md-business:before {
2489 | content: "";
2490 | }
2491 |
2492 | .ion-md-cafe:before {
2493 | content: "";
2494 | }
2495 |
2496 | .ion-md-calculator:before {
2497 | content: "";
2498 | }
2499 |
2500 | .ion-md-calendar:before {
2501 | content: "";
2502 | }
2503 |
2504 | .ion-md-call:before {
2505 | content: "";
2506 | }
2507 |
2508 | .ion-md-camera:before {
2509 | content: "";
2510 | }
2511 |
2512 | .ion-md-car:before {
2513 | content: "";
2514 | }
2515 |
2516 | .ion-md-card:before {
2517 | content: "";
2518 | }
2519 |
2520 | .ion-md-cart:before {
2521 | content: "";
2522 | }
2523 |
2524 | .ion-md-cash:before {
2525 | content: "";
2526 | }
2527 |
2528 | .ion-md-cellular:before {
2529 | content: "";
2530 | }
2531 |
2532 | .ion-md-chatboxes:before {
2533 | content: "";
2534 | }
2535 |
2536 | .ion-md-chatbubbles:before {
2537 | content: "";
2538 | }
2539 |
2540 | .ion-md-checkbox:before {
2541 | content: "";
2542 | }
2543 |
2544 | .ion-md-checkbox-outline:before {
2545 | content: "";
2546 | }
2547 |
2548 | .ion-md-checkmark:before {
2549 | content: "";
2550 | }
2551 |
2552 | .ion-md-checkmark-circle:before {
2553 | content: "";
2554 | }
2555 |
2556 | .ion-md-checkmark-circle-outline:before {
2557 | content: "";
2558 | }
2559 |
2560 | .ion-md-clipboard:before {
2561 | content: "";
2562 | }
2563 |
2564 | .ion-md-clock:before {
2565 | content: "";
2566 | }
2567 |
2568 | .ion-md-close:before {
2569 | content: "";
2570 | }
2571 |
2572 | .ion-md-close-circle:before {
2573 | content: "";
2574 | }
2575 |
2576 | .ion-md-close-circle-outline:before {
2577 | content: "";
2578 | }
2579 |
2580 | .ion-md-cloud:before {
2581 | content: "";
2582 | }
2583 |
2584 | .ion-md-cloud-circle:before {
2585 | content: "";
2586 | }
2587 |
2588 | .ion-md-cloud-done:before {
2589 | content: "";
2590 | }
2591 |
2592 | .ion-md-cloud-download:before {
2593 | content: "";
2594 | }
2595 |
2596 | .ion-md-cloud-outline:before {
2597 | content: "";
2598 | }
2599 |
2600 | .ion-md-cloud-upload:before {
2601 | content: "";
2602 | }
2603 |
2604 | .ion-md-cloudy:before {
2605 | content: "";
2606 | }
2607 |
2608 | .ion-md-cloudy-night:before {
2609 | content: "";
2610 | }
2611 |
2612 | .ion-md-code:before {
2613 | content: "";
2614 | }
2615 |
2616 | .ion-md-code-download:before {
2617 | content: "";
2618 | }
2619 |
2620 | .ion-md-code-working:before {
2621 | content: "";
2622 | }
2623 |
2624 | .ion-md-cog:before {
2625 | content: "";
2626 | }
2627 |
2628 | .ion-md-color-fill:before {
2629 | content: "";
2630 | }
2631 |
2632 | .ion-md-color-filter:before {
2633 | content: "";
2634 | }
2635 |
2636 | .ion-md-color-palette:before {
2637 | content: "";
2638 | }
2639 |
2640 | .ion-md-color-wand:before {
2641 | content: "";
2642 | }
2643 |
2644 | .ion-md-compass:before {
2645 | content: "";
2646 | }
2647 |
2648 | .ion-md-construct:before {
2649 | content: "";
2650 | }
2651 |
2652 | .ion-md-contact:before {
2653 | content: "";
2654 | }
2655 |
2656 | .ion-md-contacts:before {
2657 | content: "";
2658 | }
2659 |
2660 | .ion-md-contract:before {
2661 | content: "";
2662 | }
2663 |
2664 | .ion-md-contrast:before {
2665 | content: "";
2666 | }
2667 |
2668 | .ion-md-copy:before {
2669 | content: "";
2670 | }
2671 |
2672 | .ion-md-create:before {
2673 | content: "";
2674 | }
2675 |
2676 | .ion-md-crop:before {
2677 | content: "";
2678 | }
2679 |
2680 | .ion-md-cube:before {
2681 | content: "";
2682 | }
2683 |
2684 | .ion-md-cut:before {
2685 | content: "";
2686 | }
2687 |
2688 | .ion-md-desktop:before {
2689 | content: "";
2690 | }
2691 |
2692 | .ion-md-disc:before {
2693 | content: "";
2694 | }
2695 |
2696 | .ion-md-document:before {
2697 | content: "";
2698 | }
2699 |
2700 | .ion-md-done-all:before {
2701 | content: "";
2702 | }
2703 |
2704 | .ion-md-download:before {
2705 | content: "";
2706 | }
2707 |
2708 | .ion-md-easel:before {
2709 | content: "";
2710 | }
2711 |
2712 | .ion-md-egg:before {
2713 | content: "";
2714 | }
2715 |
2716 | .ion-md-exit:before {
2717 | content: "";
2718 | }
2719 |
2720 | .ion-md-expand:before {
2721 | content: "";
2722 | }
2723 |
2724 | .ion-md-eye:before {
2725 | content: "";
2726 | }
2727 |
2728 | .ion-md-eye-off:before {
2729 | content: "";
2730 | }
2731 |
2732 | .ion-md-fastforward:before {
2733 | content: "";
2734 | }
2735 |
2736 | .ion-md-female:before {
2737 | content: "";
2738 | }
2739 |
2740 | .ion-md-filing:before {
2741 | content: "";
2742 | }
2743 |
2744 | .ion-md-film:before {
2745 | content: "";
2746 | }
2747 |
2748 | .ion-md-finger-print:before {
2749 | content: "";
2750 | }
2751 |
2752 | .ion-md-fitness:before {
2753 | content: "";
2754 | }
2755 |
2756 | .ion-md-flag:before {
2757 | content: "";
2758 | }
2759 |
2760 | .ion-md-flame:before {
2761 | content: "";
2762 | }
2763 |
2764 | .ion-md-flash:before {
2765 | content: "";
2766 | }
2767 |
2768 | .ion-md-flash-off:before {
2769 | content: "";
2770 | }
2771 |
2772 | .ion-md-flashlight:before {
2773 | content: "";
2774 | }
2775 |
2776 | .ion-md-flask:before {
2777 | content: "";
2778 | }
2779 |
2780 | .ion-md-flower:before {
2781 | content: "";
2782 | }
2783 |
2784 | .ion-md-folder:before {
2785 | content: "";
2786 | }
2787 |
2788 | .ion-md-folder-open:before {
2789 | content: "";
2790 | }
2791 |
2792 | .ion-md-football:before {
2793 | content: "";
2794 | }
2795 |
2796 | .ion-md-funnel:before {
2797 | content: "";
2798 | }
2799 |
2800 | .ion-md-gift:before {
2801 | content: "";
2802 | }
2803 |
2804 | .ion-md-git-branch:before {
2805 | content: "";
2806 | }
2807 |
2808 | .ion-md-git-commit:before {
2809 | content: "";
2810 | }
2811 |
2812 | .ion-md-git-compare:before {
2813 | content: "";
2814 | }
2815 |
2816 | .ion-md-git-merge:before {
2817 | content: "";
2818 | }
2819 |
2820 | .ion-md-git-network:before {
2821 | content: "";
2822 | }
2823 |
2824 | .ion-md-git-pull-request:before {
2825 | content: "";
2826 | }
2827 |
2828 | .ion-md-glasses:before {
2829 | content: "";
2830 | }
2831 |
2832 | .ion-md-globe:before {
2833 | content: "";
2834 | }
2835 |
2836 | .ion-md-grid:before {
2837 | content: "";
2838 | }
2839 |
2840 | .ion-md-hammer:before {
2841 | content: "";
2842 | }
2843 |
2844 | .ion-md-hand:before {
2845 | content: "";
2846 | }
2847 |
2848 | .ion-md-happy:before {
2849 | content: "";
2850 | }
2851 |
2852 | .ion-md-headset:before {
2853 | content: "";
2854 | }
2855 |
2856 | .ion-md-heart:before {
2857 | content: "";
2858 | }
2859 |
2860 | .ion-md-heart-dislike:before {
2861 | content: "";
2862 | }
2863 |
2864 | .ion-md-heart-empty:before {
2865 | content: "";
2866 | }
2867 |
2868 | .ion-md-heart-half:before {
2869 | content: "";
2870 | }
2871 |
2872 | .ion-md-help:before {
2873 | content: "";
2874 | }
2875 |
2876 | .ion-md-help-buoy:before {
2877 | content: "";
2878 | }
2879 |
2880 | .ion-md-help-circle:before {
2881 | content: "";
2882 | }
2883 |
2884 | .ion-md-help-circle-outline:before {
2885 | content: "";
2886 | }
2887 |
2888 | .ion-md-home:before {
2889 | content: "";
2890 | }
2891 |
2892 | .ion-md-hourglass:before {
2893 | content: "";
2894 | }
2895 |
2896 | .ion-md-ice-cream:before {
2897 | content: "";
2898 | }
2899 |
2900 | .ion-md-image:before {
2901 | content: "";
2902 | }
2903 |
2904 | .ion-md-images:before {
2905 | content: "";
2906 | }
2907 |
2908 | .ion-md-infinite:before {
2909 | content: "";
2910 | }
2911 |
2912 | .ion-md-information:before {
2913 | content: "";
2914 | }
2915 |
2916 | .ion-md-information-circle:before {
2917 | content: "";
2918 | }
2919 |
2920 | .ion-md-information-circle-outline:before {
2921 | content: "";
2922 | }
2923 |
2924 | .ion-md-jet:before {
2925 | content: "";
2926 | }
2927 |
2928 | .ion-md-journal:before {
2929 | content: "";
2930 | }
2931 |
2932 | .ion-md-key:before {
2933 | content: "";
2934 | }
2935 |
2936 | .ion-md-keypad:before {
2937 | content: "";
2938 | }
2939 |
2940 | .ion-md-laptop:before {
2941 | content: "";
2942 | }
2943 |
2944 | .ion-md-leaf:before {
2945 | content: "";
2946 | }
2947 |
2948 | .ion-md-link:before {
2949 | content: "";
2950 | }
2951 |
2952 | .ion-md-list:before {
2953 | content: "";
2954 | }
2955 |
2956 | .ion-md-list-box:before {
2957 | content: "";
2958 | }
2959 |
2960 | .ion-md-locate:before {
2961 | content: "";
2962 | }
2963 |
2964 | .ion-md-lock:before {
2965 | content: "";
2966 | }
2967 |
2968 | .ion-md-log-in:before {
2969 | content: "";
2970 | }
2971 |
2972 | .ion-md-log-out:before {
2973 | content: "";
2974 | }
2975 |
2976 | .ion-md-magnet:before {
2977 | content: "";
2978 | }
2979 |
2980 | .ion-md-mail:before {
2981 | content: "";
2982 | }
2983 |
2984 | .ion-md-mail-open:before {
2985 | content: "";
2986 | }
2987 |
2988 | .ion-md-mail-unread:before {
2989 | content: "";
2990 | }
2991 |
2992 | .ion-md-male:before {
2993 | content: "";
2994 | }
2995 |
2996 | .ion-md-man:before {
2997 | content: "";
2998 | }
2999 |
3000 | .ion-md-map:before {
3001 | content: "";
3002 | }
3003 |
3004 | .ion-md-medal:before {
3005 | content: "";
3006 | }
3007 |
3008 | .ion-md-medical:before {
3009 | content: "";
3010 | }
3011 |
3012 | .ion-md-medkit:before {
3013 | content: "";
3014 | }
3015 |
3016 | .ion-md-megaphone:before {
3017 | content: "";
3018 | }
3019 |
3020 | .ion-md-menu:before {
3021 | content: "";
3022 | }
3023 |
3024 | .ion-md-mic:before {
3025 | content: "";
3026 | }
3027 |
3028 | .ion-md-mic-off:before {
3029 | content: "";
3030 | }
3031 |
3032 | .ion-md-microphone:before {
3033 | content: "";
3034 | }
3035 |
3036 | .ion-md-moon:before {
3037 | content: "";
3038 | }
3039 |
3040 | .ion-md-more:before {
3041 | content: "";
3042 | }
3043 |
3044 | .ion-md-move:before {
3045 | content: "";
3046 | }
3047 |
3048 | .ion-md-musical-note:before {
3049 | content: "";
3050 | }
3051 |
3052 | .ion-md-musical-notes:before {
3053 | content: "";
3054 | }
3055 |
3056 | .ion-md-navigate:before {
3057 | content: "";
3058 | }
3059 |
3060 | .ion-md-notifications:before {
3061 | content: "";
3062 | }
3063 |
3064 | .ion-md-notifications-off:before {
3065 | content: "";
3066 | }
3067 |
3068 | .ion-md-notifications-outline:before {
3069 | content: "";
3070 | }
3071 |
3072 | .ion-md-nuclear:before {
3073 | content: "";
3074 | }
3075 |
3076 | .ion-md-nutrition:before {
3077 | content: "";
3078 | }
3079 |
3080 | .ion-md-open:before {
3081 | content: "";
3082 | }
3083 |
3084 | .ion-md-options:before {
3085 | content: "";
3086 | }
3087 |
3088 | .ion-md-outlet:before {
3089 | content: "";
3090 | }
3091 |
3092 | .ion-md-paper:before {
3093 | content: "";
3094 | }
3095 |
3096 | .ion-md-paper-plane:before {
3097 | content: "";
3098 | }
3099 |
3100 | .ion-md-partly-sunny:before {
3101 | content: "";
3102 | }
3103 |
3104 | .ion-md-pause:before {
3105 | content: "";
3106 | }
3107 |
3108 | .ion-md-paw:before {
3109 | content: "";
3110 | }
3111 |
3112 | .ion-md-people:before {
3113 | content: "";
3114 | }
3115 |
3116 | .ion-md-person:before {
3117 | content: "";
3118 | }
3119 |
3120 | .ion-md-person-add:before {
3121 | content: "";
3122 | }
3123 |
3124 | .ion-md-phone-landscape:before {
3125 | content: "";
3126 | }
3127 |
3128 | .ion-md-phone-portrait:before {
3129 | content: "";
3130 | }
3131 |
3132 | .ion-md-photos:before {
3133 | content: "";
3134 | }
3135 |
3136 | .ion-md-pie:before {
3137 | content: "";
3138 | }
3139 |
3140 | .ion-md-pin:before {
3141 | content: "";
3142 | }
3143 |
3144 | .ion-md-pint:before {
3145 | content: "";
3146 | }
3147 |
3148 | .ion-md-pizza:before {
3149 | content: "";
3150 | }
3151 |
3152 | .ion-md-planet:before {
3153 | content: "";
3154 | }
3155 |
3156 | .ion-md-play:before {
3157 | content: "";
3158 | }
3159 |
3160 | .ion-md-play-circle:before {
3161 | content: "";
3162 | }
3163 |
3164 | .ion-md-podium:before {
3165 | content: "";
3166 | }
3167 |
3168 | .ion-md-power:before {
3169 | content: "";
3170 | }
3171 |
3172 | .ion-md-pricetag:before {
3173 | content: "";
3174 | }
3175 |
3176 | .ion-md-pricetags:before {
3177 | content: "";
3178 | }
3179 |
3180 | .ion-md-print:before {
3181 | content: "";
3182 | }
3183 |
3184 | .ion-md-pulse:before {
3185 | content: "";
3186 | }
3187 |
3188 | .ion-md-qr-scanner:before {
3189 | content: "";
3190 | }
3191 |
3192 | .ion-md-quote:before {
3193 | content: "";
3194 | }
3195 |
3196 | .ion-md-radio:before {
3197 | content: "";
3198 | }
3199 |
3200 | .ion-md-radio-button-off:before {
3201 | content: "";
3202 | }
3203 |
3204 | .ion-md-radio-button-on:before {
3205 | content: "";
3206 | }
3207 |
3208 | .ion-md-rainy:before {
3209 | content: "";
3210 | }
3211 |
3212 | .ion-md-recording:before {
3213 | content: "";
3214 | }
3215 |
3216 | .ion-md-redo:before {
3217 | content: "";
3218 | }
3219 |
3220 | .ion-md-refresh:before {
3221 | content: "";
3222 | }
3223 |
3224 | .ion-md-refresh-circle:before {
3225 | content: "";
3226 | }
3227 |
3228 | .ion-md-remove:before {
3229 | content: "";
3230 | }
3231 |
3232 | .ion-md-remove-circle:before {
3233 | content: "";
3234 | }
3235 |
3236 | .ion-md-remove-circle-outline:before {
3237 | content: "";
3238 | }
3239 |
3240 | .ion-md-reorder:before {
3241 | content: "";
3242 | }
3243 |
3244 | .ion-md-repeat:before {
3245 | content: "";
3246 | }
3247 |
3248 | .ion-md-resize:before {
3249 | content: "";
3250 | }
3251 |
3252 | .ion-md-restaurant:before {
3253 | content: "";
3254 | }
3255 |
3256 | .ion-md-return-left:before {
3257 | content: "";
3258 | }
3259 |
3260 | .ion-md-return-right:before {
3261 | content: "";
3262 | }
3263 |
3264 | .ion-md-reverse-camera:before {
3265 | content: "";
3266 | }
3267 |
3268 | .ion-md-rewind:before {
3269 | content: "";
3270 | }
3271 |
3272 | .ion-md-ribbon:before {
3273 | content: "";
3274 | }
3275 |
3276 | .ion-md-rocket:before {
3277 | content: "";
3278 | }
3279 |
3280 | .ion-md-rose:before {
3281 | content: "";
3282 | }
3283 |
3284 | .ion-md-sad:before {
3285 | content: "";
3286 | }
3287 |
3288 | .ion-md-save:before {
3289 | content: "";
3290 | }
3291 |
3292 | .ion-md-school:before {
3293 | content: "";
3294 | }
3295 |
3296 | .ion-md-search:before {
3297 | content: "";
3298 | }
3299 |
3300 | .ion-md-send:before {
3301 | content: "";
3302 | }
3303 |
3304 | .ion-md-settings:before {
3305 | content: "";
3306 | }
3307 |
3308 | .ion-md-share:before {
3309 | content: "";
3310 | }
3311 |
3312 | .ion-md-share-alt:before {
3313 | content: "";
3314 | }
3315 |
3316 | .ion-md-shirt:before {
3317 | content: "";
3318 | }
3319 |
3320 | .ion-md-shuffle:before {
3321 | content: "";
3322 | }
3323 |
3324 | .ion-md-skip-backward:before {
3325 | content: "";
3326 | }
3327 |
3328 | .ion-md-skip-forward:before {
3329 | content: "";
3330 | }
3331 |
3332 | .ion-md-snow:before {
3333 | content: "";
3334 | }
3335 |
3336 | .ion-md-speedometer:before {
3337 | content: "";
3338 | }
3339 |
3340 | .ion-md-square:before {
3341 | content: "";
3342 | }
3343 |
3344 | .ion-md-square-outline:before {
3345 | content: "";
3346 | }
3347 |
3348 | .ion-md-star:before {
3349 | content: "";
3350 | }
3351 |
3352 | .ion-md-star-half:before {
3353 | content: "";
3354 | }
3355 |
3356 | .ion-md-star-outline:before {
3357 | content: "";
3358 | }
3359 |
3360 | .ion-md-stats:before {
3361 | content: "";
3362 | }
3363 |
3364 | .ion-md-stopwatch:before {
3365 | content: "";
3366 | }
3367 |
3368 | .ion-md-subway:before {
3369 | content: "";
3370 | }
3371 |
3372 | .ion-md-sunny:before {
3373 | content: "";
3374 | }
3375 |
3376 | .ion-md-swap:before {
3377 | content: "";
3378 | }
3379 |
3380 | .ion-md-switch:before {
3381 | content: "";
3382 | }
3383 |
3384 | .ion-md-sync:before {
3385 | content: "";
3386 | }
3387 |
3388 | .ion-md-tablet-landscape:before {
3389 | content: "";
3390 | }
3391 |
3392 | .ion-md-tablet-portrait:before {
3393 | content: "";
3394 | }
3395 |
3396 | .ion-md-tennisball:before {
3397 | content: "";
3398 | }
3399 |
3400 | .ion-md-text:before {
3401 | content: "";
3402 | }
3403 |
3404 | .ion-md-thermometer:before {
3405 | content: "";
3406 | }
3407 |
3408 | .ion-md-thumbs-down:before {
3409 | content: "";
3410 | }
3411 |
3412 | .ion-md-thumbs-up:before {
3413 | content: "";
3414 | }
3415 |
3416 | .ion-md-thunderstorm:before {
3417 | content: "";
3418 | }
3419 |
3420 | .ion-md-time:before {
3421 | content: "";
3422 | }
3423 |
3424 | .ion-md-timer:before {
3425 | content: "";
3426 | }
3427 |
3428 | .ion-md-today:before {
3429 | content: "";
3430 | }
3431 |
3432 | .ion-md-train:before {
3433 | content: "";
3434 | }
3435 |
3436 | .ion-md-transgender:before {
3437 | content: "";
3438 | }
3439 |
3440 | .ion-md-trash:before {
3441 | content: "";
3442 | }
3443 |
3444 | .ion-md-trending-down:before {
3445 | content: "";
3446 | }
3447 |
3448 | .ion-md-trending-up:before {
3449 | content: "";
3450 | }
3451 |
3452 | .ion-md-trophy:before {
3453 | content: "";
3454 | }
3455 |
3456 | .ion-md-tv:before {
3457 | content: "";
3458 | }
3459 |
3460 | .ion-md-umbrella:before {
3461 | content: "";
3462 | }
3463 |
3464 | .ion-md-undo:before {
3465 | content: "";
3466 | }
3467 |
3468 | .ion-md-unlock:before {
3469 | content: "";
3470 | }
3471 |
3472 | .ion-md-videocam:before {
3473 | content: "";
3474 | }
3475 |
3476 | .ion-md-volume-high:before {
3477 | content: "";
3478 | }
3479 |
3480 | .ion-md-volume-low:before {
3481 | content: "";
3482 | }
3483 |
3484 | .ion-md-volume-mute:before {
3485 | content: "";
3486 | }
3487 |
3488 | .ion-md-volume-off:before {
3489 | content: "";
3490 | }
3491 |
3492 | .ion-md-walk:before {
3493 | content: "";
3494 | }
3495 |
3496 | .ion-md-wallet:before {
3497 | content: "";
3498 | }
3499 |
3500 | .ion-md-warning:before {
3501 | content: "";
3502 | }
3503 |
3504 | .ion-md-watch:before {
3505 | content: "";
3506 | }
3507 |
3508 | .ion-md-water:before {
3509 | content: "";
3510 | }
3511 |
3512 | .ion-md-wifi:before {
3513 | content: "";
3514 | }
3515 |
3516 | .ion-md-wine:before {
3517 | content: "";
3518 | }
3519 |
3520 | .ion-md-woman:before {
3521 | content: "";
3522 | }
--------------------------------------------------------------------------------