├── packages
├── p2p-chat-utils
│ ├── noop.js
│ ├── has.js
│ ├── arr2keys.js
│ ├── pickBy.js
│ ├── ensure-unique-file.js
│ ├── event-all.js
│ ├── package.json
│ ├── parse-chunks.js
│ ├── EventObservable.js
│ ├── is-ip-larger.js
│ ├── each.js
│ ├── get-new-address.js
│ ├── event-replaceable.js
│ ├── index.js
│ ├── pickByMap.js
│ ├── get-port.js
│ ├── md5.js
│ ├── ensure-unique-filename.js
│ └── ipset.js
├── p2p-chat-core
│ ├── .eslintrc
│ ├── index.js
│ ├── lib
│ │ ├── defaultOpts.js
│ │ ├── msgTypes.js
│ │ ├── ensureMergeIPset.js
│ │ ├── getTag.js
│ │ ├── Store.js
│ │ ├── enhanceSocket
│ │ │ ├── index.js
│ │ │ └── Parse.js
│ │ ├── login.js
│ │ ├── connect.js
│ │ ├── fileHanderMakers.js
│ │ ├── actions.js
│ │ ├── fileInfoPool.js
│ │ ├── Chat.js
│ │ └── socketHandler.js
│ └── package.json
├── p2p-chat
│ ├── .gitignore
│ ├── src
│ │ ├── components
│ │ │ ├── Aside
│ │ │ │ ├── ChatList
│ │ │ │ │ ├── ChatList.scss
│ │ │ │ │ ├── DialogType.js
│ │ │ │ │ ├── redux.js
│ │ │ │ │ └── index.js
│ │ │ │ └── ListItem
│ │ │ │ │ ├── ListItem.scss
│ │ │ │ │ └── index.js
│ │ │ ├── Settings
│ │ │ │ ├── MyInfo
│ │ │ │ │ ├── MyInfo.scss
│ │ │ │ │ └── index.js
│ │ │ │ ├── validators.js
│ │ │ │ ├── Login
│ │ │ │ │ ├── redux.js
│ │ │ │ │ └── index.js
│ │ │ │ ├── ConnectRange
│ │ │ │ │ └── index.js
│ │ │ │ ├── Connect
│ │ │ │ │ └── index.js
│ │ │ │ └── CreateChannel
│ │ │ │ │ └── index.js
│ │ │ ├── Chatting
│ │ │ │ ├── FilePanel
│ │ │ │ │ ├── FileReceive.scss
│ │ │ │ │ ├── FilePanel.scss
│ │ │ │ │ ├── constants.js
│ │ │ │ │ ├── FileInfo.js
│ │ │ │ │ ├── FileReceive.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ └── redux.js
│ │ │ │ ├── Messages
│ │ │ │ │ ├── Messages.scss
│ │ │ │ │ ├── Text.scss
│ │ │ │ │ ├── Text.js
│ │ │ │ │ └── index.js
│ │ │ │ └── Dialog
│ │ │ │ │ ├── Dialog.scss
│ │ │ │ │ ├── subscribe.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ └── redux.js
│ │ │ └── Common
│ │ │ │ ├── CustomCard
│ │ │ │ └── index.js
│ │ │ │ └── Modal
│ │ │ │ ├── index.scss
│ │ │ │ └── index.js
│ │ ├── views
│ │ │ ├── Settings.scss
│ │ │ ├── SettingsRedux.js
│ │ │ ├── AsideRedux.js
│ │ │ ├── ChattingRedux.js
│ │ │ ├── ModalBtn.js
│ │ │ ├── ModalBtnRedux.js
│ │ │ ├── Aside.js
│ │ │ ├── Chatting.js
│ │ │ └── Settings.js
│ │ ├── global
│ │ │ ├── devToolsMiddleware.js
│ │ │ ├── render.js
│ │ │ ├── subscribe.js
│ │ │ ├── reducer.js
│ │ │ ├── redux.js
│ │ │ └── ipc.js
│ │ ├── utils
│ │ │ ├── constants.js
│ │ │ ├── getNewState.js
│ │ │ ├── storage.js
│ │ │ ├── createReducer.js
│ │ │ ├── message.js
│ │ │ ├── watchState.js
│ │ │ ├── hot.js
│ │ │ └── format.js
│ │ ├── index.ejs
│ │ ├── layouts
│ │ │ ├── DevTools.js
│ │ │ ├── global.scss
│ │ │ └── App.js
│ │ ├── index.js
│ │ └── selectors
│ │ │ └── chatInfo.js
│ ├── build
│ │ ├── icon.ico
│ │ ├── icons
│ │ │ ├── 32x32.png
│ │ │ └── 512x512.png
│ │ └── build-win-portable.js
│ ├── main
│ │ ├── count.js
│ │ ├── makePlainError.js
│ │ ├── handleError.js
│ │ ├── loadUserConf.js
│ │ ├── setContextMenu.js
│ │ ├── menu.js
│ │ ├── worker.js
│ │ └── index.js
│ ├── config
│ │ ├── generateScopedName.js
│ │ ├── analyzer.js
│ │ ├── dep-externals.js
│ │ ├── css.js
│ │ ├── devServer.js
│ │ ├── main.config.js
│ │ └── renderer.config.js
│ ├── jsconfig.json
│ ├── webpack.config.js
│ └── package.json
└── p2p-chat-logger
│ ├── package.json
│ ├── README.md
│ ├── index.d.ts
│ └── index.js
├── .eslintignore
├── .prettierignore
├── postcss.config.js
├── .eslintrc
├── package.json
├── LICENSE
├── .gitignore
└── README.md
/packages/p2p-chat-utils/noop.js:
--------------------------------------------------------------------------------
1 | module.exports = () => {}
2 |
--------------------------------------------------------------------------------
/packages/p2p-chat-core/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "dgeibi"
3 | }
4 |
--------------------------------------------------------------------------------
/packages/p2p-chat/.gitignore:
--------------------------------------------------------------------------------
1 | /index.js*
2 | /worker.js*
3 | report.html
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Aside/ChatList/ChatList.scss:
--------------------------------------------------------------------------------
1 | .menu {
2 | border-right: 0;
3 | }
4 |
--------------------------------------------------------------------------------
/packages/p2p-chat-core/index.js:
--------------------------------------------------------------------------------
1 | const Chat = require('./lib/Chat')
2 |
3 | module.exports = new Chat()
4 |
--------------------------------------------------------------------------------
/packages/p2p-chat/build/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dgeibi/p2p-chat/HEAD/packages/p2p-chat/build/icon.ico
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | assets/
4 | public/
5 | /packages/p2p-chat/index.js*
6 | /packages/p2p-chat/worker.js*
--------------------------------------------------------------------------------
/packages/p2p-chat/src/views/Settings.scss:
--------------------------------------------------------------------------------
1 | .settings {
2 | padding: 10px 11px;
3 | border-bottom: 1px solid #eee;
4 | }
5 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | assets/
4 | public/
5 | /packages/p2p-chat/index.js*
6 | /packages/p2p-chat/worker.js*
--------------------------------------------------------------------------------
/packages/p2p-chat/build/icons/32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dgeibi/p2p-chat/HEAD/packages/p2p-chat/build/icons/32x32.png
--------------------------------------------------------------------------------
/packages/p2p-chat/build/icons/512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dgeibi/p2p-chat/HEAD/packages/p2p-chat/build/icons/512x512.png
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Aside/ChatList/DialogType.js:
--------------------------------------------------------------------------------
1 | export default {
2 | USER: 'user',
3 | CHANNEL: 'channel',
4 | }
5 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/global/devToolsMiddleware.js:
--------------------------------------------------------------------------------
1 | import DevTools from '../layouts/DevTools'
2 |
3 | export default DevTools.instrument()
4 |
--------------------------------------------------------------------------------
/packages/p2p-chat-core/lib/defaultOpts.js:
--------------------------------------------------------------------------------
1 | const defaultOpts = {
2 | username: 'anonymous',
3 | port: 8087,
4 | }
5 |
6 | module.exports = defaultOpts
7 |
--------------------------------------------------------------------------------
/packages/p2p-chat-utils/has.js:
--------------------------------------------------------------------------------
1 | const hasOwn = Object.prototype.hasOwnProperty
2 | const has = (obj, key) => hasOwn.call(obj, key)
3 | module.exports = has
4 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Settings/MyInfo/MyInfo.scss:
--------------------------------------------------------------------------------
1 | .my-info {
2 | color: rgba(0, 0, 0, 0.85);
3 | margin-top: 5px;
4 | padding-left: 2px;
5 | }
6 |
--------------------------------------------------------------------------------
/packages/p2p-chat/main/count.js:
--------------------------------------------------------------------------------
1 | export default function count(limit = 4) {
2 | let tick = 0
3 | return () => {
4 | if (tick > limit) return false
5 | tick += 1
6 | return true
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/p2p-chat/config/generateScopedName.js:
--------------------------------------------------------------------------------
1 | module.exports = (localName, resourcePath) => {
2 | const componentName = resourcePath.split('/').slice(-2, -1)
3 | return `${componentName}_${localName}`
4 | }
5 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/utils/constants.js:
--------------------------------------------------------------------------------
1 | export default (constants = {}, prefix) => {
2 | Object.keys(constants).forEach(key => {
3 | constants[key] = `${prefix}/${key}` // eslint-disable-line
4 | })
5 | }
6 |
--------------------------------------------------------------------------------
/packages/p2p-chat-utils/arr2keys.js:
--------------------------------------------------------------------------------
1 | module.exports = function arr2keys(arr) {
2 | return arr.reduce((obj, key) => {
3 | obj[key] = 1 // eslint-disable-line no-param-reassign
4 | return obj
5 | }, {})
6 | }
7 |
--------------------------------------------------------------------------------
/packages/p2p-chat/main/makePlainError.js:
--------------------------------------------------------------------------------
1 | export default err => {
2 | if (!err) return null
3 | const { message, stack, name, code, errno, syscall } = err
4 | return { message, stack, name, code, errno, syscall, ...err }
5 | }
6 |
--------------------------------------------------------------------------------
/packages/p2p-chat-utils/pickBy.js:
--------------------------------------------------------------------------------
1 | const pickBy = (src, testFn) =>
2 | Object.keys(src).reduce((o, k) => {
3 | if (testFn(src[k], k, src)) o[k] = src[k] // eslint-disable-line
4 | return o
5 | }, {})
6 | module.exports = pickBy
7 |
--------------------------------------------------------------------------------
/packages/p2p-chat/main/handleError.js:
--------------------------------------------------------------------------------
1 | import electron from 'electron'
2 |
3 | process.on('uncaughtException', err => {
4 | electron.dialog.showErrorBox(
5 | 'A JavaScript error occurred in the main process',
6 | err.stack
7 | )
8 | })
9 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/views/SettingsRedux.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux'
2 |
3 | import login, * as loginActions from '../components/Settings/Login/redux'
4 |
5 | export default combineReducers({ login })
6 | export { loginActions }
7 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = ({ file, env }) => ({
2 | parser: file.extname === '.scss' ? 'postcss-scss' : false,
3 | plugins: {
4 | precss: file.extname === '.scss',
5 | cssnano: env === 'production' ? {} : false,
6 | },
7 | })
8 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/views/AsideRedux.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux'
2 |
3 | import chatList, * as chatListActions from '../components/Aside/ChatList/redux'
4 |
5 | export default combineReducers({ chatList })
6 | export { chatListActions }
7 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "dgeibi/react"
4 | ],
5 | "rules": {
6 | "import/no-extraneous-dependencies": "off",
7 | "class-methods-use-this": "off",
8 | "global-require": "off",
9 | "import/first": "off"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Chatting/FilePanel/FileReceive.scss:
--------------------------------------------------------------------------------
1 | .filename {
2 | font-weight: 700;
3 | }
4 |
5 | .card > div {
6 | white-space: nowrap;
7 | word-break: break-all;
8 | text-overflow: ellipsis;
9 | overflow: hidden;
10 | }
11 |
--------------------------------------------------------------------------------
/packages/p2p-chat/config/analyzer.js:
--------------------------------------------------------------------------------
1 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
2 |
3 | module.exports = config => {
4 | config.plugin(BundleAnalyzerPlugin, [
5 | {
6 | analyzerMode: 'static',
7 | },
8 | ])
9 | }
10 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Chatting/Messages/Messages.scss:
--------------------------------------------------------------------------------
1 | .messages {
2 | flex-grow: 1;
3 | overflow-y: auto;
4 | overflow-x: hidden;
5 | margin-bottom: 16px;
6 | padding: 0 5px 0 0;
7 | }
8 |
9 | .alert {
10 | margin-bottom: 10px;
11 | }
12 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/utils/getNewState.js:
--------------------------------------------------------------------------------
1 | const getNewState = (state, key, ...restKeys) => {
2 | const newState = Object.assign({}, state)
3 | if (key) newState[key] = getNewState(newState[key], ...restKeys)
4 | return newState
5 | }
6 |
7 | export default getNewState
8 |
--------------------------------------------------------------------------------
/packages/p2p-chat-core/lib/msgTypes.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | CHANNEL_CREATE: 'channel-create',
3 | FILE_ACCEPTED: 'file-accepted',
4 | TEXT: 'text',
5 | FILEINFO: 'fileinfo',
6 | FILE: 'file',
7 | GREETING: 'greeting',
8 | GREETING_REPLY: 'greeting-reply',
9 | }
10 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/index.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <%= htmlWebpackPlugin.options.title %>
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Common/CustomCard/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Card } from 'antd'
3 |
4 | const style = {
5 | width: '150px',
6 | padding: '8px 10px',
7 | overflow: 'hidden',
8 | }
9 | export default props =>
10 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Common/Modal/index.scss:
--------------------------------------------------------------------------------
1 | .modal :global .ant-modal-body {
2 | max-height: calc(100vh - 130px);
3 | overflow-y: auto;
4 | }
5 |
6 | .modal {
7 | top: 15px;
8 | width: 520px !important;
9 | margin: 0 auto !important;
10 | padding-bottom: 0;
11 | }
12 |
--------------------------------------------------------------------------------
/packages/p2p-chat-logger/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "p2p-chat-logger",
3 | "version": "1.4.5",
4 | "description": "logger",
5 | "private": true,
6 | "main": "index.js",
7 | "author": "dgeibi",
8 | "license": "MIT",
9 | "dependencies": {
10 | "chalk": "^2.0.1"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/utils/storage.js:
--------------------------------------------------------------------------------
1 | export default {
2 | set(key, value) {
3 | localStorage.setItem(key, JSON.stringify(value))
4 | },
5 | get(key) {
6 | try {
7 | return JSON.parse(localStorage.getItem(key))
8 | } catch (e) {
9 | return null
10 | }
11 | },
12 | }
13 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Aside/ListItem/ListItem.scss:
--------------------------------------------------------------------------------
1 | .latest__badge {
2 | color: #fff;
3 | padding: 0 6px;
4 | font-size: 12px;
5 | border-radius: 10px;
6 | height: 20px;
7 | font-family: tahoma;
8 | background: #87d068;
9 | }
10 |
11 | .title-offline.title {
12 | color: grey;
13 | }
14 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Chatting/FilePanel/FilePanel.scss:
--------------------------------------------------------------------------------
1 | .filePanel {
2 | padding: 5px;
3 | display: flex;
4 | height: 120px;
5 | overflow-x: auto;
6 | }
7 |
8 | .clearWrapper {
9 | padding: 0 3px;
10 | display: inline-flex;
11 | align-items: center;
12 | justify-content: center;
13 | }
14 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Chatting/FilePanel/constants.js:
--------------------------------------------------------------------------------
1 | export const fileLoadStates = {
2 | success: 'success',
3 | waitting: 'waitting',
4 | active: 'active',
5 | exception: 'exception',
6 | }
7 |
8 | export const cardTypes = {
9 | RECEIVE: 'file:receive',
10 | INFO: 'file:info',
11 | }
12 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/global/render.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import App from '../layouts/App'
4 |
5 | export default (store, history) => {
6 | ReactDOM.render(
7 | ,
8 | document.getElementById('root')
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/packages/p2p-chat-utils/ensure-unique-file.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs-extra')
2 | const ensureFilename = require('./ensure-unique-filename')
3 |
4 | module.exports = pathname => {
5 | if (!pathname) throw Error('should pass a pathname')
6 | const path = ensureFilename(pathname)
7 | fs.ensureFileSync(path)
8 | return path
9 | }
10 |
--------------------------------------------------------------------------------
/packages/p2p-chat/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "experimentalDecorators": true,
5 | "jsx": "react",
6 | "allowSyntheticDefaultImports": true,
7 | "module": "commonjs",
8 | },
9 | "include": [
10 | "src/**/*"
11 | ],
12 | "exclude": [
13 | "node_modules"
14 | ]
15 | }
--------------------------------------------------------------------------------
/packages/p2p-chat/src/utils/createReducer.js:
--------------------------------------------------------------------------------
1 | import has from 'p2p-chat-utils/has'
2 |
3 | const createReducer = (reducerMap, initialState) => (state = initialState, action) => {
4 | if (has(reducerMap, action.type)) {
5 | return reducerMap[action.type](state, action)
6 | }
7 | return state
8 | }
9 |
10 | export default createReducer
11 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/views/ChattingRedux.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux'
2 |
3 | import dialog, * as dialogActions from '../components/Chatting/Dialog/redux'
4 | import filePanel, * as filePanelActions from '../components/Chatting/FilePanel/redux'
5 |
6 | export default combineReducers({ dialog, filePanel })
7 | export { dialogActions, filePanelActions }
8 |
--------------------------------------------------------------------------------
/packages/p2p-chat-utils/event-all.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @param {Array} meta
3 | */
4 | module.exports = (meta, callback) => {
5 | const stats = meta.map(([emitter, name], index) => {
6 | emitter.once(name, () => {
7 | stats[index] = true
8 | if (stats.every(x => x)) {
9 | callback()
10 | }
11 | })
12 |
13 | return false
14 | })
15 | }
16 |
--------------------------------------------------------------------------------
/packages/p2p-chat-utils/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "p2p-chat-utils",
3 | "version": "1.4.3",
4 | "description": "",
5 | "private": true,
6 | "main": "index.js",
7 | "scripts": {
8 | "test": "echo \"Error: no test specified\" && exit 1"
9 | },
10 | "author": "dgeibi",
11 | "license": "MIT",
12 | "dependencies": {
13 | "fs-extra": "^5.0.0"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/packages/p2p-chat/webpack.config.js:
--------------------------------------------------------------------------------
1 | module.exports = (env = {}) => {
2 | const mode = env.production ? 'production' : 'development'
3 | process.env.NODE_ENV = mode
4 |
5 | return [require('./config/renderer.config'), require('./config/main.config')].map(x => {
6 | if (typeof x === 'function') {
7 | x = x(env) // eslint-disable-line
8 | }
9 | return x
10 | })
11 | }
12 |
--------------------------------------------------------------------------------
/packages/p2p-chat-utils/parse-chunks.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Creates a stream to parse from ndjson-body.
3 | * @param {Array} chunks - array of buffers
4 | * @returns {?object}
5 | */
6 | const parseChunks = chunks => {
7 | const buffer = Buffer.concat(chunks)
8 | try {
9 | return JSON.parse(buffer.toString())
10 | } catch (e) {
11 | return null
12 | }
13 | }
14 |
15 | module.exports = parseChunks
16 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/layouts/DevTools.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { createDevTools } from 'redux-devtools'
3 | import LogMonitor from 'redux-devtools-log-monitor'
4 | import DockMonitor from 'redux-devtools-dock-monitor'
5 |
6 | export default createDevTools(
7 |
8 |
9 |
10 | )
11 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/utils/message.js:
--------------------------------------------------------------------------------
1 | import { message } from 'antd'
2 |
3 | export function showError(error) {
4 | if (error && typeof error === 'object') {
5 | message.error(error.message)
6 | console.error(error.stack) // eslint-disable-line
7 | } else if (typeof error === 'string') {
8 | message.error(error)
9 | }
10 | }
11 |
12 | export function showInfo(msg) {
13 | message.info(msg)
14 | }
15 |
--------------------------------------------------------------------------------
/packages/p2p-chat/main/loadUserConf.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-param-reassign */
2 | import Store from 'electron-store'
3 |
4 | export default locals => {
5 | const userConf = new Store({
6 | name: locals.tag,
7 | cwd: locals.settingsDir,
8 | })
9 | locals.users = userConf.get('users', {})
10 | locals.channels = userConf.get('channels', {})
11 | locals.userConf = userConf
12 |
13 | return userConf
14 | }
15 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Common/Modal/index.js:
--------------------------------------------------------------------------------
1 | import { Modal as AntdModal } from 'antd'
2 | import React from 'react'
3 | import PropTypes from 'prop-types'
4 | import styles from './index.scss'
5 |
6 | const Modal = ({ className, ...props }) => (
7 |
8 | )
9 |
10 | Modal.propTypes = {
11 | className: PropTypes.string,
12 | }
13 |
14 | export default Modal
15 |
--------------------------------------------------------------------------------
/packages/p2p-chat-core/lib/ensureMergeIPset.js:
--------------------------------------------------------------------------------
1 | const IPset = require('p2p-chat-utils/ipset')
2 |
3 | module.exports = function ensureMergeIPset(payload) {
4 | const { ipset, ipsetMerged, ipsetStore } = payload
5 | const ret = {}
6 | if (ipset) {
7 | ret.ipset = ipsetMerged ? ipset : ipset.mergeStore(ipsetStore)
8 | ret.ipsetMerged = true
9 | } else {
10 | ret.ipset = IPset(ipsetStore)
11 | }
12 | return Object.assign(payload, ret)
13 | }
14 |
--------------------------------------------------------------------------------
/packages/p2p-chat-core/lib/getTag.js:
--------------------------------------------------------------------------------
1 | const md5 = require('p2p-chat-utils/md5')
2 | const { machineIdSync } = require('node-machine-id')
3 |
4 | const machineId = machineIdSync({ original: true })
5 |
6 | /**
7 | * Get tag according to port, host, username
8 | * @param {number} port
9 | * @param {string} username
10 | * @returns {string}
11 | */
12 | module.exports = function getTag(port, username) {
13 | return md5.dataSync(machineId + port + username)
14 | }
15 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Chatting/Messages/Text.scss:
--------------------------------------------------------------------------------
1 | .text {
2 | margin: 16px 2px;
3 | padding: 5px;
4 | border-radius: 7px;
5 | background: #fff;
6 | box-shadow: 0 0 0.2px 0.6px rgba(0, 0, 0, 0.2);
7 | }
8 |
9 | .text__header {
10 | color: #346fb5;
11 | }
12 |
13 | .text__footer {
14 | text-align: right;
15 | color: rgba(0, 0, 0, 0.34);
16 | }
17 |
18 | .text__main {
19 | font-size: 1.2em;
20 | white-space: pre-wrap;
21 | word-break: break-all;
22 | }
23 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/index.js:
--------------------------------------------------------------------------------
1 | import { push } from 'connected-react-router'
2 |
3 | import { configureStore, history } from './global/redux'
4 | import ipc from './global/ipc'
5 | import render from './global/render'
6 | import subscribe from './global/subscribe'
7 |
8 | const store = configureStore()
9 |
10 | render(store, history)
11 | ipc(store.dispatch)
12 | subscribe(store)
13 |
14 | // redirect to root
15 | if (history.location.pathname !== '/') store.dispatch(push('/'))
16 |
--------------------------------------------------------------------------------
/packages/p2p-chat/config/dep-externals.js:
--------------------------------------------------------------------------------
1 | const has = require('p2p-chat-utils/has')
2 |
3 | const matchExternals = (req, dependencies) => {
4 | if (has(dependencies, req)) return true
5 | if (has(dependencies, req.split('/')[0])) return true
6 | return false
7 | }
8 |
9 | module.exports = dependencies => (context, request, callback) => {
10 | if (matchExternals(request, dependencies)) {
11 | callback(null, `commonjs ${request}`)
12 | return
13 | }
14 | callback()
15 | }
16 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Chatting/Dialog/Dialog.scss:
--------------------------------------------------------------------------------
1 | .dialog {
2 | display: flex;
3 | height: 100vh;
4 | flex-direction: column;
5 | padding: 10px 16px;
6 | }
7 |
8 | .text {
9 | width: 100%;
10 | font-size: 1.1em;
11 | margin: 15px 0;
12 | resize: none;
13 | }
14 |
15 | .send-btn-div {
16 | text-align: right;
17 | }
18 |
19 | .user-info {
20 | color: rgba(0, 0, 0, 0.85);
21 | padding: 8px 0 8px 8px;
22 | line-height: 22px;
23 | border-bottom: 1px solid #d9d9d9;
24 | }
25 |
--------------------------------------------------------------------------------
/packages/p2p-chat-utils/EventObservable.js:
--------------------------------------------------------------------------------
1 | module.exports = emitter => {
2 | const observables = []
3 | return {
4 | observe(type, listener) {
5 | const destory = () => {
6 | emitter.removeListener(type, listener)
7 | }
8 | emitter.on(type, listener)
9 | observables.push(destory)
10 | return destory
11 | },
12 | removeAllObservables() {
13 | observables.forEach(x => {
14 | x()
15 | })
16 | observables.splice(0)
17 | },
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/packages/p2p-chat-utils/is-ip-larger.js:
--------------------------------------------------------------------------------
1 | /**
2 | * compare IPs: check whether `a` is larger than `b`
3 | * @param {string} a
4 | * @param {string} b
5 | * @returns {boolean}
6 | */
7 | function isIPLarger(a, b) {
8 | const aParts = a.split('.').map(Number)
9 | const bParts = b.split('.').map(Number)
10 | return aParts.reduce((isLarger, num, index) => {
11 | if (isLarger) return true
12 | if (num > bParts[index]) return true
13 | return false
14 | }, false)
15 | }
16 |
17 | module.exports = isIPLarger
18 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Chatting/Dialog/subscribe.js:
--------------------------------------------------------------------------------
1 | import { throttle } from 'lodash'
2 |
3 | import watchState from '../../../utils/watchState'
4 | import storage from '../../../utils/storage'
5 |
6 | export default watchState(
7 | ['chatting', 'dialog'],
8 | throttle(
9 | (_dialog, state) => {
10 | const { logined, tag } = state.settings.login
11 | if (logined) {
12 | storage.set(`dialog-${tag}`, _dialog)
13 | }
14 | },
15 | 200,
16 | { leading: true }
17 | )
18 | )
19 |
--------------------------------------------------------------------------------
/packages/p2p-chat-utils/each.js:
--------------------------------------------------------------------------------
1 | const fn = (object, callback) => key => {
2 | const value = object[key]
3 | if (value !== undefined) {
4 | callback(value)
5 | }
6 | }
7 |
8 | module.exports = function each(object, keys, callback) {
9 | if (!object || typeof object !== 'object') return
10 |
11 | if (typeof keys === 'function') {
12 | Object.keys(object).forEach(fn(object, keys))
13 | } else if (typeof callback === 'function' && Array.isArray(keys)) {
14 | keys.forEach(fn(object, callback))
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/packages/p2p-chat/config/css.js:
--------------------------------------------------------------------------------
1 | module.exports = ({ rule, extract }) => config => {
2 | if (!rule || !rule.use) {
3 | throw Error('rule and rule.use required')
4 | }
5 | if (extract) {
6 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') // eslint-disable-line global-require
7 | const use = [MiniCssExtractPlugin.loader, ...rule.use]
8 | config.rule(Object.assign({}, rule, { use }))
9 | } else {
10 | const use = ['style-loader', ...rule.use]
11 | config.rule(Object.assign({}, rule, { use }))
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/p2p-chat-logger/README.md:
--------------------------------------------------------------------------------
1 | # logger
2 |
3 | a wrapper of `chalk`
4 |
5 | ## Install
6 |
7 | ```
8 | $ npm install -S dgeibi/logger
9 | ```
10 |
11 | ## Usage
12 |
13 | ``` js
14 | const logger = require('logger');
15 |
16 | logger.error(Error('something wrong'));
17 | ```
18 |
19 | ## Methods
20 |
21 | - `logger.log`: `console.log`
22 | - `logger.warn`: yellow
23 | - `logger.error`: red
24 | - `logger.err`: red
25 | - `logger.info`: green
26 | - `logger.debug`: blue
27 | - `logger.verbose`: cyan
28 |
29 | ## LICENSE
30 |
31 | [MIT](LICENSE)
32 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/utils/watchState.js:
--------------------------------------------------------------------------------
1 | const safeValue = (obj, path) =>
2 | path.reduce((s, p) => {
3 | if (!s || s === true) return undefined
4 | const v = s[p]
5 | if (v !== undefined) return v
6 | return undefined
7 | }, obj)
8 |
9 | const watchState = (path, callback) => {
10 | let cache
11 | return obj => {
12 | const value = safeValue(obj, path)
13 | if (value !== undefined && cache !== value) {
14 | cache = value
15 | callback(value, obj)
16 | }
17 | }
18 | }
19 |
20 | export default watchState
21 |
--------------------------------------------------------------------------------
/packages/p2p-chat-utils/get-new-address.js:
--------------------------------------------------------------------------------
1 | function getParts(parts, zero = true) {
2 | const last = parts[parts.length - 1]
3 | if (last < 254) return [...parts.slice(0, parts.length - 1), last + 1]
4 | return [...getParts(parts.slice(0, parts.length - 1)), zero ? 0 : 1]
5 | }
6 |
7 | /**
8 | * get next IP address
9 | * @param {string} host
10 | * @returns {string}
11 | */
12 | function getNewAddress(host) {
13 | const parts = host.split('.').map(Number)
14 | return getParts(parts, false).join('.')
15 | }
16 |
17 | module.exports = getNewAddress
18 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/utils/hot.js:
--------------------------------------------------------------------------------
1 | export default ({ sourceModule, replaceGetter, args }) => {
2 | if (process.env.NODE_ENV !== 'production' && sourceModule.hot) {
3 | sourceModule.hot.accept()
4 | sourceModule.hot.dispose(data => {
5 | const replace = replaceGetter()
6 | if (typeof replace === 'function') {
7 | // eslint-disable-next-line
8 | data.replace = replace
9 | }
10 | })
11 | if (sourceModule.hot.data && typeof sourceModule.hot.data.replace === 'function') {
12 | sourceModule.hot.data.replace.apply(null, args)
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/layouts/global.scss:
--------------------------------------------------------------------------------
1 | body {
2 | height: 100vh;
3 | }
4 |
5 | $barWidth: 7px;
6 | ::-webkit-scrollbar {
7 | width: $barWidth;
8 | height: $barWidth;
9 | }
10 |
11 | $radius: 5px;
12 | ::-webkit-scrollbar-track {
13 | background: rgba(243, 243, 243, 0.43);
14 | border-radius: $radius;
15 | }
16 |
17 | ::-webkit-scrollbar-thumb {
18 | background: rgba(180, 180, 180, 0.89);
19 | border-radius: $radius;
20 | }
21 |
22 | .col {
23 | height: 100vh;
24 | overflow-y: auto;
25 | overflow-x: hidden;
26 | }
27 |
28 | .col-1 {
29 | border-right: 0.6px solid #eee;
30 | }
31 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Aside/ListItem/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | import styles from './ListItem.scss'
5 |
6 | const ListItem = ({ title, badge, online }) => (
7 |
8 | {title} {badge > 0 && {badge}}
9 |
10 | )
11 |
12 | ListItem.propTypes = {
13 | title: PropTypes.string.isRequired,
14 | badge: PropTypes.number.isRequired,
15 | online: PropTypes.bool,
16 | }
17 |
18 | export default ListItem
19 |
--------------------------------------------------------------------------------
/packages/p2p-chat-core/lib/Store.js:
--------------------------------------------------------------------------------
1 | const each = require('p2p-chat-utils/each')
2 |
3 | module.exports = class Store {
4 | constructor() {
5 | this.store = {}
6 | }
7 |
8 | remove(key) {
9 | this.store[key] = undefined
10 | }
11 |
12 | add(key, value = true) {
13 | this.store[key] = value
14 | }
15 |
16 | has(key) {
17 | return Boolean(this.store[key])
18 | }
19 |
20 | get(key) {
21 | return this.store[key] || null
22 | }
23 |
24 | destory() {
25 | this.store = {}
26 | }
27 |
28 | each(keys, callback) {
29 | each(this.store, keys, callback)
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/utils/format.js:
--------------------------------------------------------------------------------
1 | function toFixed(num) {
2 | return num.toFixed(2)
3 | }
4 |
5 | export function formatSize(size) {
6 | if (size > 1e9) return `${toFixed(size / 1e9)} GB`
7 | else if (size > 1e6) return `${toFixed(size / 1e6)} MB`
8 | else if (size > 1e3) return `${toFixed(size / 1e3)} KB`
9 | return `${toFixed(size)} B`
10 | }
11 |
12 | export function formatSpeed(speed) {
13 | return `${formatSize(speed)}/s`
14 | }
15 |
16 | export function formatPercent(percent) {
17 | return Number((percent * 100).toPrecision(3))
18 | }
19 |
20 | export const formatTag = tag => `[${tag.slice(0, 5)}]`
21 |
--------------------------------------------------------------------------------
/packages/p2p-chat-core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "p2p-chat-core",
3 | "version": "1.4.5",
4 | "license": "MIT",
5 | "private": true,
6 | "description": "a p2p chat app",
7 | "main": "index.js",
8 | "author": "dgeibi (blog.dgeibi.xyz)",
9 | "scripts": {
10 | "lint": "eslint .",
11 | "lint:fix": "eslint --fix ."
12 | },
13 | "keywords": [
14 | "p2p",
15 | "socket"
16 | ],
17 | "dependencies": {
18 | "fs-extra": "^5.0.0",
19 | "internal-ip": "^3.0.1",
20 | "node-machine-id": "^1.1.10",
21 | "p2p-chat-logger": "^1.4.5",
22 | "p2p-chat-utils": "^1.4.3"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/p2p-chat-core/lib/enhanceSocket/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-param-reassign */
2 | const Parse = require('./Parse')
3 |
4 | const msgSocket = {
5 | send(msg) {
6 | this.write(`${JSON.stringify(msg)}\n`)
7 | },
8 | }
9 |
10 | const enhance = opts => {
11 | const { socket, mixins } = opts
12 | const parse = new Parse(opts)
13 | socket.on('data', chunk => {
14 | parse.transform(chunk)
15 | })
16 | const done = () => {
17 | parse.destory()
18 | }
19 | socket.on('close', done)
20 | socket.on('error', done)
21 | Object.assign(socket, msgSocket, mixins)
22 | return socket
23 | }
24 |
25 | module.exports = enhance
26 |
--------------------------------------------------------------------------------
/packages/p2p-chat-utils/event-replaceable.js:
--------------------------------------------------------------------------------
1 | const EventObservable = require('./EventObservable')
2 | const noop = require('./noop')
3 |
4 | module.exports = ({ emitter, callback, args, disable }) => {
5 | let on
6 | let obs
7 |
8 | if (disable) {
9 | on = (...xx) => emitter.on(...xx)
10 | } else {
11 | obs = EventObservable(emitter)
12 | on = obs.observe
13 | }
14 |
15 | if (args && args.length) {
16 | callback(on, ...args)
17 | } else {
18 | callback(on)
19 | }
20 |
21 | if (disable) return noop
22 |
23 | return newCallback => {
24 | obs.removeAllObservables()
25 | newCallback(on, ...args)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/packages/p2p-chat-logger/index.d.ts:
--------------------------------------------------------------------------------
1 | declare module "p2p-chat-logger" {
2 | namespace internal {
3 | export function log(message?: any, ...optionalParams: any[]): void;
4 | export function error(message?: any, ...optionalParams: any[]): void;
5 | export function err(message?: any, ...optionalParams: any[]): void;
6 | export function info(message?: any, ...optionalParams: any[]): void;
7 | export function warn(message?: any, ...optionalParams: any[]): void;
8 | export function verbose(message?: any, ...optionalParams: any[]): void;
9 | export function debug(message?: any, ...optionalParams: any[]): void;
10 | }
11 | export = internal;
12 | }
13 |
--------------------------------------------------------------------------------
/packages/p2p-chat-utils/index.js:
--------------------------------------------------------------------------------
1 | exports.ipset = require('./ipset')
2 | exports.pickByMap = require('./pickByMap')
3 | exports.pickBy = require('./pickBy')
4 | exports.md5 = require('./md5')
5 | exports.arr2keys = require('./arr2keys')
6 | exports.each = require('./each')
7 | exports.ensureUniqueFilename = require('./ensure-unique-filename')
8 | exports.eventReplaceable = require('./event-replaceable')
9 | exports.EventObservable = require('./EventObservable')
10 | exports.getNewAddress = require('./get-new-address')
11 | exports.getPort = require('./get-port')
12 | exports.ipset = require('./ipset')
13 | exports.isIPLarger = require('./is-ip-larger')
14 | exports.has = require('./has')
15 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Settings/MyInfo/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | import { formatTag } from '../../../utils/format'
5 | import styles from './MyInfo.scss'
6 |
7 | const MyInfo = ({ logined, tag, address, port, username }) =>
8 | logined ? (
9 |
10 | {username}
11 | {formatTag(tag)}
12 |
13 | {address}:{port}
14 |
15 | ) : null
16 |
17 | MyInfo.propTypes = {
18 | logined: PropTypes.bool.isRequired,
19 | tag: PropTypes.string,
20 | address: PropTypes.string,
21 | port: PropTypes.number,
22 | username: PropTypes.string,
23 | }
24 |
25 | export default MyInfo
26 |
--------------------------------------------------------------------------------
/packages/p2p-chat-utils/pickByMap.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-param-reassign */
2 |
3 | function getValue(object, props) {
4 | if (!object) return null
5 | if (!Array.isArray(props)) return object[props]
6 | const [first, ...rest] = props
7 | if (!first) return object
8 | return getValue(object[first], rest)
9 | }
10 |
11 | module.exports = function pickByMap(object, props, keys) {
12 | if (!keys) keys = Object.keys(object)
13 | return keys.reduce((obj, key) => {
14 | const value = object[key]
15 | if (!value) return obj
16 | obj[key] = {}
17 | Object.keys(props).forEach(propKey => {
18 | obj[key][propKey] = getValue(object[key], props[propKey])
19 | })
20 | return obj
21 | }, {})
22 | }
23 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/global/subscribe.js:
--------------------------------------------------------------------------------
1 | import subscribeDialog from '../components/Chatting/Dialog/subscribe'
2 | import hot from '../utils/hot'
3 |
4 | const subscribes = [subscribeDialog]
5 |
6 | let replace
7 | const makeReplace = (unsubscribe, store) => {
8 | replace = subscribe => {
9 | unsubscribe()
10 | replace = null
11 | subscribe(store)
12 | }
13 | }
14 |
15 | const subscribe = store => {
16 | const listener = () => {
17 | const state = store.getState()
18 | subscribes.forEach(x => x && x(state))
19 | }
20 | makeReplace(store.subscribe(listener), store)
21 | }
22 |
23 | hot({
24 | sourceModule: module,
25 | replaceGetter: () => replace,
26 | args: [subscribe],
27 | })
28 |
29 | export default subscribe
30 |
--------------------------------------------------------------------------------
/packages/p2p-chat/main/setContextMenu.js:
--------------------------------------------------------------------------------
1 | import { Menu } from 'electron'
2 |
3 | const selectionMenu = Menu.buildFromTemplate([{ role: 'copy' }])
4 |
5 | const inputMenu = Menu.buildFromTemplate([
6 | { role: 'undo' },
7 | { role: 'redo' },
8 | { type: 'separator' },
9 | { role: 'cut' },
10 | { role: 'copy' },
11 | { role: 'paste' },
12 | { type: 'separator' },
13 | { role: 'selectall' },
14 | ])
15 |
16 | export default window => {
17 | window.webContents.on('context-menu', (e, props) => {
18 | const { selectionText, isEditable } = props
19 | if (isEditable) {
20 | inputMenu.popup(window)
21 | } else if (selectionText && selectionText.trim() !== '') {
22 | selectionMenu.popup(window)
23 | }
24 | })
25 | }
26 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/global/reducer.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux'
2 | import { connectRouter } from 'connected-react-router'
3 | import settings from '../views/SettingsRedux'
4 | import aside from '../views/AsideRedux'
5 | import chatting from '../views/ChattingRedux'
6 | import modalbtns from '../views/ModalBtnRedux'
7 |
8 | // const initialState = appReducer({}, {})
9 |
10 | export default history => {
11 | const appReducer = combineReducers({
12 | settings,
13 | aside,
14 | chatting,
15 | modalbtns,
16 | router: connectRouter(history),
17 | })
18 |
19 | return (state, action) => {
20 | if (action.type === 'LOGOUT') {
21 | window.location.reload()
22 | }
23 |
24 | return appReducer(state, action)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Settings/validators.js:
--------------------------------------------------------------------------------
1 | import ipRegex from 'ip-regex'
2 |
3 | export const validPort = (rule, value, callback) => {
4 | const port = Number(value)
5 | if (Number.isNaN(port) || !Number.isInteger(port) || port < 2000 || port > 59999) {
6 | callback(Error('Port should be a integer (2000~59999)'))
7 | return
8 | }
9 | callback()
10 | }
11 |
12 | export const validAddress = (rule, value, callback) => {
13 | if (!value || ipRegex({ exact: true }).test(value)) {
14 | callback()
15 | } else {
16 | callback(Error(`${value} is not a regular IP`))
17 | }
18 | }
19 |
20 | export const validName = (rule, value, callback) => {
21 | if (!/\s/.test(value)) {
22 | callback()
23 | } else {
24 | callback(Error('should not contain white space'))
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/packages/p2p-chat-logger/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | const chalk = require('chalk')
3 | const util = require('util')
4 |
5 | const colors = {
6 | log: null,
7 | verbose: 'cyan',
8 | info: 'green',
9 | warn: 'yellow',
10 | debug: 'blue',
11 | error: 'red',
12 | }
13 |
14 | const aliases = {
15 | err: 'error',
16 | }
17 |
18 | const mapFn = arg => (typeof arg === 'function' ? String(arg) : arg)
19 |
20 | const log = color => (...args) => {
21 | const str = util.format(...args.map(mapFn))
22 | if (color !== null) {
23 | console.log(chalk[color](str))
24 | } else {
25 | console.log(str)
26 | }
27 | }
28 |
29 | const logger = {}
30 |
31 | Object.keys(colors).forEach(x => {
32 | logger[x] = log(colors[x])
33 | })
34 |
35 | Object.keys(aliases).forEach(x => {
36 | logger[x] = logger[aliases[x]]
37 | })
38 |
39 | module.exports = logger
40 |
--------------------------------------------------------------------------------
/packages/p2p-chat-core/lib/login.js:
--------------------------------------------------------------------------------
1 | const internalIP = require('internal-ip')
2 | const getPort = require('p2p-chat-utils/get-port')
3 | const getTag = require('./getTag')
4 |
5 | function getInternalIP() {
6 | return internalIP.v4.sync() || internalIP.v6.sync()
7 | }
8 |
9 | /**
10 | * get tag, address, port
11 | * @param {{port: number, host: string, username: string}} opts
12 | * @param {function(?Error, ?{port: number, address: string, tag: string})} callback
13 | */
14 | function login(opts, callback) {
15 | getPort({ start: opts.port }, (e, port) => {
16 | if (!e) {
17 | const id = Object.assign({}, opts)
18 | id.port = port
19 | id.address = opts.host || getInternalIP() // lan ip address
20 | id.tag = getTag(port, opts.username)
21 | callback(null, id)
22 | } else {
23 | callback(e)
24 | }
25 | })
26 | }
27 |
28 | module.exports = login
29 |
--------------------------------------------------------------------------------
/packages/p2p-chat/build/build-win-portable.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const builder = require('electron-builder')
4 | const commonConfig = require('../package').build
5 |
6 | const { Platform } = builder
7 |
8 | const createConfig = arch =>
9 | Object.assign({}, commonConfig, {
10 | win: {
11 | target: [
12 | {
13 | arch,
14 | target: 'portable',
15 | },
16 | {
17 | arch,
18 | target: 'nsis',
19 | },
20 | ],
21 | },
22 | portable: {
23 | artifactName: `\${productName}-portable-win-\${version}-${arch}.\${ext}`,
24 | },
25 | nsis: {
26 | artifactName: `\${productName}-win-installer-\${version}-${arch}.\${ext}`,
27 | },
28 | })
29 |
30 | const arch = process.argv[2] || 'ia32'
31 |
32 | builder.build({
33 | targets: Platform.WINDOWS.createTarget(),
34 | config: createConfig(arch),
35 | })
36 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/views/ModalBtn.js:
--------------------------------------------------------------------------------
1 | import { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import { connect } from 'react-redux'
4 | import { modalBtnActions, selectVisible } from './ModalBtnRedux'
5 |
6 | @connect(
7 | (state, ownProp) => ({
8 | visible: selectVisible(state.modalbtns, ownProp),
9 | }),
10 | (dispatch, ownProp) => ({
11 | show: () => dispatch(modalBtnActions.show(ownProp.id)),
12 | hide: () => dispatch(modalBtnActions.hide(ownProp.id)),
13 | })
14 | )
15 | class ModalBtn extends Component {
16 | static propTypes = {
17 | show: PropTypes.func.isRequired,
18 | hide: PropTypes.func.isRequired,
19 | visible: PropTypes.bool.isRequired,
20 | children: PropTypes.func.isRequired,
21 | }
22 |
23 | render() {
24 | const { show, hide, visible, children } = this.props
25 | return children({ show, hide, visible })
26 | }
27 | }
28 |
29 | export default ModalBtn
30 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/views/ModalBtnRedux.js:
--------------------------------------------------------------------------------
1 | import { createAction } from 'redux-actions'
2 | import constants from '../utils/constants'
3 |
4 | const initalState = {}
5 |
6 | const TYPES = {
7 | SHOW: '',
8 | HIDE: '',
9 | }
10 | constants(TYPES, 'modalBtn')
11 |
12 | export default (state = initalState, action) => {
13 | switch (action.type) {
14 | case TYPES.SHOW:
15 | return {
16 | ...state,
17 | [action.payload]: true,
18 | }
19 | case TYPES.HIDE:
20 | return {
21 | ...state,
22 | [action.payload]: false,
23 | }
24 | default:
25 | return state
26 | }
27 | }
28 |
29 | export const modalBtnActions = {
30 | show: createAction(TYPES.SHOW),
31 | hide: createAction(TYPES.HIDE),
32 | }
33 |
34 | export function selectVisible(state, ownProp) {
35 | if (state[ownProp.id] === undefined) return !!ownProp.visibleDefault
36 | return !!state[ownProp.id]
37 | }
38 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Chatting/Messages/Text.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import dateFormat from 'dateformat'
3 | import PropTypes from 'prop-types'
4 |
5 | import { formatTag } from '../../../utils/format'
6 |
7 | import styles from './Text.scss'
8 |
9 | const Text = ({ username, text, myName, self, date, tag }) => (
10 |
11 |
12 | {self ? myName : username + formatTag(tag)}
13 |
14 | {text}
15 |
18 |
19 | )
20 |
21 | Text.propTypes = {
22 | text: PropTypes.string.isRequired,
23 | date: PropTypes.number.isRequired,
24 | username: PropTypes.string,
25 | self: PropTypes.bool,
26 | myName: PropTypes.string,
27 | tag: PropTypes.string,
28 | }
29 |
30 | export default Text
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "p2p-chat-bootstrap",
3 | "version": "0.0.0",
4 | "license": "MIT",
5 | "description": "a P2P LAN chatting and file sharing tool",
6 | "author": "dgeibi",
7 | "private": true,
8 | "scripts": {
9 | "app": "cd packages/p2p-chat && yarn run",
10 | "lint": "eslint .",
11 | "fix": "eslint . --fix",
12 | "format": "prettier --write '**/*.{js,scss}'"
13 | },
14 | "lint-staged": {
15 | "*.{scss,js}": [
16 | "prettier --write",
17 | "git add"
18 | ]
19 | },
20 | "husky": {
21 | "hooks": {
22 | "precommit": "lint-staged"
23 | }
24 | },
25 | "devDependencies": {
26 | "eslint": "^5.11.0",
27 | "eslint-config-dgeibi": "^5.1.1",
28 | "husky": "^1.2.1",
29 | "lint-staged": "^8.1.0",
30 | "prettier": "^1.15.3"
31 | },
32 | "workspaces": [
33 | "packages/*"
34 | ],
35 | "prettier": {
36 | "semi": false,
37 | "singleQuote": true,
38 | "trailingComma": "es5",
39 | "printWidth": 90
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/global/redux.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, compose } from 'redux'
2 | import { routerMiddleware as createRouterMiddleware } from 'connected-react-router'
3 | import createHistory from 'history/createHashHistory'
4 | import makeReducer from './reducer'
5 |
6 | const history = createHistory()
7 | const RouterMiddleware = createRouterMiddleware(history)
8 |
9 | const middlewares = [
10 | applyMiddleware(RouterMiddleware),
11 | process.env.NODE_ENV !== 'production' && require('./devToolsMiddleware').default,
12 | ].filter(Boolean)
13 |
14 | const finalCreateStore = compose(...middlewares)(createStore)
15 |
16 | const configureStore = initialState => {
17 | const store = finalCreateStore(makeReducer(history), initialState)
18 |
19 | if (process.env.NODE_ENV !== 'production' && module.hot) {
20 | module.hot.accept('./reducer', () => {
21 | store.replaceReducer(makeReducer(history))
22 | })
23 | }
24 |
25 | return store
26 | }
27 |
28 | export { history, configureStore }
29 |
--------------------------------------------------------------------------------
/packages/p2p-chat-utils/get-port.js:
--------------------------------------------------------------------------------
1 | const net = require('net')
2 | /* eslint-disable no-param-reassign, no-bitwise */
3 | /* base-on get-port getport */
4 |
5 | function getport(opts, callback) {
6 | if (!callback) throw TypeError('callback required')
7 | opts = opts || { start: 2000, end: 50000 }
8 | opts.start |= 0
9 | opts.end |= 0
10 | opts.start = opts.start < 2000 ? 2000 : opts.start
11 | opts.end = opts.end < 2000 ? 50000 : opts.end
12 |
13 | const { start, end } = opts
14 |
15 | if (start >= end) {
16 | callback(Error('out of ports'))
17 | return
18 | }
19 |
20 | const server = net.createServer()
21 |
22 | server.unref()
23 | server.on('error', () => {
24 | opts.start += 1
25 | getport(opts, callback)
26 | })
27 |
28 | server.listen(start, () => {
29 | if (server.address().port !== start) {
30 | opts.start += 1
31 | getport(opts, callback)
32 | server.close()
33 | return
34 | }
35 | server.close(() => {
36 | callback(null, start)
37 | })
38 | })
39 | }
40 |
41 | module.exports = getport
42 |
--------------------------------------------------------------------------------
/packages/p2p-chat-utils/md5.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const crypto = require('crypto')
3 |
4 | /**
5 | * Get md5sum of file
6 | * @param {string} filepath full path of file
7 | * @param {boolean} strict whether check `file` is a file
8 | * @param {function(?Error, string)} callback
9 | */
10 | const file = (filepath, strict, callback) => {
11 | if (strict) {
12 | let stats
13 | try {
14 | stats = fs.statSync(filepath)
15 | } catch (err) {
16 | callback(err)
17 | return
18 | }
19 | if (!stats.isFile()) {
20 | callback(Error(`${filepath} is not a file`))
21 | return
22 | }
23 | }
24 |
25 | fs.createReadStream(filepath)
26 | .pipe(crypto.createHash('md5').setEncoding('hex'))
27 | .on('finish', function finished() {
28 | callback(null, this.read())
29 | })
30 | }
31 |
32 | /**
33 | * Get md5 checksum of data
34 | * @param {(string|Buffer)} data
35 | * @returns {string}
36 | */
37 | const dataSync = data =>
38 | crypto
39 | .createHash('md5')
40 | .update(data)
41 | .digest('hex')
42 |
43 | module.exports = {
44 | dataSync,
45 | file,
46 | }
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2017 dgeibi
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Settings/Login/redux.js:
--------------------------------------------------------------------------------
1 | import { ipcRenderer } from 'electron'
2 | import { push } from 'connected-react-router'
3 |
4 | import createReducer from '../../../utils/createReducer'
5 | import getConstants from '../../../utils/constants'
6 |
7 | const TYPES = {
8 | UPDATE_SETTINGS: '',
9 | LOGOUT: '',
10 | }
11 |
12 | getConstants(TYPES, 'LOGIN')
13 | const initialState = {
14 | username: 'anonymous',
15 | port: 8087,
16 | logined: false,
17 | }
18 |
19 | const reducerMap = {
20 | [TYPES.UPDATE_SETTINGS](state, action) {
21 | return {
22 | ...state,
23 | ...action.payload,
24 | logined: true,
25 | }
26 | },
27 | [TYPES.LOGOUT](state) {
28 | return {
29 | ...state,
30 | logined: false,
31 | }
32 | },
33 | }
34 |
35 | export default createReducer(reducerMap, initialState)
36 |
37 | export const logout = () => {
38 | ipcRenderer.send('logout')
39 | return {
40 | type: TYPES.LOGOUT,
41 | }
42 | }
43 |
44 | export const updateSettings = id => ({
45 | type: TYPES.UPDATE_SETTINGS,
46 | payload: id,
47 | })
48 |
49 | export const backToRoot = () => push('/')
50 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (http://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # Typescript v1 declaration files
40 | typings/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # Optional eslint cache
46 | .eslintcache
47 |
48 | # Optional REPL history
49 | .node_repl_history
50 |
51 | # Output of 'npm pack'
52 | *.tgz
53 |
54 | # Yarn Integrity file
55 | .yarn-integrity
56 |
57 | # dotenv environment variables file
58 | .env
59 |
60 | dist/
61 | assets/
62 |
--------------------------------------------------------------------------------
/packages/p2p-chat/config/devServer.js:
--------------------------------------------------------------------------------
1 | const { spawn } = require('child_process')
2 | const webpack = require('webpack')
3 | const logger = require('p2p-chat-logger')
4 |
5 | class StartElectron {
6 | apply(compiler) {
7 | compiler.hooks.done.tap('selfdone', () => {
8 | if (this.started) return
9 | this.started = true
10 | spawn('electron .', {
11 | shell: true,
12 | env: Object.assign(
13 | {
14 | DEV_PORT: compiler.options.devServer.port,
15 | },
16 | process.env
17 | ),
18 | stdio: 'inherit',
19 | })
20 | .on('close', () => process.exit(0))
21 | .on('error', spawnError => logger.error(spawnError))
22 | })
23 | }
24 | }
25 |
26 | module.exports = options => wtf => {
27 | wtf.plugin(webpack.HotModuleReplacementPlugin)
28 | wtf.plugin(StartElectron)
29 | // eslint-disable-next-line no-param-reassign
30 | wtf.config.devServer = Object.assign(
31 | wtf.config.devServer,
32 | {
33 | disableHostCheck: true,
34 | hot: true,
35 | stats: {
36 | colors: true,
37 | chunks: false,
38 | children: false,
39 | },
40 | },
41 | options
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/packages/p2p-chat-utils/ensure-unique-filename.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-constant-condition, no-param-reassign */
2 | const fs = require('fs')
3 | const path = require('path')
4 |
5 | /**
6 | * Get Unique filename that do not exist in disk
7 | * @return {string}
8 | * @param {string} filepath
9 | */
10 | const ensureUnique = filepath => {
11 | do {
12 | if (fs.existsSync(filepath)) {
13 | const dirname = path.dirname(filepath)
14 | const basename = path.basename(filepath)
15 |
16 | const arr = basename.split('.')
17 |
18 | if (arr.length === 1) {
19 | arr.push(1)
20 | } else if (arr.length === 2) {
21 | const n = Number(arr[1])
22 | if (Number.isNaN(n)) {
23 | arr.splice(1, 0, '1')
24 | } else {
25 | arr[1] = n + 1
26 | }
27 | } else {
28 | let idx = arr.length - 2
29 | let n = Number(arr[idx])
30 | if (!Number.isNaN(n)) {
31 | arr[idx] = n + 1
32 | } else {
33 | idx += 1
34 | n = Number(arr[idx])
35 | if (!Number.isNaN(n)) {
36 | arr[idx] = n + 1
37 | } else {
38 | arr.splice(arr.length - 1, 0, '1')
39 | }
40 | }
41 | }
42 | filepath = path.join(dirname, arr.join('.'))
43 | } else {
44 | break
45 | }
46 | } while (true)
47 | return filepath
48 | }
49 |
50 | module.exports = ensureUnique
51 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Chatting/FilePanel/FileInfo.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Button } from 'antd'
3 | import PropTypes from 'prop-types'
4 |
5 | import Card from '../../Common/CustomCard'
6 | import { formatSize } from '../../../utils/format'
7 | import styles from './FileReceive.scss'
8 | import { fileLoadStates } from './constants'
9 |
10 | const FileInfo = ({ filename, accept, ignore, size, username, status }) => (
11 |
12 |
13 |
14 | {filename}
15 |
16 |
17 | {formatSize(size)} by {username}
18 |
19 | {fileLoadStates.waitting === status ? (
20 | 'waiting to receive..'
21 | ) : (
22 |
23 |
24 | {' '}
27 |
30 |
31 | )}
32 |
33 |
34 | )
35 |
36 | FileInfo.propTypes = {
37 | username: PropTypes.string.isRequired,
38 | size: PropTypes.number.isRequired,
39 | filename: PropTypes.string.isRequired,
40 | status: PropTypes.string,
41 | accept: PropTypes.func,
42 | ignore: PropTypes.func,
43 | }
44 |
45 | export default FileInfo
46 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/selectors/chatInfo.js:
--------------------------------------------------------------------------------
1 | import { createSelector, defaultMemoize } from 'reselect'
2 | import pickBy from 'p2p-chat-utils/pickBy'
3 | import has from 'p2p-chat-utils/has'
4 |
5 | const pickProps = (stub, src) => pickBy(src, (value, key) => has(stub, key))
6 |
7 | const getChannelOnlineMembers = (members, users) =>
8 | Object.values(users ? pickProps(members, users) : members).filter(x => x.online)
9 |
10 | const getChannelInfo = defaultMemoize((channels, users, key) => {
11 | const channel = { ...channels[key] }
12 | channel.users = pickProps(channel.users, users)
13 | channel.onlineCount = getChannelOnlineMembers(channel.users).length
14 | channel.online = channel.onlineCount > 0
15 | channel.totalCount = Object.keys(channel.users).length
16 | return channel
17 | })
18 |
19 | const getInfo = (users, channels, type, key) => {
20 | if (type === 'user') return users[key]
21 | if (type === 'channel') return getChannelInfo(channels, users, key)
22 | return null
23 | }
24 |
25 | const usersSelector = state => state.aside.chatList.users
26 | const channelsSelector = state => state.aside.chatList.channels
27 | const typeSelector = (state, ownProps) => ownProps.match.params.type
28 | const keySelector = (state, ownProps) => ownProps.match.params.key
29 |
30 | const selectInfo = createSelector(
31 | [usersSelector, channelsSelector, typeSelector, keySelector],
32 | getInfo
33 | )
34 |
35 | export { getInfo, selectInfo }
36 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/layouts/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Provider } from 'react-redux'
3 | import PropTypes from 'prop-types'
4 | import { Row, Col } from 'antd'
5 | import { Route } from 'react-router-dom'
6 | import { ConnectedRouter as Router } from 'connected-react-router'
7 | import { hot } from 'react-hot-loader'
8 |
9 | import SettingNav from '../views/Settings'
10 | import Aside from '../views/Aside'
11 | import Chatting from '../views/Chatting'
12 | import styles from './global.scss'
13 |
14 | function getDevTool() {
15 | if (process.env.NODE_ENV !== 'production') {
16 | const DevTools = require('./DevTools').default
17 | return
18 | }
19 | return null
20 | }
21 |
22 | const App = ({ store, history }) => (
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | }
34 | />
35 | {getDevTool()}
36 |
37 |
38 |
39 |
40 | )
41 |
42 | App.propTypes = {
43 | store: PropTypes.object.isRequired,
44 | history: PropTypes.object.isRequired,
45 | }
46 |
47 | export default hot(module)(App)
48 |
--------------------------------------------------------------------------------
/packages/p2p-chat/config/main.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const Config = require('wtf-webpack-config')
3 | const depExternals = require('./dep-externals')
4 | const analyzer = require('./analyzer')
5 | const pkg = require('../package.json')
6 |
7 | module.exports = (env = {}) => {
8 | const PROD = env.production === true
9 | const mode = PROD ? 'production' : 'development'
10 | const PUBLIC_PATH = PROD ? '' : '/'
11 | const SRC_DIR = path.join(__dirname, '../main')
12 | const OUTPUT_DIR = path.join(__dirname, '..')
13 | const defaultInclude = [SRC_DIR]
14 |
15 | const config = new Config({
16 | optimization: {
17 | minimize: false,
18 | },
19 | devtool: env.debug ? 'eval' : 'source-map',
20 | mode,
21 | entry: {
22 | index: `${SRC_DIR}/index.js`,
23 | worker: `${SRC_DIR}/worker.js`,
24 | },
25 | output: {
26 | path: OUTPUT_DIR,
27 | filename: '[name].js',
28 | publicPath: PUBLIC_PATH,
29 | },
30 | target: 'electron-main',
31 | node: {
32 | console: false,
33 | global: false,
34 | process: false,
35 | Buffer: false,
36 | __filename: false,
37 | __dirname: false,
38 | setImmediate: false,
39 | },
40 | externals: [depExternals(pkg.dependencies)],
41 | })
42 | .rule({
43 | test: /\.js$/,
44 | include: defaultInclude,
45 | loader: 'babel-loader',
46 | options: {
47 | babelrc: false,
48 | presets: [
49 | [
50 | '@babel/env',
51 | {
52 | targets: {
53 | electron: '4.0.0',
54 | },
55 | modules: false,
56 | useBuiltIns: 'usage',
57 | shippedProposals: true,
58 | },
59 | ],
60 | ],
61 | },
62 | })
63 | .use(analyzer, Boolean(env.report))
64 |
65 | return config.toConfig()
66 | }
67 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Chatting/FilePanel/FileReceive.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Progress } from 'antd'
3 | import { shell } from 'electron'
4 | import { dirname } from 'path'
5 | import PropTypes from 'prop-types'
6 |
7 | import Card from '../../Common/CustomCard'
8 | import { fileLoadStates } from './constants'
9 | import { formatSize, formatSpeed, formatPercent } from '../../../utils/format'
10 | import styles from './FileReceive.scss'
11 |
12 | const openFile = filepath => () => {
13 | shell.openItem(filepath)
14 | }
15 | const openDir = filepath => openFile(dirname(filepath))
16 |
17 | const { success, active, exception } = fileLoadStates
18 |
19 | const FileReceive = ({
20 | username,
21 | size,
22 | filename,
23 | error,
24 | speed,
25 | percent,
26 | status,
27 | filepath,
28 | }) => (
29 |
30 |
31 |
32 | {status === success ?
{filename} : filename}
33 |
34 |
35 | {formatSize(size)} by {username}
36 |
37 | {status === active &&
{formatSpeed(speed)}
}
38 | {status === exception &&
{error.message}
}
39 | {status !== success && (
40 |
41 | )}
42 | {status === success && (
43 |
47 | )}
48 |
49 |
50 | )
51 |
52 | FileReceive.propTypes = {
53 | username: PropTypes.string.isRequired,
54 | size: PropTypes.number.isRequired,
55 | filename: PropTypes.string.isRequired,
56 | error: PropTypes.object,
57 | speed: PropTypes.number,
58 | percent: PropTypes.number,
59 | status: PropTypes.string,
60 | filepath: PropTypes.string,
61 | }
62 |
63 | export default FileReceive
64 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Chatting/Messages/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Alert } from 'antd'
3 | import PropTypes from 'prop-types'
4 |
5 | import Text from './Text'
6 | import styles from './Messages.scss'
7 |
8 | class Messages extends Component {
9 | static propTypes = {
10 | messages: PropTypes.array.isRequired,
11 | username: PropTypes.string.isRequired,
12 | }
13 |
14 | saveMessageList = div => {
15 | this.messageList = div
16 | }
17 |
18 | scrollToBottom() {
19 | if (!this.messageList) return
20 | const { scrollHeight } = this.messageList
21 | const height = this.messageList.clientHeight
22 | const maxScrollTop = scrollHeight - height
23 | this.messageList.scrollTop = maxScrollTop > 0 ? maxScrollTop : 0
24 | }
25 |
26 | componentDidMount() {
27 | this.scrollToBottom()
28 | }
29 |
30 | componentDidUpdate() {
31 | this.scrollToBottom()
32 | }
33 |
34 | shouldComponentUpdate(nextProps) {
35 | return this.props.messages.length !== nextProps.messages.length
36 | }
37 |
38 | render() {
39 | const { messages, username } = this.props
40 | return (
41 |
42 | {messages.map(msg => {
43 | const props = { key: msg.uid }
44 | if (msg.alert) {
45 | const { message, description, alert } = msg
46 | return (
47 |
55 | )
56 | }
57 | if (msg.text) {
58 | props.myName = username
59 | return
60 | }
61 | return null
62 | })}
63 |
64 | )
65 | }
66 | }
67 |
68 | export default Messages
69 |
--------------------------------------------------------------------------------
/packages/p2p-chat-core/lib/connect.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-param-reassign, no-continue, no-underscore-dangle */
2 | const net = require('net')
3 | const getNewHost = require('p2p-chat-utils/get-new-address')
4 | const isIPLarger = require('p2p-chat-utils/is-ip-larger')
5 | const noop = require('p2p-chat-utils/noop')
6 |
7 | /**
8 | * 将 from ~ to 范围内的地址添加到 ipset
9 | * @param {string} from
10 | * @param {string} to
11 | * @param {number} port
12 | * @param {object} ipset
13 | */
14 | function connectHostRange(from, to, port, ipset) {
15 | if (isIPLarger(from, to)) return // 超过范围
16 | ipset.add(from, port)
17 | connectHostRange(getNewHost(from), to, port, ipset)
18 | }
19 |
20 | function connectScatter(opts, fallbackHost) {
21 | if (opts.connects) {
22 | opts.connects.forEach(conn => {
23 | const host = conn.host || fallbackHost
24 | const { port } = conn
25 | opts.ipset.add(host, port)
26 | })
27 | opts.connects = undefined
28 | }
29 | }
30 |
31 | function connectIPset(ipset, handler) {
32 | ipset.forEach((host, port) => {
33 | net
34 | .connect(
35 | port,
36 | host,
37 | handler
38 | )
39 | .on('error', noop)
40 | })
41 | }
42 |
43 | function connectRange(opts, fallbackHost) {
44 | const { hostStart, portStart, ipset } = opts
45 | let { hostEnd, portEnd } = opts
46 |
47 | if (!portStart) return
48 | if (portEnd && portEnd < portStart) return
49 | if (hostStart && !hostEnd) hostEnd = hostStart
50 |
51 | if (!portEnd) portEnd = portStart + 1
52 | else portEnd += 1
53 |
54 | if (hostStart) {
55 | for (let port = portStart; port < portEnd; port += 1) {
56 | connectHostRange(hostStart, hostEnd, port, ipset)
57 | }
58 | } else {
59 | const host = fallbackHost
60 | for (let port = portStart; port < portEnd; port += 1) {
61 | ipset.add(host, port)
62 | }
63 | }
64 | }
65 |
66 | module.exports = {
67 | connectIPset,
68 | connectRange,
69 | connectScatter,
70 | }
71 |
--------------------------------------------------------------------------------
/packages/p2p-chat/main/menu.js:
--------------------------------------------------------------------------------
1 | import electron from 'electron'
2 |
3 | const { app, Menu, dialog } = electron
4 |
5 | const template = [
6 | {
7 | label: 'Edit',
8 | submenu: [
9 | { role: 'undo' },
10 | { role: 'redo' },
11 | { type: 'separator' },
12 | { role: 'cut' },
13 | { role: 'copy' },
14 | { role: 'paste' },
15 | ],
16 | },
17 | {
18 | label: 'View',
19 | submenu: [
20 | { role: 'toggledevtools' },
21 | { type: 'separator' },
22 | { role: 'resetzoom' },
23 | { role: 'zoomin' },
24 | { role: 'zoomout' },
25 | { type: 'separator' },
26 | { role: 'togglefullscreen' },
27 | ],
28 | },
29 | {
30 | role: 'help',
31 | submenu: [
32 | {
33 | label: 'Learn More',
34 | click() {
35 | electron.shell.openExternal('https://github.com/dgeibi/p2p-chat')
36 | },
37 | },
38 | {
39 | label: 'Report Issues',
40 | click() {
41 | electron.shell.openExternal('https://github.com/dgeibi/p2p-chat/issues/new')
42 | },
43 | },
44 | {
45 | label: 'About',
46 | click() {
47 | const name = app.getName()
48 | dialog.showMessageBox({
49 | type: 'info',
50 | title: name,
51 | message: name,
52 | detail: `Version ${app.getVersion()}
53 | Node ${process.versions.node}
54 | Renderer ${process.versions.chrome}
55 | Electron ${process.versions.electron}
56 | Architecture ${process.arch}`,
57 | buttons: ['OK'],
58 | })
59 | },
60 | },
61 | ],
62 | },
63 | ]
64 |
65 | if (process.platform === 'darwin') {
66 | // Edit menu
67 | template[0].submenu.push(
68 | { type: 'separator' },
69 | {
70 | label: 'Speech',
71 | submenu: [{ role: 'startspeaking' }, { role: 'stopspeaking' }],
72 | }
73 | )
74 | }
75 |
76 | const menu = Menu.buildFromTemplate(template)
77 | Menu.setApplicationMenu(menu)
78 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # p2p-chat
2 |
3 | a P2P LAN chatting and file sharing tool
4 |
5 | [Releases](https://github.com/dgeibi/p2p-chat/releases)
6 |
7 | ## Background
8 |
9 | 原为计算机网络课设,从2017年5月开始开发,我尝试在 TCP 上实现应用层的网络协议。
10 |
11 | ### ~~一切用 JSON~~
12 |
13 | 传输的数据格式用JSON,参考 https://nodejs.org/api/buffer.html#buffer_buf_tojson ,我试着将 buffer 包裹在 JSON 里,连大文件的数据也放在里边,binary 转成 utf8 字符串……
14 |
15 | TCP 传输的最小单位是字节,从 data 事件拿到的数据可能不是完整的消息,如果一个 socket 连续发送消息,字节可能会黏住,分不出需要的消息的结束位置。因此,我要实现一个解析器,从字节流中解析出消息。
16 |
17 | 我想到的第一个解析数据的方法是 try catch + JSON.parse,将拿到的数据存放到一个数组中,拼接数组中的 buffer,尝试用 JSON.parse 取得数据,取不出时接着等下一个块继续以上步骤,直到 parse 成功。传小文件没有问题,但传大文件就会让 node 消耗大量内存,卡死。我怀疑聊天核心模块在 electron main process 进行操作影响 UI 的流畅程度导致卡死,就 fork 出一个子进程,但没有效果。
18 |
19 | 我不得不开始重新思考如何设计传输协议,于是……想到了 stream 和 HTTP。
20 |
21 | HTTP 消息有 head 和 body 两个部分,head 的格式严格定义,body 部分几乎没有限制,两者用`\r\n\r\n`分割。
22 |
23 | 对于短连接,断开 TCP 连接,可以让浏览器知道消息结束。
24 |
25 | 对于长连接,head 可以记录 body 的长度 `Content-Length` 用来判断消息是否结束,这只适用于数据大小已经预先知道的情况,对于聊天工具的传输文件已经足够了。题外话,HTTP 还有其它方法如分块传输编码。
26 |
27 | 之前内存泄漏的原因是在 JSON 被解析出来前,内存中一直积累 JSON 数据,解析成功后才将很多个小 buffer 释放掉,将拼接得到的大 buffer 写入文件系统,才算释放完内存。
28 |
29 | 在分割 head 和 body 的基础上,借助 stream 及时生效的特性,我们可以将 body 的数据及时写入文件系统的 writeStream 中,不在内存积累太多数据。
30 |
31 | ### 设计
32 |
33 | * head:为了简单还用 JSON,`JSON.stringify` 的结果没有 `\n`,用 `\n` 标记 head 结束就好,剩下的就是 body 了。如果有 body,就要有一个字段标记 body 的长度。
34 | * body:可选。格式任意。
35 |
36 | ### 实现
37 |
38 | 主要是使用 `buffer.indexOf('\n')` 找到 head 的结束位置,在 `\n` 之前的 buffer 是 head 的组成部分,合并这些 buffer,用 `JSON.parse` 解析出 head。body 在 `\n` 后,根据给定长度将字节写到一个 writeStream。
39 |
40 | ## Development
41 |
42 | Use [yarn](https://yarnpkg.com) to install dependencies
43 |
44 | ```
45 | $ yarn
46 | ```
47 |
48 | Run the current application in development mode
49 |
50 | ```
51 | $ yarn app dev
52 | ```
53 |
54 | Create packages
55 |
56 | ``` sh
57 | $ yarn app dist # build for linux, win32, win64
58 | $ yarn app dist:linux # build for linux
59 | $ yarn app dist:win32 # build for win32
60 | $ yarn app dist:win64 # build for win64
61 | ```
62 |
63 | Then check out the output in `packages/p2p-chat/dist`.
64 |
65 | ## LICENSE
66 |
67 | [MIT](LICENSE)
68 |
--------------------------------------------------------------------------------
/packages/p2p-chat-core/lib/fileHanderMakers.js:
--------------------------------------------------------------------------------
1 | const logger = require('p2p-chat-logger')
2 |
3 | const fileReceiveError = ({
4 | chat,
5 | error,
6 | info: { id, tag, username, filename, channel },
7 | }) => {
8 | chat.emit('file-receive-fail', {
9 | tag,
10 | error,
11 | channel,
12 | username,
13 | filename,
14 | id,
15 | })
16 | }
17 |
18 | const fileReceived = ({
19 | chat,
20 | info: { tag, username, filename, filepath, id, channel },
21 | }) => {
22 | chat.emit('file-receiced', {
23 | tag,
24 | username,
25 | filename,
26 | filepath,
27 | id,
28 | channel,
29 | })
30 | }
31 |
32 | /**
33 | * 校验、写入文件
34 | * @param {object} message
35 | */
36 |
37 | const makeProcessing = (chat, { checksum, tag, channel, id }) => (
38 | currentChecksum,
39 | percent,
40 | speed
41 | ) => {
42 | if (currentChecksum !== checksum) return
43 | chat.emit('file-processing', {
44 | tag,
45 | channel,
46 | id,
47 | percent,
48 | speed,
49 | })
50 | }
51 |
52 | const makeDone = (chat, socket, { checksum, id, tag, channel }, { processing }) => {
53 | const done = currentChecksum => {
54 | if (currentChecksum !== checksum) return
55 | socket.removeListener('file-processing', processing)
56 | socket.removeListener('file-done', done)
57 | chat.emit('file-process-done', { id, tag, channel })
58 | }
59 | return done
60 | }
61 |
62 | const makeClose = (chat, socket, info) => {
63 | const { checksum, filepath, filename, id } = info
64 | const close = (currentChecksum, realChecksum) => {
65 | if (currentChecksum !== checksum) return
66 | socket.removeListener('file-close', close)
67 | if (realChecksum !== checksum) {
68 | const error = Error(`\`${filename}\` validation fail`)
69 | fileReceiveError({
70 | chat,
71 | error,
72 | info,
73 | })
74 | } else {
75 | fileReceived({
76 | chat,
77 | info,
78 | })
79 | logger.verbose('file receiced', filepath)
80 | chat.confirmFileReceived(id)
81 | }
82 | }
83 | return close
84 | }
85 |
86 | module.exports = { makeClose, makeDone, makeProcessing, fileReceiveError, fileReceived }
87 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Chatting/FilePanel/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Button } from 'antd'
3 | import PropTypes from 'prop-types'
4 |
5 | import FileInfo from './FileInfo'
6 | import FileReceive from './FileReceive'
7 | import styles from './FilePanel.scss'
8 | import { cardTypes } from './constants'
9 |
10 | const accept = ({ tag, channel }, id, checksum, acceptFile) => () => {
11 | acceptFile({
12 | id,
13 | tag,
14 | channel,
15 | checksum,
16 | })
17 | }
18 |
19 | const ignore = ({ tag, channel }, id, ignoreFile) => () => {
20 | ignoreFile({
21 | id,
22 | tag,
23 | channel,
24 | })
25 | }
26 |
27 | class FilePanel extends Component {
28 | static propTypes = {
29 | id: PropTypes.object.isRequired,
30 | clearPanel: PropTypes.func.isRequired,
31 | acceptFile: PropTypes.func.isRequired,
32 | ignoreFile: PropTypes.func.isRequired,
33 | files: PropTypes.object.isRequired,
34 | }
35 |
36 | clear = () => {
37 | const { clearPanel, id } = this.props
38 | clearPanel(id)
39 | }
40 |
41 | render() {
42 | const { files, acceptFile, ignoreFile } = this.props
43 | const filesArr = Object.values(files)
44 | if (filesArr.length <= 0) return null
45 | return (
46 |
47 |
48 |
49 |
50 | {filesArr.map(msg => {
51 | const { type, id, checksum, key, ...payload } = msg
52 | switch (type) {
53 | case cardTypes.RECEIVE:
54 | return
55 | case cardTypes.INFO:
56 | return (
57 |
63 | )
64 | default:
65 | return null
66 | }
67 | })}
68 |
69 | )
70 | }
71 | }
72 |
73 | export default FilePanel
74 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/views/Aside.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { bindActionCreators } from 'redux'
3 | import { connect } from 'react-redux'
4 | import PropTypes from 'prop-types'
5 | import { createSelector } from 'reselect'
6 | import { matchPath } from 'react-router-dom'
7 | import { sortBy } from 'lodash'
8 | import { getInfo } from '../selectors/chatInfo'
9 |
10 | import ChatList from '../components/Aside/ChatList'
11 | import { chatListActions } from './AsideRedux'
12 |
13 | const byOnline = key => x => {
14 | const prefix = x.online ? '0' : '1'
15 | return prefix + x[key]
16 | }
17 | const selectUsers = state => state.aside.chatList.users
18 | const selectChannels = state => state.aside.chatList.channels
19 | const selectSortedChannels = createSelector(
20 | [selectUsers, selectChannels],
21 | (users, channels) =>
22 | sortBy(
23 | Object.keys(channels).map(key => getInfo(users, channels, 'channel', key)),
24 | byOnline('name')
25 | )
26 | )
27 | const selectSortedUsers = createSelector(
28 | [selectUsers],
29 | users => sortBy(Object.values(users), byOnline('username'))
30 | )
31 |
32 | const selectLocation = state => state.router.location
33 | const selectCurrent = createSelector(
34 | [selectLocation],
35 | location => {
36 | if (!location) return {}
37 | const match = matchPath(location.pathname, {
38 | path: '/chat/:type/:key',
39 | })
40 | if (match) return match.params
41 | return {}
42 | }
43 | )
44 |
45 | @connect(
46 | state => ({
47 | chatList: {
48 | visible: state.aside.chatList.visible,
49 | users: selectSortedUsers(state),
50 | channels: selectSortedChannels(state),
51 | current: selectCurrent(state),
52 | },
53 | }),
54 | dispatch => ({
55 | chatListActions: bindActionCreators(chatListActions, dispatch),
56 | })
57 | )
58 | class Aside extends Component {
59 | static propTypes = {
60 | chatList: PropTypes.object.isRequired,
61 | chatListActions: PropTypes.object.isRequired,
62 | }
63 |
64 | render() {
65 | const { chatList, chatListActions: actions } = this.props
66 | return (
67 |
68 |
69 |
70 | )
71 | }
72 | }
73 |
74 | export default Aside
75 |
--------------------------------------------------------------------------------
/packages/p2p-chat/main/worker.js:
--------------------------------------------------------------------------------
1 | import chat from 'p2p-chat-core'
2 | import makePlainError from './makePlainError'
3 |
4 | const postToMain = (key, payload) => {
5 | process.send({
6 | key,
7 | payload,
8 | })
9 | }
10 |
11 | process.on('exit', () => {
12 | if (chat.active) chat.exit()
13 | })
14 |
15 | process.on('uncaughtException', err => {
16 | process.send({ act: 'suicide', error: makePlainError(err) })
17 |
18 | chat.exit(() => {
19 | process.exit(1)
20 | })
21 |
22 | setTimeout(() => {
23 | process.exit(1)
24 | }, 5000)
25 | })
26 |
27 | process.on('message', message => {
28 | const { key, payload } = message
29 | switch (key) {
30 | case 'change-setting': {
31 | chat.connectServers(payload)
32 | break
33 | }
34 | case 'setup': {
35 | chat.setup(payload, (err, id) => {
36 | postToMain('setup-reply', { error: makePlainError(err), id })
37 | })
38 | break
39 | }
40 | case 'logout': {
41 | chat.exit(err => {
42 | postToMain('logout-reply', { error: makePlainError(err) })
43 | })
44 | break
45 | }
46 | case 'local-text': {
47 | chat.textToUsers(payload)
48 | break
49 | }
50 | case 'local-file': {
51 | chat.sendFileToUsers(payload)
52 | break
53 | }
54 | case 'accept-file': {
55 | chat.acceptFile(payload)
56 | break
57 | }
58 | case 'create-channel': {
59 | chat.createChannel(payload)
60 | break
61 | }
62 | default:
63 | break
64 | }
65 | })
66 |
67 | const C2M = key => {
68 | chat.on(key, payload => {
69 | if (payload && typeof payload === 'object' && payload.error) {
70 | // eslint-disable-next-line no-param-reassign
71 | payload.error = makePlainError(payload.error)
72 | }
73 | postToMain(key, payload)
74 | })
75 | }
76 |
77 | chat.on('error', e => {
78 | postToMain('chat-error', {
79 | error: makePlainError(e),
80 | })
81 | })
82 |
83 | C2M('logout')
84 | C2M('login')
85 | C2M('text')
86 | C2M('text-sent')
87 | C2M('fileinfo')
88 | C2M('file-receiced')
89 | C2M('file-receive-fail')
90 | C2M('file-sent')
91 | C2M('file-send-fail')
92 | C2M('file-unable-to-send')
93 | C2M('file-process-start')
94 | C2M('file-processing')
95 | C2M('file-process-done')
96 | C2M('channel-create')
97 |
--------------------------------------------------------------------------------
/packages/p2p-chat-utils/ipset.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-use-before-define */
2 |
3 | /**
4 | * IP-Port set
5 | */
6 | function IPset(initalStore) {
7 | let store = initalStore || {}
8 |
9 | const self = {
10 | add,
11 | remove,
12 | has,
13 | reset,
14 | forEach,
15 | getStore,
16 | mergeStore,
17 | excludeStore,
18 | }
19 |
20 | /**
21 | * add address to ipset
22 | * @param {string} host
23 | * @param {number} port
24 | */
25 | function add(host, port) {
26 | if (typeof host !== 'string') throw TypeError('host should be a string')
27 | const portnum = Math.trunc(port)
28 | if (Number.isNaN(portnum)) {
29 | throw TypeError('port should be a integer')
30 | }
31 | if (store[host] === undefined) store[host] = {}
32 | store[host][portnum] = true
33 | return self
34 | }
35 |
36 | /**
37 | * remove address from ipset
38 | * @param {string} host
39 | * @param {number} port
40 | */
41 | function remove(host, port) {
42 | if (store[host] !== undefined) {
43 | store[host][port] = false
44 | }
45 | return self
46 | }
47 |
48 | /**
49 | * check whether has `host:port` in ipset
50 | * @param {string} host
51 | * @param {number} port
52 | */
53 | function has(host, port) {
54 | if (store[host] === undefined) return false
55 | if (store[host][port]) return true
56 | return false
57 | }
58 |
59 | /**
60 | * reset ipset
61 | */
62 | function reset(s) {
63 | store = s || {}
64 | return self
65 | }
66 |
67 | /**
68 | * invoke function with all addresses
69 | * @param {function(string, number)} fn
70 | */
71 | function forEach(fn) {
72 | Object.keys(store).forEach(host => {
73 | const portStore = store[host]
74 | Object.keys(portStore).forEach(portStr => {
75 | const port = +portStr
76 | const exists = portStore[portStr]
77 | if (exists) {
78 | fn(host, port)
79 | }
80 | })
81 | })
82 | return self
83 | }
84 |
85 | function getStore() {
86 | return store
87 | }
88 |
89 | function mergeStore(s) {
90 | const ipset = IPset(s)
91 | ipset.forEach((host, port) => {
92 | add(host, port)
93 | })
94 | return self
95 | }
96 |
97 | function excludeStore(s) {
98 | const ipset = IPset(s)
99 | ipset.forEach((host, port) => {
100 | if (has(host, port)) {
101 | remove(host, port)
102 | }
103 | })
104 | return self
105 | }
106 |
107 | return self
108 | }
109 |
110 | module.exports = IPset
111 |
--------------------------------------------------------------------------------
/packages/p2p-chat-core/lib/actions.js:
--------------------------------------------------------------------------------
1 | const logger = require('p2p-chat-logger')
2 | const each = require('p2p-chat-utils/each')
3 | const pickByMap = require('p2p-chat-utils/pickByMap')
4 |
5 | const ensureMergeIPset = require('./ensureMergeIPset')
6 | const { connectRange, connectScatter } = require('./connect')
7 | const fileInfoPool = require('./fileInfoPool')
8 | const msgTypes = require('./msgTypes')
9 |
10 | module.exports = superClass =>
11 | class Actions extends superClass {
12 | /**
13 | * 发送文本
14 | * @param {Array} tags
15 | * @param {string} text
16 | */
17 | textToUsers(opts) {
18 | const { tags, payload } = opts
19 | const message = Object.assign(this.getMessage(), payload)
20 | message.type = msgTypes.TEXT
21 | each(this.clients, tags, socket => {
22 | socket.send(message)
23 | })
24 | this.emit('text-sent', Object.assign({ tag: tags[0] }, payload))
25 | }
26 |
27 | /**
28 | * 发送文件
29 | * @param {Array} tags
30 | * @param {string} filepath
31 | */
32 | sendFileToUsers(opts) {
33 | const { tags, filepath, payload } = opts
34 | const msg = Object.assign(this.getMessage(), payload)
35 |
36 | fileInfoPool.getInfoMsg(filepath, msg, (error, message) => {
37 | if (error) {
38 | logger.err('file-unable-to-send', error)
39 | this.emit('file-unable-to-send', { error })
40 | return
41 | }
42 | each(this.clients, tags, socket => {
43 | socket.send(message)
44 | })
45 | })
46 | }
47 |
48 | // 同意接收文件
49 | acceptFile(opts) {
50 | const { tag, payload } = opts
51 | const { id } = payload
52 | const socket = this.clients[tag]
53 | if (socket) {
54 | const message = Object.assign(this.getMessage(), payload)
55 | message.type = msgTypes.FILE_ACCEPTED
56 | this.files.add(id)
57 | socket.send(message)
58 | }
59 | }
60 |
61 | getOnlineUser() {
62 | const infos = pickByMap(this.clients, {
63 | tag: ['info', 'tag'],
64 | username: ['info', 'username'],
65 | })
66 | return infos
67 | }
68 |
69 | confirmFileReceived(id) {
70 | this.files.remove(id)
71 | }
72 |
73 | createChannel(opts) {
74 | const { payload, tags } = opts
75 | const message = Object.assign(this.getMessage(), payload)
76 | message.type = msgTypes.CHANNEL_CREATE
77 |
78 | each(this.clients, tags, socket => {
79 | socket.send(message)
80 | })
81 | }
82 |
83 | /**
84 | * 连接其它服务器
85 | * @param {setupPayload} opts
86 | */
87 | connectServers(opts) {
88 | if (!this.server.listening) return
89 |
90 | ensureMergeIPset(opts)
91 | connectRange(opts, this.address)
92 | connectScatter(opts, this.address)
93 | this.connectIPset(opts.ipset)
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Settings/Login/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Input, Form, Button } from 'antd'
3 | import { ipcRenderer } from 'electron'
4 | import PropTypes from 'prop-types'
5 |
6 | import { validPort, validName } from '../validators'
7 | import Modal from '../../Common/Modal'
8 |
9 | function FormItem(props) {
10 | const formItemLayout = {
11 | labelCol: { span: 4 },
12 | wrapperCol: { span: 14 },
13 | }
14 | return
15 | }
16 |
17 | const validForm = (form, callback) => {
18 | form.validateFields((err, options) => {
19 | if (err) {
20 | callback(err)
21 | return
22 | }
23 | ipcRenderer.send('setup', options)
24 | callback()
25 | })
26 | }
27 |
28 | @Form.create()
29 | class Login extends Component {
30 | static propTypes = {
31 | hide: PropTypes.func.isRequired,
32 | visible: PropTypes.bool.isRequired,
33 | form: PropTypes.object.isRequired,
34 | port: PropTypes.number.isRequired,
35 | username: PropTypes.string.isRequired,
36 | }
37 |
38 | handleCancel = () => {
39 | this.props.hide()
40 | }
41 |
42 | handleCreate = () => {
43 | const { form } = this.props
44 | validForm(form, err => {
45 | if (!err) {
46 | this.props.form.resetFields()
47 | this.props.hide()
48 | }
49 | })
50 | }
51 |
52 | render() {
53 | const { getFieldDecorator } = this.props.form
54 | const { visible } = this.props
55 | const footer = (
56 |
59 | )
60 | return (
61 |
68 |
97 |
98 | )
99 | }
100 | }
101 |
102 | export default Login
103 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/global/ipc.js:
--------------------------------------------------------------------------------
1 | import { ipcRenderer } from 'electron'
2 | import replaceable from 'p2p-chat-utils/event-replaceable'
3 | import { loginActions } from '../views/SettingsRedux'
4 | import { chatListActions } from '../views/AsideRedux'
5 | import { dialogActions, filePanelActions } from '../views/ChattingRedux'
6 | import { showError, showInfo } from '../utils/message'
7 | import hot from '../utils/hot'
8 |
9 | function listeners(on, dispatch) {
10 | on('setup-reply', (event, { error, id }) => {
11 | if (!error) {
12 | dispatch(loginActions.updateSettings(id))
13 | dispatch(chatListActions.show())
14 | dispatch(dialogActions.restoreDialog(id.tag))
15 | } else {
16 | showError(error)
17 | }
18 | })
19 |
20 | on('logout-reply', (event, { error }) => {
21 | dispatch({ type: 'LOGOUT' })
22 | if (error) {
23 | showError(error)
24 | } else {
25 | showInfo('Logouted.')
26 | }
27 | })
28 |
29 | on('worker-err', (event, { error }) => {
30 | showError(error)
31 | })
32 |
33 | on('chat-error', (event, { error }) => {
34 | console.error(error.stack)
35 | })
36 |
37 | on('text', (event, message) => {
38 | dispatch(dialogActions.newMessage(message))
39 | })
40 |
41 | on('text-sent', (event, message) => {
42 | dispatch(dialogActions.textSent(message))
43 | })
44 |
45 | // file send
46 | on('file-sent', (event, info) => {
47 | dispatch(dialogActions.fileSentNotice(info))
48 | })
49 |
50 | on('file-send-fail', (event, info) => {
51 | dispatch(dialogActions.fileSendError(info))
52 | })
53 |
54 | on('file-unable-to-send', (event, { error }) => {
55 | showError(error)
56 | })
57 |
58 | // file receive
59 | on('fileinfo', (event, message) => {
60 | dispatch(filePanelActions.fileCome(message))
61 | })
62 |
63 | on('file-process-start', (event, message) => {
64 | dispatch(filePanelActions.fileStart(message))
65 | })
66 |
67 | on('file-processing', (event, message) => {
68 | dispatch(filePanelActions.fileProcessing(message))
69 | })
70 |
71 | on('file-process-done', (event, message) => {
72 | dispatch(filePanelActions.fileEnd(message))
73 | })
74 |
75 | on('file-receive-fail', (event, message) => {
76 | dispatch(filePanelActions.fileReceiveError(message))
77 | })
78 |
79 | on('file-receiced', (event, message) => {
80 | dispatch(filePanelActions.fileReceived(message))
81 | })
82 |
83 | on('logout', (event, message) => {
84 | if (message.error && message.error.code !== 'ECONNRESET') {
85 | dispatch(dialogActions.socketError(message))
86 | }
87 | })
88 | }
89 |
90 | let replace
91 |
92 | export default (...args) => {
93 | replace = replaceable({
94 | args,
95 | emitter: ipcRenderer,
96 | callback: listeners,
97 | disable: !module.hot,
98 | })
99 | }
100 |
101 | hot({
102 | sourceModule: module,
103 | replaceGetter: () => replace,
104 | args: [listeners],
105 | })
106 |
--------------------------------------------------------------------------------
/packages/p2p-chat-core/lib/fileInfoPool.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const path = require('path')
3 | const net = require('net')
4 |
5 | const md5 = require('p2p-chat-utils/md5')
6 | const enhanceSocket = require('./enhanceSocket')
7 |
8 | let messages = {}
9 |
10 | /**
11 | * Get `fileinfo` message
12 | * @param {string} filepath - full file path
13 | * @param {object} message - message template
14 | * @param {function(Error, object)} callback
15 | */
16 | function getInfoMsg(filepath, message, callback) {
17 | fs.stat(filepath, (err, stats) => {
18 | if (err || !stats.isFile()) {
19 | callback(Error(`${filepath} is not a file`))
20 | return
21 | }
22 | const { size } = stats
23 | if (size === 0) {
24 | callback(Error(`${filepath} is empty file`))
25 | return
26 | }
27 | md5.file(filepath, false, (md5Error, checksum) => {
28 | if (md5Error) {
29 | callback(md5Error)
30 | return
31 | }
32 | const filename = path.basename(filepath)
33 | const fileInfoMessage = Object.assign(message, {
34 | type: 'fileinfo',
35 | filename,
36 | checksum,
37 | size,
38 | })
39 | messages[checksum] = Object.assign({}, message, {
40 | filepath,
41 | type: 'file',
42 | })
43 | callback(null, fileInfoMessage)
44 | })
45 | })
46 | }
47 |
48 | /**
49 | * 发送文件
50 | * @param {string} checksum
51 | * @param {object} options connect options
52 | * @param {function(?Error, ?string)} callback
53 | */
54 | function send(checksum, payload, options, callback) {
55 | if (!messages[checksum]) {
56 | callback(Error(`file not loaded from ${checksum}`))
57 | return
58 | }
59 | const fileMsg = Object.assign({}, payload, messages[checksum])
60 | const { filepath, filename } = fileMsg
61 | fs.stat(filepath, (err, stats) => {
62 | if (err) {
63 | callback(Error(`fail to read file ${filepath}`), filename)
64 | return
65 | }
66 |
67 | const { size } = stats
68 | if (size === 0) {
69 | callback(Error(`${filepath} is empty file`), filename)
70 | } else if (size !== fileMsg.size) {
71 | callback(Error(`${filepath} size changed`), filename)
72 | } else {
73 | delete fileMsg.filepath
74 |
75 | net
76 | .connect(
77 | options,
78 | function handler() {
79 | const socket = this
80 | enhanceSocket({ socket })
81 | fileMsg.bodyLength = fileMsg.size
82 | socket.send(fileMsg)
83 | const readStream = fs.createReadStream(filepath)
84 | readStream.pipe(socket)
85 | readStream.once('end', () => {
86 | socket.end()
87 | socket.once('close', () => {
88 | callback(null, filename)
89 | })
90 | })
91 | }
92 | )
93 | .on('error', e => {
94 | callback(e, filename)
95 | })
96 | }
97 | })
98 | }
99 |
100 | function destory() {
101 | messages = {}
102 | }
103 |
104 | module.exports = {
105 | getInfoMsg,
106 | send,
107 | destory,
108 | }
109 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Settings/ConnectRange/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { ipcRenderer } from 'electron'
3 | import { Input, Form } from 'antd'
4 | import PropTypes from 'prop-types'
5 |
6 | import { validAddress, validPort } from '../validators'
7 | import Modal from '../../Common/Modal'
8 |
9 | const FormItem = Form.Item
10 |
11 | const validForm = (form, callback) => {
12 | form.validateFields((err, values) => {
13 | if (err) {
14 | callback(err)
15 | return
16 | }
17 | ipcRenderer.send('change-setting', values)
18 | callback()
19 | })
20 | }
21 |
22 | @Form.create()
23 | class ConnectRange extends Component {
24 | static propTypes = {
25 | hide: PropTypes.func.isRequired,
26 | visible: PropTypes.bool.isRequired,
27 | form: PropTypes.object.isRequired,
28 | }
29 |
30 | handleCancel = () => {
31 | this.props.hide()
32 | }
33 |
34 | handleCreate = () => {
35 | const { form, hide } = this.props
36 | validForm(form, err => {
37 | if (!err) {
38 | form.resetFields()
39 | hide()
40 | }
41 | })
42 | }
43 |
44 | render() {
45 | const { visible } = this.props
46 | const { getFieldDecorator } = this.props.form
47 |
48 | return (
49 |
57 |
103 |
104 | )
105 | }
106 | }
107 |
108 | export default ConnectRange
109 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/views/Chatting.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { bindActionCreators } from 'redux'
3 | import { connect } from 'react-redux'
4 | import { basename } from 'path'
5 | import { createSelector } from 'reselect'
6 | import PropTypes from 'prop-types'
7 |
8 | import Dialog from '../components/Chatting/Dialog'
9 | import FilePanel from '../components/Chatting/FilePanel'
10 | import { selectInfo } from '../selectors/chatInfo'
11 | import * as actions from './ChattingRedux'
12 |
13 | const emptyArray = []
14 | const emptyObject = {}
15 |
16 | function select(state, ownProps, fleid) {
17 | const { type, key } = ownProps.match.params
18 | if (!type) return null
19 | if (!fleid) return state[type][key]
20 | if (!state[type][key]) return null
21 | return state[type][key][fleid]
22 | }
23 |
24 | const selectType = (state, props) => props.match.params.type
25 | const selectKey = (state, props) => props.match.params.key
26 |
27 | const selectChannelUsers = (state, props) => {
28 | if (props.match.params.type !== 'channel') return null
29 | return state.aside.chatList.channels[props.match.params.key].users
30 | }
31 |
32 | const selectID = createSelector(
33 | [selectType, selectKey, selectChannelUsers],
34 | (type, key, users) => {
35 | if (type === 'channel') {
36 | return {
37 | type,
38 | key,
39 | tags: Object.keys(users),
40 | channel: key,
41 | }
42 | } else if (type === 'user') {
43 | return {
44 | type,
45 | key,
46 | tags: [key],
47 | }
48 | }
49 | return null
50 | }
51 | )
52 |
53 | const fileMapper = filepath => ({
54 | uid: filepath,
55 | path: filepath,
56 | name: basename(filepath),
57 | })
58 |
59 | const selectFilePaths = (state, props) =>
60 | select(state.chatting.dialog, props, 'filePaths')
61 | const selectFileList = createSelector(
62 | [selectFilePaths],
63 | filePaths => {
64 | if (!filePaths) return emptyArray
65 | return filePaths.map(fileMapper)
66 | }
67 | )
68 |
69 | @connect(
70 | (state, ownProps) => {
71 | const { dialog, filePanel } = state.chatting
72 | return {
73 | dialogProps: {
74 | username: state.settings.login.username,
75 | messages: select(dialog, ownProps, 'messages') || emptyArray,
76 | text: select(dialog, ownProps, 'text') || '',
77 | fileList: selectFileList(state, ownProps),
78 | info: selectInfo(state, ownProps),
79 | },
80 | id: selectID(state, ownProps),
81 | files: select(filePanel, ownProps) || emptyObject,
82 | }
83 | },
84 | dispatch => ({
85 | dialogActions: bindActionCreators(actions.dialogActions, dispatch),
86 | filePanelActions: bindActionCreators(actions.filePanelActions, dispatch),
87 | })
88 | )
89 | class Chatting extends Component {
90 | static propTypes = {
91 | dialogActions: PropTypes.object.isRequired,
92 | files: PropTypes.object.isRequired,
93 | filePanelActions: PropTypes.object.isRequired,
94 | id: PropTypes.object.isRequired,
95 | dialogProps: PropTypes.object.isRequired,
96 | }
97 |
98 | render() {
99 | const { dialogActions, files, filePanelActions, id, dialogProps } = this.props
100 | return (
101 |
102 |
105 |
106 | )
107 | }
108 | }
109 |
110 | export default Chatting
111 |
--------------------------------------------------------------------------------
/packages/p2p-chat/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "p2p-chat",
3 | "version": "1.6.0",
4 | "license": "MIT",
5 | "description": "a P2P LAN chatting and file sharing tool",
6 | "main": "index.js",
7 | "output": "assets",
8 | "author": "dgeibi ",
9 | "private": true,
10 | "homepage": "https://github.com/dgeibi/p2p-chat",
11 | "scripts": {
12 | "webpack:main": "webpack --config ./config/main.config.js --env.debug",
13 | "serve": "webpack-dev-server --progress --config ./config/renderer.config.js --env.serve",
14 | "dev": "run-s webpack:main serve",
15 | "prod": "run-s clean webpack electron",
16 | "dist": "run-s clean webpack builder:*",
17 | "dist:linux": "run-s clean webpack builder:linux",
18 | "dist:win32": "run-s clean webpack builder:win32",
19 | "dist:win64": "run-s clean webpack builder:win64",
20 | "builder:linux": "build -l",
21 | "builder:win32": "node ./build/build-win-portable.js ia32",
22 | "builder:win64": "node ./build/build-win-portable.js x64",
23 | "clean": "rimraf dist assets",
24 | "webpack": "webpack --env.production",
25 | "webpack:report": "webpack --env.production --env.report",
26 | "electron": "electron ."
27 | },
28 | "keywords": [
29 | "electron-app",
30 | "electron",
31 | "p2p",
32 | "socket",
33 | "react",
34 | "redux"
35 | ],
36 | "dependencies": {
37 | "electron-store": "^2.0.0",
38 | "fs-extra": "^5.0.0",
39 | "ip-regex": "^3.0.0",
40 | "p2p-chat-core": "*",
41 | "p2p-chat-logger": "*",
42 | "p2p-chat-utils": "*"
43 | },
44 | "devDependencies": {
45 | "@babel/core": "^7.2.2",
46 | "@babel/preset-env": "^7.2.3",
47 | "@dgeibi/babel-preset-react-app": "2.0.4",
48 | "antd": "^3.11.4",
49 | "babel-core": "^7.0.0-bridge.0",
50 | "babel-loader": "^8.0.4",
51 | "babel-plugin-import": "^1.11.0",
52 | "connected-react-router": "^6.0.0",
53 | "css-loader": "^2.0.0",
54 | "cssnano": "^4.1.8",
55 | "dateformat": "^2.0.0",
56 | "electron": "^4.0.0",
57 | "electron-builder": "^20.38.0",
58 | "file-loader": "^3.0.0",
59 | "history": "^4.7.2",
60 | "html-webpack-plugin": "^3.2.0",
61 | "less": "^3.9.0",
62 | "less-loader": "^4.1.0",
63 | "lodash": "^4.17.11",
64 | "mini-css-extract-plugin": "^0.5.0",
65 | "npm-run-all": "^4.1.5",
66 | "postcss-loader": "^3.0.0",
67 | "postcss-scss": "^2.0.0",
68 | "precss": "^3.0.0",
69 | "prop-types": "15.6.2",
70 | "react": "16.7.0",
71 | "react-dom": "16.7.0",
72 | "react-hot-loader": "^4.6.3",
73 | "react-redux": "6.0.0",
74 | "react-router": "4.3.1",
75 | "react-router-dom": "4.3.1",
76 | "redux": "^4.0.1",
77 | "redux-actions": "^2.6.4",
78 | "redux-devtools": "^3.5.0",
79 | "redux-devtools-dock-monitor": "^1.1.3",
80 | "redux-devtools-log-monitor": "^1.4.0",
81 | "reselect": "^4.0.0",
82 | "rimraf": "^2.6.2",
83 | "style-loader": "^0.23.1",
84 | "webpack": "^4.28.2",
85 | "webpack-bundle-analyzer": "^3.0.3",
86 | "webpack-cli": "^3.1.2",
87 | "webpack-dev-server": "^3.1.13",
88 | "wtf-webpack-config": "^0.1.3"
89 | },
90 | "build": {
91 | "appId": "xyz.dgeibi.p2pchat",
92 | "files": [
93 | "!LICENSE",
94 | "!webpack-config/**",
95 | "!webpack.config.js",
96 | "!package-lock.json",
97 | "!src/**",
98 | "!main/**",
99 | "!report.html",
100 | "!public/**"
101 | ],
102 | "mac": {
103 | "category": "public.app-category.social-networking"
104 | },
105 | "linux": {
106 | "category": "Network",
107 | "target": [
108 | "AppImage",
109 | "deb"
110 | ]
111 | }
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Aside/ChatList/redux.js:
--------------------------------------------------------------------------------
1 | import { push } from 'connected-react-router'
2 | import constants from '../../../utils/constants'
3 | import createReducer from '../../../utils/createReducer'
4 |
5 | const TYPES = {
6 | SETUP: '',
7 | ADD_USER: '',
8 | CHANGE_DIALOG: '',
9 | OFF_USER: '',
10 | ADD_CHANNLE: '',
11 | SHOW_LIST: '',
12 | HIDE_LIST: '',
13 | CLEAR_BADGE: '',
14 | INCREASE_BADGE: '',
15 | RESET: '',
16 | }
17 | constants(TYPES, 'ASIDE')
18 |
19 | const initalState = {
20 | users: {},
21 | channels: {},
22 | visible: false,
23 | }
24 |
25 | const reducerMap = {
26 | [TYPES.SETUP](state, action) {
27 | return {
28 | ...state,
29 | ...action.payload,
30 | }
31 | },
32 | [TYPES.OFF_USER](state, action) {
33 | return {
34 | ...state,
35 | users: {
36 | ...state.users,
37 | [action.payload.tag]: {
38 | ...state.users[action.payload.tag],
39 | ...action.payload,
40 | online: false,
41 | },
42 | },
43 | }
44 | },
45 | [TYPES.ADD_USER](state, action) {
46 | return {
47 | ...state,
48 | users: {
49 | ...state.users,
50 | [action.payload.tag]: {
51 | ...state.users[action.payload.tag],
52 | ...action.payload,
53 | online: true,
54 | },
55 | },
56 | }
57 | },
58 | [TYPES.ADD_CHANNLE](state, action) {
59 | return {
60 | ...state,
61 | channels: {
62 | ...state.channels,
63 | [action.payload.key]: action.payload,
64 | },
65 | }
66 | },
67 | [TYPES.SHOW_LIST](state) {
68 | return {
69 | ...state,
70 | visible: true,
71 | }
72 | },
73 | [TYPES.HIDE_LIST](state) {
74 | return {
75 | ...state,
76 | visible: false,
77 | }
78 | },
79 | [TYPES.INCREASE_BADGE](state, action) {
80 | const { type, key } = action.id
81 | const types = `${type}s`
82 | return {
83 | ...state,
84 | [types]: {
85 | ...state[types],
86 | [key]: {
87 | ...state[types][key],
88 | badge: (state[types][key].badge || 0) + 1,
89 | },
90 | },
91 | }
92 | },
93 | [TYPES.CLEAR_BADGE](state, action) {
94 | const { type, key } = action.id
95 | const types = `${type}s`
96 | if (!state[types][key].badge) return state
97 | return {
98 | ...state,
99 | [types]: {
100 | ...state[types],
101 | [key]: {
102 | ...state[types][key],
103 | badge: 0,
104 | },
105 | },
106 | }
107 | },
108 | [TYPES.RESET]() {
109 | return { ...initalState }
110 | },
111 | }
112 |
113 | export default createReducer(reducerMap, initalState)
114 |
115 | export const setup = ({ users, channels }) => ({
116 | type: TYPES.SETUP,
117 | payload: { users, channels },
118 | })
119 |
120 | export const addUser = message => ({
121 | type: TYPES.ADD_USER,
122 | payload: message,
123 | })
124 |
125 | export const offUser = message => ({
126 | type: TYPES.OFF_USER,
127 | payload: message,
128 | })
129 |
130 | export const addChannel = channel => ({
131 | type: TYPES.ADD_CHANNLE,
132 | payload: channel,
133 | })
134 |
135 | export const show = () => ({
136 | type: TYPES.SHOW_LIST,
137 | })
138 |
139 | export const hide = () => ({
140 | type: TYPES.HIDE_LIST,
141 | })
142 |
143 | export const changeDialog = (type, key) => push(`/chat/${type}/${key}`)
144 |
145 | export const clearBadge = id => ({
146 | type: TYPES.CLEAR_BADGE,
147 | id,
148 | })
149 |
150 | export const increaseBadge = id => ({
151 | type: TYPES.INCREASE_BADGE,
152 | id,
153 | })
154 |
155 | export const reset = () => ({
156 | type: TYPES.RESET,
157 | })
158 |
--------------------------------------------------------------------------------
/packages/p2p-chat-core/lib/Chat.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-param-reassign, no-continue, no-underscore-dangle */
2 | const net = require('net')
3 | const { EventEmitter } = require('events')
4 | const logger = require('p2p-chat-logger')
5 | const each = require('p2p-chat-utils/each')
6 | const noop = require('p2p-chat-utils/noop')
7 |
8 | const login = require('./login')
9 | const ensureMergeIPset = require('./ensureMergeIPset')
10 | const Store = require('./Store')
11 | const socketHandler = require('./socketHandler')
12 | const actions = require('./actions')
13 | const fileInfoPool = require('./fileInfoPool')
14 | const defaultOpts = require('./defaultOpts')
15 |
16 | class Chat extends actions(socketHandler(EventEmitter)) {
17 | constructor() {
18 | super()
19 | this.clients = null
20 | this.active = false
21 | this.tag = null
22 | this.port = null
23 | this.address = null
24 | this.username = null
25 | this.files = new Store()
26 |
27 | this.downloadDir = 'Downloads'
28 | }
29 |
30 | setup(options, callback) {
31 | const opts = Object.assign({}, defaultOpts, options)
32 |
33 | // 已经处于启动状态,重新启动
34 | if (this.active) {
35 | // 保存用户地址/端口到 ipset
36 | opts.payload = opts.payload || {}
37 |
38 | // 退出后启动
39 | this.exit(err => {
40 | if (!err) {
41 | this.setup(options, callback)
42 | } else {
43 | callback(err)
44 | }
45 | })
46 | return
47 | }
48 |
49 | opts.port = Math.trunc(opts.port)
50 | if (Number.isNaN(opts.port) || opts.port < 2000 || opts.port > 59999) {
51 | callback(TypeError('port should be a integer (2000~59999)'))
52 | return
53 | }
54 | if (typeof opts.username !== 'string') {
55 | callback(TypeError('username should be a string'))
56 | return
57 | }
58 |
59 | login(opts, this._login(callback))
60 | }
61 |
62 | _setID({ port, username, tag, address, downloadDir }) {
63 | this.username = username
64 | this.port = port
65 | this.address = address
66 | this.tag = tag
67 | this.downloadDir = downloadDir || this.downloadDir
68 | this.clients = {}
69 | }
70 |
71 | _login(callback) {
72 | return (error, id) => {
73 | if (error) {
74 | callback(error)
75 | return
76 | }
77 |
78 | // id has props from opts
79 | this._setID(id)
80 |
81 | // 1. create server, sending data
82 | const server = net.createServer(this.handleSocket)
83 |
84 | const { host, port, username, tag, address, payload = {} } = id
85 |
86 | // 2. start listening
87 | server.listen({ port, host }, () => {
88 | this.server = server
89 | logger.verbose('>> opened server on', server.address())
90 | logger.verbose(`>> Hi! ${username}[${tag}]`)
91 |
92 | // 3. connect to other servers
93 | ensureMergeIPset(payload)
94 | this.connectServers(payload)
95 | this.active = true
96 | callback(null, {
97 | host,
98 | port,
99 | username,
100 | tag,
101 | address,
102 | })
103 | })
104 | }
105 | }
106 |
107 | destory(callback = noop) {
108 | this.files.destory()
109 | fileInfoPool.destory()
110 |
111 | each(this.clients, client => {
112 | client.end()
113 | client.destroy()
114 | })
115 |
116 | this.server.close(() => {
117 | this.active = false
118 | logger.verbose(`>> Bye! ${this.username}[${this.tag}]`)
119 | setImmediate(callback)
120 | // when reloading, why process.nextTick make the app slow
121 | // https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/#process-nexttick
122 | // process.nextTick create a microtask, close event callback is also a microtask
123 | })
124 | }
125 |
126 | exit(callback = noop) {
127 | if (this.active) {
128 | this.destory(callback)
129 | } else {
130 | callback()
131 | }
132 | }
133 | }
134 |
135 | module.exports = Chat
136 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Settings/Connect/index.js:
--------------------------------------------------------------------------------
1 | import { Form, Input, Icon, Button, Col } from 'antd'
2 | import React, { Component } from 'react'
3 | import { ipcRenderer } from 'electron'
4 | import PropTypes from 'prop-types'
5 |
6 | import Modal from '../../Common/Modal'
7 | import { validAddress, validPort } from '../validators'
8 |
9 | const InputGroup = Input.Group
10 | const FormItem = Form.Item
11 |
12 | let uuid = 0
13 |
14 | const validForm = (form, callback) => {
15 | form.validateFields((err, values) => {
16 | if (err) {
17 | callback(err)
18 | return
19 | }
20 |
21 | const connects = values.keys.map(i => {
22 | const port = Math.floor(values[`port-${i}`])
23 | const host = values[`address-${i}`]
24 | return { port, host }
25 | })
26 |
27 | if (connects.length > 0) {
28 | ipcRenderer.send('change-setting', { connects })
29 | }
30 |
31 | callback()
32 | })
33 | }
34 |
35 | @Form.create()
36 | class Connect extends Component {
37 | static propTypes = {
38 | hide: PropTypes.func.isRequired,
39 | visible: PropTypes.bool.isRequired,
40 | form: PropTypes.object.isRequired,
41 | }
42 |
43 | remove = k => {
44 | const { form } = this.props
45 | const keys = form.getFieldValue('keys')
46 | if (keys.length === 1) return
47 | form.setFieldsValue({
48 | keys: keys.filter(key => key !== k),
49 | })
50 | }
51 |
52 | add = () => {
53 | const { form } = this.props
54 |
55 | uuid += 1
56 | const keys = form.getFieldValue('keys')
57 | const nextKeys = keys.concat(uuid)
58 | form.setFieldsValue({
59 | keys: nextKeys,
60 | })
61 | }
62 |
63 | handleCancel = () => {
64 | this.props.hide()
65 | }
66 |
67 | handleCreate = () => {
68 | const { form, hide } = this.props
69 | validForm(form, err => {
70 | if (!err) {
71 | form.resetFields()
72 | hide()
73 | }
74 | })
75 | }
76 |
77 | render() {
78 | const { form } = this.props
79 | const { getFieldDecorator, getFieldValue } = form
80 |
81 | const { remove, add } = this
82 | getFieldDecorator('keys', { initialValue: [] })
83 | const keys = getFieldValue('keys')
84 | const minusCircle = k => ({
85 | addonAfter:
86 | keys.length > 1 ? (
87 | remove(k)}
91 | />
92 | ) : null,
93 | })
94 | const formItems = keys.map(k => (
95 |
96 |
97 |
98 | {getFieldDecorator(`address-${k}`, {
99 | validateTrigger: ['onChange'],
100 | rules: [
101 | {
102 | validator: validAddress,
103 | },
104 | ],
105 | })()}
106 |
107 |
108 |
109 |
110 |
111 | {getFieldDecorator(`port-${k}`, {
112 | validateTrigger: ['onChange'],
113 | rules: [
114 | {
115 | validator: validPort,
116 | },
117 | {
118 | required: true,
119 | message: 'Please input port or remove the entry',
120 | },
121 | ],
122 | })()}
123 |
124 |
125 |
126 | ))
127 |
128 | const { visible } = this.props
129 | const { handleCancel, handleCreate } = this
130 | return (
131 |
139 |
147 |
148 | )
149 | }
150 | }
151 |
152 | export default Connect
153 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Settings/CreateChannel/index.js:
--------------------------------------------------------------------------------
1 | import { Form, Input, Checkbox, Button, Alert } from 'antd'
2 | import React, { Component } from 'react'
3 | import PropTypes from 'prop-types'
4 | import { ipcRenderer } from 'electron'
5 |
6 | import Modal from '../../Common/Modal'
7 | import { validName } from '../validators'
8 |
9 | const CheckboxGroup = Checkbox.Group
10 | const FormItem = Form.Item
11 |
12 | const create = ({ tags, name }) => {
13 | ipcRenderer.send('create-channel', { tags, name })
14 | }
15 |
16 | const validForm = (form, callback) => {
17 | form.validateFields((err, values) => {
18 | if (err) {
19 | callback(err)
20 | return
21 | }
22 | create(values)
23 | callback()
24 | })
25 | }
26 |
27 | @Form.create()
28 | class CreateChannel extends Component {
29 | static checkboxsField = 'tags'
30 |
31 | static propTypes = {
32 | hide: PropTypes.func.isRequired,
33 | form: PropTypes.object.isRequired,
34 | visible: PropTypes.bool.isRequired,
35 | onlineUsers: PropTypes.arrayOf(PropTypes.object).isRequired,
36 | }
37 |
38 | state = {
39 | indeterminate: false,
40 | checkAll: false,
41 | }
42 |
43 | handleCancel = () => {
44 | this.props.hide()
45 | }
46 |
47 | handleCreate = () => {
48 | const { form, hide } = this.props
49 | validForm(form, err => {
50 | if (!err) {
51 | form.resetFields()
52 | hide()
53 | }
54 | })
55 | }
56 |
57 | onChange = checkedList => {
58 | const { onlineUsers } = this.props
59 | this.setState({
60 | indeterminate: checkedList.length > 0 && checkedList.length < onlineUsers.length,
61 | checkAll: checkedList.length === onlineUsers.length,
62 | })
63 | }
64 |
65 | onCheckAllChange = e => {
66 | const values = this.props.onlineUsers.map(x => x.value)
67 | this.props.form.setFieldsValue({
68 | [CreateChannel.checkboxsField]: e.target.checked ? values : [],
69 | })
70 | this.setState({
71 | indeterminate: false,
72 | checkAll: e.target.checked,
73 | })
74 | }
75 |
76 | renderForm() {
77 | const { getFieldDecorator } = this.props.form
78 | const { onlineUsers } = this.props
79 | return (
80 |
114 | )
115 | }
116 |
117 | render() {
118 | const { onlineUsers, visible } = this.props
119 | const props = {}
120 | if (onlineUsers.length <= 0) {
121 | props.footer = (
122 |
125 | )
126 | props.children = (
127 |
132 | )
133 | } else {
134 | props.children = this.renderForm()
135 | }
136 | return (
137 |
146 | )
147 | }
148 | }
149 |
150 | export default CreateChannel
151 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Aside/ChatList/index.js:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react'
2 | import { Menu } from 'antd'
3 | import { ipcRenderer } from 'electron'
4 | import PropTypes from 'prop-types'
5 | import { isEqual } from 'lodash'
6 | import EventObservable from 'p2p-chat-utils/EventObservable'
7 | import { formatTag } from '../../../utils/format'
8 | import ListItem from '../ListItem'
9 | import DialogType from './DialogType'
10 | import styles from './ChatList.scss'
11 |
12 | function idOf(tag, channel) {
13 | if (channel) {
14 | return { type: 'channel', key: channel }
15 | }
16 | return { type: 'user', key: tag }
17 | }
18 |
19 | class ChatList extends PureComponent {
20 | static propTypes = {
21 | current: PropTypes.shape({
22 | type: PropTypes.string,
23 | key: PropTypes.string,
24 | }),
25 | addUser: PropTypes.func.isRequired,
26 | offUser: PropTypes.func.isRequired,
27 | addChannel: PropTypes.func.isRequired,
28 | setup: PropTypes.func.isRequired,
29 | increaseBadge: PropTypes.func.isRequired,
30 | clearBadge: PropTypes.func.isRequired,
31 | changeDialog: PropTypes.func.isRequired,
32 | users: PropTypes.arrayOf(
33 | PropTypes.shape({
34 | username: PropTypes.string,
35 | tag: PropTypes.string,
36 | })
37 | ).isRequired,
38 | channels: PropTypes.arrayOf(
39 | PropTypes.shape({
40 | users: PropTypes.object,
41 | })
42 | ).isRequired,
43 | visible: PropTypes.bool.isRequired,
44 | }
45 |
46 | observables = EventObservable(ipcRenderer)
47 |
48 | componentWillMount() {
49 | const { addUser, offUser, addChannel, setup, increaseBadge } = this.props
50 | const { observe } = this.observables
51 | observe('login', (event, message) => {
52 | addUser(message)
53 | })
54 |
55 | observe('logout', (event, message) => {
56 | offUser(message)
57 | })
58 |
59 | observe('channel-create', (events, { channel }) => {
60 | addChannel(channel)
61 | })
62 |
63 | observe('after-setup', (event, { users, channels }) => {
64 | setup({ users, channels })
65 | })
66 |
67 | const handleIncome = (event, { tag, channel }) => {
68 | const currentID = this.props.current
69 | const id = idOf(tag, channel)
70 | if (!isEqual(id, currentID)) {
71 | increaseBadge(id, currentID)
72 | }
73 | }
74 |
75 | observe('fileinfo', handleIncome)
76 | observe('text', handleIncome)
77 | }
78 |
79 | componentWillUnmount() {
80 | this.observables.removeAllObservables()
81 | }
82 |
83 | componentWillReceiveProps(nextProps) {
84 | const currentID = this.props.current
85 | const nextID = nextProps.current
86 |
87 | if (nextID && nextID.type && !isEqual(currentID, nextID)) {
88 | this.props.clearBadge(nextID)
89 | }
90 | }
91 |
92 | handleClick = e => {
93 | const [key, type] = e.keyPath
94 | this.props.changeDialog(type, key)
95 | }
96 |
97 | render() {
98 | const { users, channels, visible } = this.props
99 | if (!visible) {
100 | return null
101 | }
102 |
103 | return (
104 |
134 | )
135 | }
136 | }
137 |
138 | export default ChatList
139 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Chatting/Dialog/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import { Input, Button, Form, Upload, Collapse } from 'antd'
4 | import Messages from '../Messages'
5 | import { formatTag } from '../../../utils/format'
6 | import styles from './Dialog.scss'
7 |
8 | const { TextArea } = Input
9 | const { Panel } = Collapse
10 |
11 | class Dialog extends Component {
12 | static propTypes = {
13 | setText: PropTypes.func.isRequired,
14 | id: PropTypes.object.isRequired,
15 | fileList: PropTypes.arrayOf(PropTypes.object).isRequired,
16 | sendFiles: PropTypes.func.isRequired,
17 | text: PropTypes.string.isRequired,
18 | sendMessage: PropTypes.func.isRequired,
19 | removeFile: PropTypes.func.isRequired,
20 | addFile: PropTypes.func.isRequired,
21 | messages: PropTypes.array.isRequired,
22 | username: PropTypes.string.isRequired,
23 | info: PropTypes.shape({
24 | online: PropTypes.bool,
25 | username: PropTypes.string,
26 | name: PropTypes.string,
27 | users: PropTypes.object,
28 | }).isRequired,
29 | children: PropTypes.oneOfType([
30 | PropTypes.element,
31 | PropTypes.arrayOf(PropTypes.element),
32 | ]),
33 | }
34 |
35 | handleTextChange = e => {
36 | const { setText, id } = this.props
37 | const text = e.target.value
38 | setText(id, text)
39 | }
40 |
41 | handleSubmit = e => {
42 | e.preventDefault()
43 | const { id, fileList, sendFiles, text, sendMessage, setText } = this.props
44 | if (text) {
45 | sendMessage(id, text)
46 | setText(id, '')
47 | }
48 | if (fileList.length > 0) {
49 | const filePaths = fileList.map(x => x.path)
50 | sendFiles(id, filePaths)
51 | }
52 | }
53 |
54 | handleFileRemove = file => {
55 | const { removeFile, id } = this.props
56 | removeFile(id, file.path)
57 | }
58 |
59 | handleFileAdd = file => {
60 | const { addFile, id } = this.props
61 | addFile(id, file.path)
62 |
63 | return false
64 | }
65 |
66 | render() {
67 | const { messages, username, fileList, text, info } = this.props
68 | return (
69 |
70 | {info.name && (
71 |
72 |
75 | onlines:{' '}
76 |
77 | {username}
78 | [me]
79 | {' '}
80 | {Object.values(info.users)
81 | .filter(x => x.online)
82 | .map(x => (
83 |
84 | {x.username}
85 | {formatTag(x.tag)}{' '}
86 |
87 | ))}
88 |
89 |
90 | )}
91 | {info.username && (
92 |
93 | {info.username}
94 | {formatTag(info.tag)} ({info.online ? `${info.host}:${info.port}` : 'Offline'}
95 | )
96 |
97 | )}
98 |
99 |
{this.props.children}
100 |
106 |
107 |
108 |
127 |
128 | )
129 | }
130 | }
131 |
132 | export default Dialog
133 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/views/Settings.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { bindActionCreators } from 'redux'
3 | import { connect } from 'react-redux'
4 | import { Button } from 'antd'
5 | import PropTypes from 'prop-types'
6 | import { createSelector } from 'reselect'
7 |
8 | import { loginActions } from './SettingsRedux'
9 | import { chatListActions } from './AsideRedux'
10 | import MyInfo from '../components/Settings/MyInfo'
11 | import Login from '../components/Settings/Login'
12 | import Connect from '../components/Settings/Connect'
13 | import ConnectRange from '../components/Settings/ConnectRange'
14 | import CreateChannel from '../components/Settings/CreateChannel'
15 | import ModalBtn from './ModalBtn'
16 |
17 | import { formatTag } from '../utils/format'
18 | import styles from './Settings.scss'
19 |
20 | const selectUsers = state => state.aside.chatList.users
21 |
22 | const selectOnlineUsers = createSelector(
23 | selectUsers,
24 | users =>
25 | Object.values(users)
26 | .filter(({ online }) => Boolean(online))
27 | .map(({ tag, username }) => ({
28 | label: username + formatTag(tag),
29 | value: tag,
30 | }))
31 | )
32 |
33 | @connect(
34 | state => ({
35 | login: state.settings.login,
36 | onlineUsers: selectOnlineUsers(state),
37 | }),
38 | dispatch => ({
39 | resetChatList: () => dispatch(chatListActions.reset()),
40 | loginActions: bindActionCreators(loginActions, dispatch),
41 | })
42 | )
43 | class Settings extends Component {
44 | static propTypes = {
45 | loginActions: PropTypes.shape({
46 | logout: PropTypes.func.isRequired,
47 | backToRoot: PropTypes.func.isRequired,
48 | }),
49 | resetChatList: PropTypes.func.isRequired,
50 | onlineUsers: PropTypes.arrayOf(
51 | PropTypes.shape({
52 | label: PropTypes.string.isRequired,
53 | value: PropTypes.string.isRequired,
54 | })
55 | ).isRequired,
56 | login: PropTypes.shape({
57 | logined: PropTypes.bool.isRequired,
58 | }).isRequired,
59 | }
60 |
61 | logout = () => {
62 | this.props.loginActions.logout()
63 | this.props.resetChatList()
64 | this.props.loginActions.backToRoot()
65 | }
66 |
67 | render() {
68 | const { onlineUsers, login } = this.props
69 | const { logined } = login
70 | return (
71 |
72 | {!logined && (
73 |
74 | {({ hide, visible }) => (
75 |
81 | )}
82 |
83 | )}
84 | {logined && (
85 |
86 |
87 | {({ show, hide, visible }) => (
88 |
89 |
90 |
91 |
92 | )}
93 | {' '}
94 |
95 | {({ show, hide, visible }) => (
96 |
97 |
98 |
99 |
100 | )}
101 | {' '}
102 |
103 | {({ show, hide, visible }) => (
104 |
105 |
106 |
111 |
112 | )}
113 | {' '}
114 |
120 |
121 | )}
122 |
123 |
124 | )
125 | }
126 | }
127 |
128 | export default Settings
129 |
--------------------------------------------------------------------------------
/packages/p2p-chat/config/renderer.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const Config = require('wtf-webpack-config')
3 | const HtmlWebpackPlugin = require('html-webpack-plugin')
4 |
5 | const devServer = require('./devServer')
6 | const css = require('./css')
7 | const depExternals = require('./dep-externals')
8 | const analyzer = require('./analyzer')
9 | const generateScopedName = require('./generateScopedName')
10 | const pkg = require('../package.json')
11 |
12 | const getLocalIdent = (context, localIdentName, localName) =>
13 | generateScopedName(localName, context.resourcePath)
14 |
15 | module.exports = (env = {}) => {
16 | const SERVE = Boolean(env.serve)
17 | const PROD = env.production && !SERVE
18 |
19 | const PUBLIC_PATH = PROD ? '' : '/'
20 | const SRC_DIR = path.join(__dirname, '../src')
21 | const OUTPUT_DIR = path.join(__dirname, '../assets')
22 | const defaultInclude = [SRC_DIR]
23 |
24 | const config = new Config({
25 | mode: SERVE || !PROD ? 'development' : 'production',
26 | devtool: PROD ? '' : 'cheap-module-source-map',
27 | entry: {
28 | app: `${SRC_DIR}/index.js`,
29 | },
30 | output: {
31 | path: OUTPUT_DIR,
32 | filename: '[name].js',
33 | publicPath: PUBLIC_PATH,
34 | },
35 | target: 'electron-renderer',
36 | node: {
37 | console: false,
38 | global: false,
39 | process: false,
40 | Buffer: false,
41 | __filename: false,
42 | __dirname: false,
43 | setImmediate: false,
44 | },
45 | externals: [depExternals(pkg.dependencies)],
46 | })
47 | .rule({
48 | test: /\.js$/,
49 | include: defaultInclude,
50 | loader: 'babel-loader',
51 | options: {
52 | babelrc: false,
53 | presets: [
54 | [
55 | '@dgeibi/babel-preset-react-app',
56 | {
57 | targets: {
58 | electron: '4.0.0',
59 | },
60 | useBuiltIns: 'usage',
61 | shippedProposals: true,
62 | },
63 | ],
64 | ],
65 | plugins: [
66 | [
67 | 'import',
68 | {
69 | libraryName: 'antd',
70 | libraryDirectory: 'es',
71 | style: 'css',
72 | },
73 | 'antd-import',
74 | ],
75 | [
76 | 'import',
77 | {
78 | libraryName: 'lodash',
79 | libraryDirectory: '',
80 | camel2DashComponentName: false,
81 | },
82 | 'lodash-import',
83 | ],
84 | SERVE && 'react-hot-loader/babel',
85 | ].filter(Boolean),
86 | },
87 | })
88 | .rule({
89 | test: /\.(woff|woff2|eot|ttf|svg)(\?v=\d+\.\d+\.\d+)?$/,
90 | loader: 'file-loader',
91 | options: {
92 | name: '[name].[ext]',
93 | },
94 | })
95 | .use(
96 | css({
97 | rule: {
98 | test: /\.css$/,
99 | use: [
100 | {
101 | loader: 'css-loader',
102 | options: {
103 | importLoaders: 1,
104 | sourceMap: false,
105 | },
106 | },
107 | { loader: 'postcss-loader', options: { sourceMap: false } },
108 | ],
109 | },
110 | extract: PROD,
111 | })
112 | )
113 | .use(
114 | css({
115 | rule: {
116 | test: /\.scss$/,
117 | use: [
118 | {
119 | loader: 'css-loader',
120 | options: {
121 | getLocalIdent,
122 | modules: true,
123 | importLoaders: 1,
124 | sourceMap: false,
125 | },
126 | },
127 | { loader: 'postcss-loader', options: { sourceMap: false } },
128 | ],
129 | },
130 | extract: PROD,
131 | })
132 | )
133 | .plugin(HtmlWebpackPlugin, [
134 | {
135 | title: pkg.name,
136 | template: 'src/index.ejs',
137 | filename: 'index.html',
138 | },
139 | ])
140 | .use(
141 | devServer({
142 | contentBase: OUTPUT_DIR,
143 | }),
144 | SERVE
145 | )
146 | .use(analyzer, Boolean(env.report))
147 | .plugin(require('mini-css-extract-plugin'), [], PROD)
148 |
149 | return config.toConfig()
150 | }
151 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Chatting/FilePanel/redux.js:
--------------------------------------------------------------------------------
1 | import { ipcRenderer } from 'electron'
2 | import makeConstants from '../../../utils/constants'
3 | import getNewState from '../../../utils/getNewState'
4 | import createReducer from '../../../utils/createReducer'
5 |
6 | import { fileLoadStates, cardTypes } from './constants'
7 |
8 | function findPos({ tag, channel, id }) {
9 | if (channel) {
10 | return { type: 'channel', key: channel, id }
11 | }
12 | return { type: 'user', key: tag, id }
13 | }
14 |
15 | const initialState = {
16 | user: {},
17 | channel: {},
18 | }
19 |
20 | const TYPES = {
21 | FILE_INFO: '',
22 | FILE_START: '',
23 | FILE_PROCESSING: '',
24 | FILE_END: '',
25 | FILE_FAIL: '',
26 | FILE_DONE: '',
27 | ACCEPT_FILE: '',
28 | CLEAR_PANEL: '',
29 | IGNORE_FILE: '',
30 | }
31 |
32 | makeConstants(TYPES, 'CHATTING_FILE')
33 |
34 | const updateFileInfo = (state, action) => {
35 | const { type, key, id } = findPos(action.payload)
36 | return {
37 | ...state,
38 | [type]: {
39 | ...state[type],
40 | [key]: {
41 | ...state[type][key],
42 | [id]: {
43 | ...state[type][key][id],
44 | ...action.payload,
45 | },
46 | },
47 | },
48 | }
49 | }
50 |
51 | const reducerMap = {
52 | [TYPES.ACCEPT_FILE]: updateFileInfo,
53 | [TYPES.FILE_START]: updateFileInfo,
54 | [TYPES.FILE_END]: updateFileInfo,
55 | [TYPES.FILE_PROCESSING]: updateFileInfo,
56 | [TYPES.FILE_FAIL]: updateFileInfo,
57 | [TYPES.FILE_DONE]: updateFileInfo,
58 |
59 | [TYPES.FILE_INFO](state, action) {
60 | const { type, key, id } = findPos(action.payload)
61 | return {
62 | ...state,
63 | [type]: {
64 | ...state[type],
65 | [key]: {
66 | [id]: action.payload,
67 | ...state[type][key],
68 | },
69 | },
70 | }
71 | },
72 |
73 | [TYPES.CLEAR_PANEL](state, action) {
74 | const { type, key } = action.payload
75 | const newState = getNewState(state, type, key)
76 | const panel = newState[type][key]
77 | Object.values(panel).forEach(({ status, id }) => {
78 | if (
79 | status === undefined ||
80 | status === fileLoadStates.exception ||
81 | status === fileLoadStates.success
82 | ) {
83 | delete panel[id]
84 | }
85 | })
86 | return newState
87 | },
88 |
89 | [TYPES.IGNORE_FILE](state, action) {
90 | const { type, key, id } = findPos(action.payload)
91 | const newState = getNewState(state, type, key)
92 | const panel = newState[type][key]
93 | delete panel[id]
94 | return newState
95 | },
96 | }
97 |
98 | export default createReducer(reducerMap, initialState)
99 |
100 | export const fileCome = message => ({
101 | type: TYPES.FILE_INFO,
102 | payload: { ...message, type: cardTypes.INFO },
103 | })
104 |
105 | export const fileStart = message => ({
106 | type: TYPES.FILE_START,
107 | payload: {
108 | ...message,
109 | type: cardTypes.RECEIVE,
110 | percent: 0,
111 | speed: 0,
112 | status: fileLoadStates.active,
113 | },
114 | })
115 |
116 | export const fileProcessing = message => ({
117 | type: TYPES.FILE_PROCESSING,
118 | payload: message,
119 | })
120 |
121 | export const fileEnd = message => ({
122 | type: TYPES.FILE_END,
123 | payload: { ...message, percent: 1, speed: 0 },
124 | })
125 |
126 | export const fileReceiveError = message => ({
127 | type: TYPES.FILE_FAIL,
128 | payload: {
129 | ...message,
130 | status: fileLoadStates.exception,
131 | },
132 | })
133 |
134 | export const fileReceived = message => ({
135 | type: TYPES.FILE_DONE,
136 | payload: { ...message, status: fileLoadStates.success },
137 | })
138 |
139 | export const acceptFile = ({ tag, checksum, channel, id }) => {
140 | ipcRenderer.send('accept-file', {
141 | tag,
142 | checksum,
143 | payload: { checksum, channel, id },
144 | })
145 | return {
146 | type: TYPES.ACCEPT_FILE,
147 | payload: {
148 | tag,
149 | channel,
150 | id,
151 | status: fileLoadStates.waitting,
152 | },
153 | }
154 | }
155 |
156 | export const ignoreFile = ({ tag, channel, id }) => ({
157 | type: TYPES.IGNORE_FILE,
158 | payload: { tag, id, channel },
159 | })
160 |
161 | export const clearPanel = ({ type, key }) => ({
162 | type: TYPES.CLEAR_PANEL,
163 | payload: { type, key },
164 | })
165 |
--------------------------------------------------------------------------------
/packages/p2p-chat/src/components/Chatting/Dialog/redux.js:
--------------------------------------------------------------------------------
1 | import { ipcRenderer } from 'electron'
2 | import createReducer from '../../../utils/createReducer'
3 | import makeConstants from '../../../utils/constants'
4 | import getNewState from '../../../utils/getNewState'
5 | import storage from '../../../utils/storage'
6 |
7 | const initialState = {
8 | user: {},
9 | channel: {},
10 | }
11 |
12 | const TYPES = {
13 | NEW_MESSAGE: '',
14 | MESSAGE_SENT: '',
15 | FETCH_MESSAGES: '',
16 | MESSAGES_FETCHED: '',
17 | SEND_MY_MESSAGE: '',
18 | SEND_FILES: '',
19 | FILE_SENT: '',
20 | FILE_SEND_ERROR: '',
21 | SOCKET_ERROR: '',
22 | ADD_FILE: '',
23 | REMOVE_FILE: '',
24 | SET_TEXT: '',
25 | RESTORE: '',
26 | }
27 | makeConstants(TYPES, 'DIALOG')
28 |
29 | function updateMessage(state, action) {
30 | const type = action.payload.channel ? 'channel' : 'user'
31 | const key = action.payload.channel || action.payload.tag
32 | const newState = getNewState(state, type, key)
33 | if (newState[type][key].messages) {
34 | newState[type][key].messages = [...state[type][key].messages, action.payload]
35 | } else {
36 | newState[type][key].messages = [action.payload]
37 | }
38 | return newState
39 | }
40 |
41 | const reducerMap = {
42 | [TYPES.FILE_SENT]: updateMessage,
43 | [TYPES.FILE_SEND_ERROR]: updateMessage,
44 | [TYPES.MESSAGE_SENT]: updateMessage,
45 | [TYPES.NEW_MESSAGE]: updateMessage,
46 | [TYPES.SOCKET_ERROR]: updateMessage,
47 | [TYPES.ADD_FILE](state, action) {
48 | const { type, key } = action.id
49 | const newState = getNewState(state, type, key)
50 | const s = newState[type][key]
51 | if (s.filePaths) {
52 | s.filePaths = [...new Set([...s.filePaths, action.payload])]
53 | } else {
54 | s.filePaths = [action.payload]
55 | }
56 | return newState
57 | },
58 | [TYPES.REMOVE_FILE](state, action) {
59 | const { type, key } = action.id
60 | const newState = getNewState(state, type, key)
61 | const s = newState[type][key]
62 | s.filePaths = s.filePaths.slice()
63 | const index = s.filePaths.indexOf(action.payload)
64 | s.filePaths.splice(index, 1)
65 | return newState
66 | },
67 | [TYPES.SEND_FILES](state, action) {
68 | const { type, key } = action.id
69 | const newState = getNewState(state, type, key)
70 | newState[type][key].filePaths = []
71 | return newState
72 | },
73 | [TYPES.SET_TEXT](state, action) {
74 | const { type, key } = action.id
75 | const newState = getNewState(state, type, key)
76 | newState[type][key].text = action.payload
77 | return newState
78 | },
79 | [TYPES.RESTORE](state, action) {
80 | if (action.payload) return action.payload
81 | return state
82 | },
83 | }
84 |
85 | export default createReducer(reducerMap, initialState)
86 |
87 | export const newMessage = msg => {
88 | const now = Date.now()
89 | const payload = {
90 | ...msg,
91 | uid: now + msg.tag,
92 | date: now,
93 | }
94 | return {
95 | type: TYPES.NEW_MESSAGE,
96 | payload,
97 | }
98 | }
99 |
100 | export const sendMessage = (id, text) => {
101 | const { tags, channel } = id
102 | ipcRenderer.send('local-text', { tags, payload: { text, channel } })
103 |
104 | return {
105 | type: TYPES.SEND_MY_MESSAGE,
106 | id,
107 | }
108 | }
109 |
110 | export const sendFiles = (id, paths) => {
111 | const { tags, channel } = id
112 |
113 | paths.forEach(filepath => {
114 | ipcRenderer.send('local-file', { tags, filepath, payload: { channel } })
115 | })
116 |
117 | return {
118 | type: TYPES.SEND_FILES,
119 | id,
120 | }
121 | }
122 |
123 | export const fileSentNotice = info => {
124 | const { filename, username } = info
125 | const message = `sent ${username} '${filename}' successfully.`
126 | const payload = {
127 | ...info,
128 | uid: info.filename + info.tag + performance.now(),
129 | alert: 'success',
130 | message,
131 | }
132 | return {
133 | type: TYPES.FILE_SENT,
134 | payload,
135 | }
136 | }
137 |
138 | export const textSent = msg => {
139 | const uid = Date.now()
140 | const payload = {
141 | ...msg,
142 | uid,
143 | self: true,
144 | date: uid,
145 | }
146 | return {
147 | type: TYPES.MESSAGE_SENT,
148 | payload,
149 | }
150 | }
151 |
152 | export const fileSendError = info => {
153 | const { filename, username, tag, error } = info
154 | const message = `Failed to send ${username} '${filename}'`
155 | const description = error.message
156 | const payload = {
157 | ...info,
158 | uid: filename + tag + Date.now(),
159 | alert: 'error',
160 | message,
161 | description,
162 | }
163 | return {
164 | type: TYPES.FILE_SEND_ERROR,
165 | payload,
166 | }
167 | }
168 |
169 | export const socketError = info => {
170 | const { filename, tag, error } = info
171 | const message = `Something Wrong with the socket`
172 | const description = error.message
173 | const payload = {
174 | ...info,
175 | uid: filename + tag + Date.now(),
176 | alert: 'error',
177 | message,
178 | description,
179 | }
180 | return {
181 | type: TYPES.SOCKET_ERROR,
182 | payload,
183 | }
184 | }
185 |
186 | export const addFile = (id, path) => ({
187 | type: TYPES.ADD_FILE,
188 | id,
189 | payload: path,
190 | })
191 |
192 | export const removeFile = (id, path) => ({
193 | type: TYPES.REMOVE_FILE,
194 | id,
195 | payload: path,
196 | })
197 |
198 | export const setText = (id, text) => ({
199 | type: TYPES.SET_TEXT,
200 | id,
201 | payload: text,
202 | })
203 |
204 | export const restoreDialog = tag => ({
205 | type: TYPES.RESTORE,
206 | payload: storage.get(`dialog-${tag}`),
207 | })
208 |
--------------------------------------------------------------------------------
/packages/p2p-chat-core/lib/enhanceSocket/Parse.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-param-reassign, no-underscore-dangle */
2 |
3 | const parseChunks = require('p2p-chat-utils/parse-chunks')
4 | const ensureUniqueFile = require('p2p-chat-utils/ensure-unique-file')
5 | const eventAll = require('p2p-chat-utils/event-all')
6 | const { createWriteStream } = require('fs')
7 | const crypto = require('crypto')
8 | const { EventEmitter } = require('events')
9 | const { resolve } = require('path')
10 |
11 | class Parse extends EventEmitter {
12 | /**
13 | * @param {Object} options
14 | * @param {net.Socket} options.socket
15 | * @param {string} [options.delimiter]
16 | * @param {Object} [options.speedMeter]
17 | * @param {number} [options.speedMeter.gap]
18 | * @param {boolean} [options.speedMeter.enable]
19 | */
20 | constructor(options) {
21 | super()
22 | this.opts = Object.assign(
23 | {
24 | delimiter: '\n',
25 | speedMeter: {
26 | gap: 400,
27 | enable: true,
28 | },
29 | },
30 | options
31 | )
32 | this.socket = options.socket
33 | this.headCaches = []
34 | this.resetState()
35 | }
36 |
37 | resetState() {
38 | this.writeStream = null
39 | this.interval = null
40 | this.hasher = null
41 | this.msg = null
42 | this.bodyLeft = 0
43 | }
44 |
45 | emitError(error) {
46 | this.socket.emit('error', error)
47 | }
48 |
49 | submitMsg(reset = true) {
50 | if (!this.msg) {
51 | this.emitError(Error('message not found'))
52 | return
53 | }
54 | this.msg.realHost = this.msg.host
55 | this.msg.host = this.socket.remoteAddress
56 | this.socket.emit('message', this.msg)
57 | if (reset) this.msg = null
58 | }
59 |
60 | write(data) {
61 | if (!this.writeStream.write(data)) {
62 | this.socket.pause()
63 | this.writeStream.once('drain', () => {
64 | this.socket.resume()
65 | })
66 | }
67 | this.hasher.write(data)
68 | }
69 |
70 | transformBody(buffer) {
71 | if (buffer.byteLength > this.bodyLeft) {
72 | // has head start
73 | const pos = this.bodyLeft
74 | this.write(buffer.slice(0, pos))
75 | this.processDone()
76 | this.transform(buffer.slice(pos))
77 | } else {
78 | // cache the whole buffer
79 | this.write(buffer)
80 | this.bodyLeft -= buffer.byteLength
81 | if (this.bodyLeft === 0) {
82 | this.processDone()
83 | }
84 | }
85 | }
86 |
87 | processStart() {
88 | try {
89 | const pathname = ensureUniqueFile(resolve(this.opts.dirname, this.msg.filename))
90 | this.submitFileMsg(pathname)
91 | this.createWriteStream(pathname)
92 | this.openSpeedMeter()
93 | } catch (e) {
94 | this.emitError(e)
95 | }
96 | }
97 |
98 | createWriteStream(pathname) {
99 | const {
100 | socket,
101 | msg: { checksum },
102 | } = this
103 | const writeStream = createWriteStream(pathname)
104 | const hasher = crypto.createHash('md5').setEncoding('hex')
105 |
106 | eventAll([[hasher, 'finish'], [writeStream, 'close']], () => {
107 | socket.emit('file-close', checksum, hasher.read())
108 | })
109 |
110 | hasher.on('error', e => {
111 | socket.emit('error', e)
112 | })
113 | writeStream.on('error', e => {
114 | socket.emit('error', e)
115 | })
116 | this.writeStream = writeStream
117 | this.hasher = hasher
118 | }
119 |
120 | submitFileMsg(pathname) {
121 | this.msg.filepath = pathname
122 | this.submitMsg(false)
123 | }
124 |
125 | openSpeedMeter() {
126 | if (!this.opts.speedMeter || !this.opts.speedMeter.enable) return
127 | this.interval = setInterval(this.processing(), this.opts.speedMeter.gap)
128 | }
129 |
130 | closeSpeedMeter() {
131 | if (this.interval) {
132 | clearInterval(this.interval)
133 | this.interval = null
134 | }
135 | }
136 |
137 | processing() {
138 | let lastLeft = this.bodyLeft
139 | let pass = process.uptime()
140 | const fn = () => {
141 | const now = process.uptime()
142 | const {
143 | bodyLeft,
144 | msg: { bodyLength, checksum },
145 | } = this
146 |
147 | const percent = (bodyLength - bodyLeft) / bodyLength
148 | const speed = (lastLeft - bodyLeft) / (now - pass)
149 |
150 | lastLeft = bodyLeft
151 | pass = now
152 |
153 | this.socket.emit('file-processing', checksum, percent, speed)
154 | }
155 | return fn
156 | }
157 |
158 | processDone() {
159 | // write and emit event
160 | this.writeStream.end()
161 | this.hasher.end()
162 | this.socket.emit('file-done', this.msg.checksum)
163 |
164 | // reset state
165 | this.closeSpeedMeter()
166 | this.resetState()
167 | }
168 |
169 | transform(chunk) {
170 | if (this.bodyLeft <= 0) {
171 | // receive head first
172 | const idx = chunk.indexOf(this.opts.delimiter)
173 | if (idx >= 0) this.transformHead(chunk, idx)
174 | else this.headCaches.push(chunk)
175 | } else {
176 | if (this.msg && this.bodyLeft === this.msg.bodyLength) this.processStart()
177 | this.transformBody(chunk)
178 | }
179 | }
180 |
181 | transformHead(chunk, index) {
182 | const headData = chunk.slice(0, index)
183 | this.headCaches.push(headData)
184 | this.msg = parseChunks(this.headCaches)
185 | if (!this.msg) {
186 | this.emitError(Error('fail to parse chunks'))
187 | return
188 | }
189 |
190 | this.headCaches = [] // empty cache
191 | this.bodyLeft = this.msg.bodyLength || 0
192 |
193 | const isPlainMsg = this.bodyLeft === 0
194 | if (isPlainMsg) this.submitMsg()
195 |
196 | const startPos = index + this.opts.delimiter.length
197 | const left = startPos < chunk.byteLength ? chunk.slice(startPos) : null
198 |
199 | if (left) this.transformHeadLeft(left, isPlainMsg)
200 | }
201 |
202 | transformHeadLeft(chunk, isPlainMsg) {
203 | if (isPlainMsg) {
204 | this.transform(chunk)
205 | } else {
206 | this.processStart()
207 | this.transformBody(chunk)
208 | }
209 | }
210 |
211 | destory() {
212 | this.closeSpeedMeter()
213 | if (this.writeStream) {
214 | this.writeStream.removeAllListeners()
215 | this.writeStream.destroy()
216 | }
217 | if (this.hasher) {
218 | this.hasher.removeAllListeners()
219 | this.hasher.end()
220 | }
221 | }
222 | }
223 |
224 | module.exports = Parse
225 |
--------------------------------------------------------------------------------
/packages/p2p-chat/main/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-param-reassign */
2 | import './handleError'
3 | import path from 'path'
4 | import url from 'url'
5 | import cp from 'child_process'
6 | import EventEmitter from 'events'
7 | import logger from 'p2p-chat-logger'
8 | import electron from 'electron'
9 | import IPset from 'p2p-chat-utils/ipset'
10 | import noop from 'p2p-chat-utils/noop'
11 | import each from 'p2p-chat-utils/each'
12 | import md5 from 'p2p-chat-utils/md5'
13 | import pickByMap from 'p2p-chat-utils/pickByMap'
14 | import { ensureDirSync } from 'fs-extra'
15 | import count from './count'
16 | import pkg from '../package.json'
17 | import loadUserConf from './loadUserConf'
18 | import setContextMenu from './setContextMenu'
19 | import makePlainError from './makePlainError'
20 | import './menu'
21 |
22 | /** @type {cp.ChildProcess} */
23 | let worker
24 |
25 | const tick = count()
26 |
27 | const { app, BrowserWindow, ipcMain } = electron
28 |
29 | const chatProxy = new EventEmitter()
30 | /** @type {BrowserWindow} */
31 | let win
32 |
33 | /* eslint-disable no-use-before-define */
34 |
35 | process.on('uncaughtException', killWorkerAndExit)
36 | process.on('exit', () => {
37 | if (worker && !worker.killed) {
38 | worker.kill()
39 | }
40 | })
41 |
42 | const postToWorker = (key, payload) => {
43 | worker.send({
44 | key,
45 | payload,
46 | })
47 | }
48 |
49 | const locals = {
50 | users: null,
51 | channels: null,
52 | username: null,
53 | tag: null,
54 | host: null,
55 | port: null,
56 | downloadDir: null,
57 | settingsDir: null,
58 | devPort: Math.floor(process.env.DEV_PORT),
59 | userConf: null,
60 | appName: null,
61 | }
62 |
63 | createWorker()
64 |
65 | R2W('setup', opts => {
66 | locals.username = opts.username || 'anonymous'
67 | opts.username = locals.username
68 | opts.downloadDir = locals.downloadDir
69 | })
70 |
71 | R2W('change-setting', payload => {
72 | payload.ipsetStore = getIPsetStore(locals.users || {})
73 | })
74 |
75 | R2W('create-channel', (opts, event) => {
76 | const { tags } = opts
77 | opts.name = opts.name || 'default'
78 | const key = md5.dataSync(opts.name + Math.random())
79 | const users = getUserFullInfos(tags)
80 | users[locals.tag] = {
81 | tag: locals.tag,
82 | host: locals.host,
83 | port: locals.port,
84 | }
85 | const channel = {
86 | key,
87 | users,
88 | name: opts.name,
89 | }
90 | opts.payload = Object.assign({}, opts.payload, { channel, key })
91 | locals.userConf.set(`channels.${key}`, channel)
92 | event.sender.send('channel-create', { channel, key })
93 | })
94 | R2W('logout', () => {
95 | Object.assign(locals, {
96 | username: null,
97 | channels: null,
98 | users: null,
99 | host: null,
100 | port: null,
101 | tag: null,
102 | userConf: null,
103 | })
104 | win.setTitle(pkg.name)
105 | })
106 | R2W('local-file')
107 | R2W('local-text')
108 | R2W('accept-file')
109 |
110 | chatProxy.on('setup-reply', ({ error, id }) => {
111 | if (!error) {
112 | const { username, address, port, host, tag } = id
113 | locals.host = host || address
114 | locals.port = port
115 | locals.tag = tag
116 | win.setTitle(`${username}[${id.tag.slice(0, 5)}] - ${pkg.name}`)
117 | loadSettings(locals)
118 | } else {
119 | logger.err('setup failed\n', error)
120 | }
121 | })
122 |
123 | chatProxy.on('logout-reply', ({ error }) => {
124 | if (error) {
125 | logger.err('logout failed\n', error)
126 | }
127 | })
128 |
129 | chatProxy.on('login', ({ tag, username, host, port }) => {
130 | locals.userConf.set(`users.${tag}`, {
131 | tag,
132 | username,
133 | host,
134 | port,
135 | })
136 | })
137 |
138 | chatProxy.on('channel-create', ({ key, channel }) => {
139 | locals.userConf.set(`channels.${key}`, channel)
140 | })
141 |
142 | app.on('ready', () => {
143 | const appName = app.getName()
144 | const settingsDir = path.join(app.getPath('appData'), appName, 'ChatSettings')
145 | ensureDirSync(settingsDir)
146 | const downloadDir = path.join(app.getPath('downloads'), appName)
147 | ensureDirSync(downloadDir)
148 | Object.assign(locals, {
149 | downloadDir,
150 | settingsDir,
151 | })
152 |
153 | createWindow()
154 | })
155 |
156 | app.on('window-all-closed', () => {
157 | if (process.platform !== 'darwin') {
158 | app.quit()
159 | }
160 | })
161 |
162 | app.on('activate', () => {
163 | if (win === null) {
164 | createWindow()
165 | }
166 | })
167 |
168 | function handleWorkerError(err) {
169 | win.webContents.send('worker-err', { error: makePlainError(err) })
170 | logger.error(err)
171 | if (worker && !worker.killed) worker.kill()
172 | createWorker()
173 | }
174 |
175 | function createWorker() {
176 | if (!tick()) return
177 | worker = cp.fork(`${__dirname}/worker.js`, ['--color'])
178 | logger.debug(`new chat worker ${worker.pid}`)
179 | worker.on('message', m => {
180 | const { key, payload, act, error } = m
181 | if (key) {
182 | chatProxy.emit(key, payload)
183 | win.webContents.send(key, payload)
184 | } else if (act === 'suicide') {
185 | handleWorkerError(error)
186 | }
187 | })
188 |
189 | worker.on('error', handleWorkerError)
190 |
191 | worker.on('exit', () => {
192 | logger.debug(`chat worker ${worker.pid} exits`)
193 | })
194 | }
195 |
196 | function createWindow() {
197 | win = new BrowserWindow({
198 | minWidth: 800,
199 | minHeight: 600,
200 | width: 800,
201 | height: 600,
202 | title: pkg.name,
203 | webPreferences: {
204 | nodeIntegration: true,
205 | },
206 | })
207 |
208 | const urlObj = Object.assign(
209 | {
210 | slashes: true,
211 | },
212 | locals.devPort > 2000
213 | ? {
214 | protocol: 'http:',
215 | host: `localhost:${locals.devPort}`,
216 | pathname: 'index.html',
217 | }
218 | : {
219 | pathname: path.join(__dirname, pkg.output, 'index.html'),
220 | protocol: 'file:',
221 | }
222 | )
223 |
224 | const indexPath = url.format(urlObj)
225 |
226 | win.loadURL(indexPath)
227 |
228 | win.on('closed', () => {
229 | win = null
230 | })
231 |
232 | setContextMenu(win)
233 | }
234 |
235 | function R2W(key, beforePost = noop) {
236 | ipcMain.on(key, (event, payload) => {
237 | beforePost(payload, event)
238 | postToWorker(key, payload)
239 | })
240 | }
241 |
242 | function getUserFullInfos(tags) {
243 | return pickByMap(
244 | locals.users,
245 | {
246 | tag: 'tag',
247 | host: 'host',
248 | port: 'port',
249 | },
250 | tags
251 | )
252 | }
253 |
254 | function getIPsetStore(users) {
255 | const ipset = IPset()
256 | each(users, ({ host, port }) => {
257 | ipset.add(host, port)
258 | })
259 | return ipset.getStore()
260 | }
261 |
262 | function loadSettings(_locals) {
263 | loadUserConf(_locals)
264 | const { users, channels } = _locals
265 |
266 | const payload = {
267 | ipsetStore: getIPsetStore(users),
268 | }
269 |
270 | win.webContents.send('after-setup', { users, channels })
271 | postToWorker('change-setting', payload)
272 | }
273 |
274 | function killWorkerAndExit() {
275 | if (worker && !worker.killed) {
276 | worker.on('close', () => {
277 | process.exit(1)
278 | })
279 | worker.kill()
280 | } else {
281 | process.exit(1)
282 | }
283 | }
284 |
--------------------------------------------------------------------------------
/packages/p2p-chat-core/lib/socketHandler.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-param-reassign, no-underscore-dangle */
2 | const path = require('path')
3 | const logger = require('p2p-chat-logger')
4 | const has = require('p2p-chat-utils/has')
5 |
6 | const each = require('p2p-chat-utils/each')
7 | const IPset = require('p2p-chat-utils/ipset')
8 |
9 | const enhanceSocket = require('./enhanceSocket')
10 | const { connectIPset } = require('./connect')
11 | const fileInfoPool = require('./fileInfoPool')
12 | const fileHanderMakers = require('./fileHanderMakers')
13 | const msgTypes = require('./msgTypes')
14 |
15 | module.exports = superClass =>
16 | class SocketHandler extends superClass {
17 | constructor() {
18 | super()
19 | this.messageHandlerMap = {
20 | [msgTypes.CHANNEL_CREATE]: this.handleChannelCreate,
21 | [msgTypes.FILE_ACCEPTED]: this.handleFileAccept,
22 | [msgTypes.TEXT]: this.handleText,
23 | [msgTypes.FILEINFO]: this.handleFileinfo,
24 | }
25 | this.handleSocket = this.handleSocket.bind(this)
26 | this.handleMessage = this.handleMessage.bind(this)
27 | const handleLogout = this.handleLogout.bind(this)
28 |
29 | this.socketMixins = {
30 | logout(error) {
31 | handleLogout(this, error)
32 | },
33 | }
34 | }
35 |
36 | /**
37 | * @param {net.Socket} socket
38 | * @param {{greeting: boolean, reGreeting: boolean}} opts
39 | */
40 | handleSocket(socket, opts = {}) {
41 | const { greeting } = opts
42 |
43 | enhanceSocket({
44 | socket,
45 | dirname: path.resolve(this.downloadDir, this.username),
46 | mixins: this.socketMixins,
47 | })
48 |
49 | // 连接服务器后,发送信息
50 | if (greeting) socket.send(this.getGreetingMsg())
51 |
52 | // 连接出错,进行下线处理
53 | socket.on('error', e => {
54 | this.emit('error', e)
55 | socket.logout(e)
56 | this.handleError(socket, e)
57 | })
58 |
59 | // 收到第一个报文,一个会话开始
60 | socket.once('message', session => {
61 | const { tag, type } = session
62 | socket.type = type
63 | socket.isFileSocket = function isFileSocket() {
64 | return this.type === msgTypes.FILE
65 | }
66 |
67 | // 对发送文件的socket特殊处理
68 | if (
69 | this.clients[tag] &&
70 | session.type === msgTypes.FILE &&
71 | this.files.has(session.id)
72 | ) {
73 | this.handleFileSocket(socket, session)
74 | return
75 | }
76 |
77 | if (
78 | (type !== msgTypes.GREETING && type !== msgTypes.GREETING_REPLY) ||
79 | this.clients[tag]
80 | ) {
81 | socket.destroy()
82 | return
83 | }
84 |
85 | // 回复信息
86 | const msg = this.getMessage()
87 | msg.type = msgTypes.GREETING_REPLY
88 | socket.send(msg)
89 |
90 | if (type === msgTypes.GREETING) {
91 | this._waitReply(socket, session)
92 | } else {
93 | this._bindSocket(socket, session)
94 | }
95 | })
96 | }
97 |
98 | handleFileSocket(socket, message) {
99 | const { id, tag, username, filepath, channel, size } = message
100 | const filename = path.basename(filepath)
101 | message.filename = filename
102 | this._saveInfo(socket, message)
103 |
104 | this.emit('file-process-start', {
105 | id,
106 | tag,
107 | channel,
108 | size,
109 | filename,
110 | username,
111 | })
112 |
113 | const processing = fileHanderMakers.makeProcessing(this, message)
114 | const done = fileHanderMakers.makeDone(this, socket, message, { processing })
115 | const close = fileHanderMakers.makeClose(this, socket, message)
116 |
117 | socket.on('file-processing', processing)
118 | socket.on('file-done', done)
119 | socket.on('file-close', close)
120 | }
121 |
122 | _waitReply(socket, preSession) {
123 | socket.once('message', session => {
124 | const { type, tag } = session
125 | if (type !== msgTypes.GREETING_REPLY || preSession.tag !== tag) {
126 | socket.destroy()
127 | return
128 | }
129 | this._bindSocket(socket, session)
130 | })
131 | }
132 |
133 | _saveInfo(socket, session) {
134 | socket.info = Object.assign({ localTag: this.tag }, session)
135 | }
136 |
137 | _bindSocket(socket, session) {
138 | const { username, tag } = session
139 |
140 | // 添加信息,存入 clients
141 | this._saveInfo(socket, session)
142 |
143 | // 已登录的提示
144 | if (!this.clients[tag]) {
145 | logger.verbose(`${username}[${tag}] login.`)
146 | this.emit('login', session)
147 | }
148 | this.clients[tag] = socket
149 |
150 | // 连接断开后,进行一些处理
151 | socket.on('end', socket.logout)
152 |
153 | // 处理报文
154 | socket.on('message', this.handleMessage)
155 | }
156 |
157 | /**
158 | * 连接断开/出错 | 下线
159 | * @param {{info: {localTag: string, tag: string, username: string}}} socket
160 | */
161 | handleLogout(socket, error) {
162 | if (!socket) return
163 | if (!socket.info || typeof socket.isFileSocket !== 'function') {
164 | if (typeof socket.destroy === 'function') socket.destroy()
165 | return
166 | }
167 | const { username, tag, localTag } = socket.info
168 | if (localTag === this.tag && !socket.isFileSocket()) {
169 | if (this.clients[tag] && !this.clients[tag].destroyed) {
170 | this.clients[tag].destroy()
171 | }
172 | this.clients[tag] = undefined
173 | logger.verbose(`${username}[${tag}] logout.`)
174 | this.emit('logout', { tag, username, error })
175 | }
176 | if (!socket.destroyed) {
177 | socket.destroy()
178 | }
179 | }
180 |
181 | handleError(socket, error) {
182 | if (error) {
183 | logger.error(error)
184 | }
185 | if (!socket) return
186 | if (!socket.info || typeof socket.isFileSocket !== 'function') {
187 | if (typeof socket.destroy === 'function') socket.destroy()
188 | return
189 | }
190 | const { localTag } = socket.info
191 | if (!socket.destroyed) {
192 | socket.destroy()
193 | }
194 | if (localTag !== this.tag) return
195 | // file socket error
196 | if (socket.isFileSocket()) {
197 | if (error) {
198 | fileHanderMakers.fileReceiveError({
199 | chat: this,
200 | error,
201 | info: socket.info,
202 | })
203 | }
204 | }
205 | }
206 |
207 | handleMessage(message) {
208 | if (has(this.messageHandlerMap, message.type)) {
209 | const handler = this.messageHandlerMap[message.type]
210 | handler.call(this, message)
211 | }
212 | }
213 |
214 | handleFileAccept(message) {
215 | const { checksum, port, host, id } = message
216 | let done = false
217 | fileInfoPool.send(checksum, { id }, { port, host }, (error, filename) => {
218 | if (done) return
219 | done = true
220 | const payload = Object.assign({}, message)
221 | payload.filename = filename
222 |
223 | if (error) {
224 | payload.error = error
225 | logger.err('file-send-fail', filename, error.message)
226 | this.emit('file-send-fail', payload)
227 | } else {
228 | this.emit('file-sent', payload)
229 | }
230 | })
231 | }
232 |
233 | handleChannelCreate({ tag, key, channel }) {
234 | this.emit('channel-create', { tag, key, channel })
235 | const ipset = IPset()
236 | each(channel.users, ({ host, port }) => {
237 | ipset.add(host, port)
238 | })
239 | each(this.clients, ({ info: { host, port } }) => {
240 | ipset.remove(host, port)
241 | })
242 | this.connectIPset(ipset)
243 | }
244 |
245 | handleText(message) {
246 | this.emit('text', message)
247 | }
248 |
249 | handleFileinfo(message) {
250 | message.id = `${message.checksum}?${Date.now()}`
251 | this.emit('fileinfo', message)
252 | }
253 |
254 | connectIPset(ipset) {
255 | if (!ipset) throw Error('ipset should be passed')
256 | const { address, port, handleSocket } = this
257 | ipset.remove(address, port)
258 |
259 | connectIPset(ipset, function handler() {
260 | handleSocket(this, { greeting: true })
261 | })
262 | }
263 |
264 | getGreetingMsg() {
265 | const msg = this.getMessage()
266 | msg.type = msgTypes.GREETING
267 | return msg
268 | }
269 |
270 | getMessage() {
271 | return {
272 | host: this.address,
273 | port: this.port,
274 | username: this.username,
275 | tag: this.tag,
276 | }
277 | }
278 | }
279 |
--------------------------------------------------------------------------------