├── client ├── src │ ├── main │ │ ├── service │ │ │ ├── handle-event.js │ │ │ ├── index.js │ │ │ ├── global.js │ │ │ └── shortcut.js │ │ ├── index.dev.js │ │ ├── config │ │ │ └── tray.js │ │ └── index.js │ ├── renderer │ │ ├── components │ │ │ ├── content │ │ │ │ ├── _parts │ │ │ │ │ ├── content-head.vue │ │ │ │ │ └── default-page.vue │ │ │ │ ├── chat-box │ │ │ │ │ ├── parts │ │ │ │ │ │ ├── message-types │ │ │ │ │ │ │ ├── video-message.vue │ │ │ │ │ │ │ ├── voice-message.vue │ │ │ │ │ │ │ ├── text-message.vue │ │ │ │ │ │ │ ├── file-message.vue │ │ │ │ │ │ │ └── image-message.vue │ │ │ │ │ │ ├── message-item.vue │ │ │ │ │ │ └── message-boxes │ │ │ │ │ │ │ ├── left-message-box.vue │ │ │ │ │ │ │ └── right-message-box.vue │ │ │ │ │ └── special-pages │ │ │ │ │ │ └── video-chat.vue │ │ │ │ └── contact-info │ │ │ │ │ ├── index.vue │ │ │ │ │ └── parts │ │ │ │ │ └── user-info.vue │ │ │ ├── @parts │ │ │ │ ├── dotting.vue │ │ │ │ ├── win-menu.vue │ │ │ │ └── loading.vue │ │ │ ├── sidebar │ │ │ │ ├── contact │ │ │ │ │ ├── parts │ │ │ │ │ │ ├── contact-list.vue │ │ │ │ │ │ ├── search-result.vue │ │ │ │ │ │ ├── group-item.vue │ │ │ │ │ │ └── user-item.vue │ │ │ │ │ └── index.vue │ │ │ │ ├── chat │ │ │ │ │ ├── parts │ │ │ │ │ │ └── chat-item.vue │ │ │ │ │ └── index.vue │ │ │ │ └── menu-bar.vue │ │ │ ├── login.vue │ │ │ └── main.vue │ │ ├── services │ │ │ ├── index.js │ │ │ ├── unknown.js │ │ │ ├── contextmenu.js │ │ │ └── shortcut.js │ │ ├── assets │ │ │ ├── logo.png │ │ │ ├── info-bg.png │ │ │ ├── image-error.png │ │ │ ├── default-avatar.png │ │ │ ├── sounds │ │ │ │ └── new-message.mp3 │ │ │ └── logo.svg │ │ ├── styles │ │ │ ├── fonts │ │ │ │ ├── fonts │ │ │ │ │ ├── icomoon.eot │ │ │ │ │ ├── icomoon.ttf │ │ │ │ │ └── icomoon.woff │ │ │ │ ├── custom-fonts │ │ │ │ │ └── ChenJiShi_xing_shu.ttf │ │ │ │ └── style.css │ │ │ ├── _variables.styl │ │ │ └── style.styl │ │ ├── vue-extend │ │ │ ├── modules │ │ │ │ ├── error.js │ │ │ │ ├── index.js │ │ │ │ ├── dialog.js │ │ │ │ ├── page.js │ │ │ │ ├── md5.js │ │ │ │ ├── decrypt.js │ │ │ │ ├── encrypt.js │ │ │ │ ├── aes.js │ │ │ │ └── utils.js │ │ │ └── index.js │ │ ├── store │ │ │ ├── modules │ │ │ │ ├── Main.js │ │ │ │ ├── Contact.js │ │ │ │ ├── Chat.js │ │ │ │ └── Message.js │ │ │ └── index.js │ │ ├── main.js │ │ ├── utils │ │ │ └── index.js │ │ ├── config.js │ │ ├── router │ │ │ └── index.js │ │ └── app.vue │ └── index.ejs ├── .eslintignore ├── build │ └── icons │ │ ├── icon.icns │ │ └── icon.ico ├── .gitignore ├── README.md ├── .babelrc ├── LICENSE ├── .electron-vue │ ├── dev-client.js │ ├── webpack.main.config.js │ ├── build.js │ ├── webpack.web.config.js │ ├── dev-runner.js │ └── webpack.renderer.config.js ├── .eslintrc.js └── package.json ├── server ├── .gitignore ├── config.js ├── utils │ ├── utils.js │ ├── log.js │ ├── decrypt.js │ ├── encrypt.js │ └── aes.js ├── controllers │ ├── index.js │ └── modules │ │ ├── contact.js │ │ ├── login.js │ │ ├── message.js │ │ └── webrtc.js ├── README.md ├── services │ ├── generate_folder.js │ ├── mongoose.js │ └── file_delete.js ├── models │ ├── category.js │ ├── message.js │ ├── offline-message.js │ └── user.js ├── package.json ├── initData │ └── index.js └── app.js ├── .gitignore ├── README.md ├── LICENSE ├── .vscode └── settings.json └── electron.md /client/src/main/service/handle-event.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/_parts/content-head.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/.eslintignore: -------------------------------------------------------------------------------- 1 | test/unit/coverage/** 2 | test/unit/*.js 3 | test/e2e/*.js 4 | -------------------------------------------------------------------------------- /client/src/main/service/index.js: -------------------------------------------------------------------------------- 1 | import './global'; 2 | import './shortcut'; 3 | -------------------------------------------------------------------------------- /client/build/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola-v2/HEAD/client/build/icons/icon.icns -------------------------------------------------------------------------------- /client/build/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola-v2/HEAD/client/build/icons/icon.ico -------------------------------------------------------------------------------- /client/src/renderer/services/index.js: -------------------------------------------------------------------------------- 1 | import './unknown'; 2 | import './contextmenu'; 3 | import './shortcut'; 4 | -------------------------------------------------------------------------------- /client/src/renderer/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola-v2/HEAD/client/src/renderer/assets/logo.png -------------------------------------------------------------------------------- /client/src/renderer/assets/info-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola-v2/HEAD/client/src/renderer/assets/info-bg.png -------------------------------------------------------------------------------- /client/src/renderer/assets/image-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola-v2/HEAD/client/src/renderer/assets/image-error.png -------------------------------------------------------------------------------- /client/src/renderer/assets/default-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola-v2/HEAD/client/src/renderer/assets/default-avatar.png -------------------------------------------------------------------------------- /client/src/renderer/assets/sounds/new-message.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola-v2/HEAD/client/src/renderer/assets/sounds/new-message.mp3 -------------------------------------------------------------------------------- /client/src/renderer/styles/fonts/fonts/icomoon.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola-v2/HEAD/client/src/renderer/styles/fonts/fonts/icomoon.eot -------------------------------------------------------------------------------- /client/src/renderer/styles/fonts/fonts/icomoon.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola-v2/HEAD/client/src/renderer/styles/fonts/fonts/icomoon.ttf -------------------------------------------------------------------------------- /client/src/renderer/styles/fonts/fonts/icomoon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola-v2/HEAD/client/src/renderer/styles/fonts/fonts/icomoon.woff -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .db/ 3 | node_modules/ 4 | release/ 5 | upload/ 6 | npm-debug.log 7 | npm-debug.log.* 8 | thumbs.db 9 | dump.rdb 10 | !.gitkeep -------------------------------------------------------------------------------- /client/src/renderer/styles/fonts/custom-fonts/ChenJiShi_xing_shu.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola-v2/HEAD/client/src/renderer/styles/fonts/custom-fonts/ChenJiShi_xing_shu.ttf -------------------------------------------------------------------------------- /server/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | SERVER: 'http://localhost:3010', 3 | COMMUNICATION_ENCRYPT_METHOD: 'aes-128-ecb', 4 | COMMUNICATION_ENCRYPT_KEY: '00qXqFtnfPdjYg5p' 5 | }; 6 | -------------------------------------------------------------------------------- /client/src/main/service/global.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | if (process.env.NODE_ENV !== 'development') { 4 | global.__static = path.join(__dirname, '/static').replace(/\\/g, '\\\\'); 5 | } 6 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/electron/* 3 | dist/web/* 4 | build/* 5 | !build/icons 6 | coverage 7 | node_modules/ 8 | npm-debug.log 9 | npm-debug.log.* 10 | thumbs.db 11 | dump.rdb 12 | !.gitkeep 13 | -------------------------------------------------------------------------------- /client/src/renderer/vue-extend/modules/error.js: -------------------------------------------------------------------------------- 1 | function handleResponseError(eventName, response) { 2 | this.$remote.dialog.showErrorBox(eventName, response.message); 3 | } 4 | 5 | export default { 6 | handleResponseError 7 | }; 8 | -------------------------------------------------------------------------------- /client/src/main/service/shortcut.js: -------------------------------------------------------------------------------- 1 | // import { app, globalShortcut } from 'electron'; 2 | 3 | // app.on('ready', () => { 4 | // globalShortcut.register('CommandOrControl+X', () => { 5 | // console.log('CommandOrControl+X is pressed'); 6 | // }); 7 | // }); 8 | -------------------------------------------------------------------------------- /server/utils/utils.js: -------------------------------------------------------------------------------- 1 | function generateUUID() { 2 | let s4 = function() { 3 | return Math.floor((1 + Math.random()) * 0x10000) 4 | .toString(16) 5 | .substring(1); 6 | }; 7 | 8 | return `${s4()}-${s4()}-${s4()}`; 9 | } 10 | 11 | module.exports = { 12 | generateUUID 13 | }; 14 | -------------------------------------------------------------------------------- /server/controllers/index.js: -------------------------------------------------------------------------------- 1 | const login = require('./modules/login'); 2 | const message = require('./modules/message'); 3 | const contact = require('./modules/contact'); 4 | const webrtc = require('./modules/webrtc'); 5 | 6 | module.exports = { 7 | login, 8 | message, 9 | contact, 10 | webrtc 11 | }; 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | server/.db/ 3 | server/node_modules/ 4 | server/release/ 5 | server/upload/ 6 | npm-debug.log 7 | npm-debug.log.* 8 | thumbs.db 9 | dump.rdb 10 | !.gitkeep 11 | 12 | 13 | client/dist/electron/* 14 | client/dist/web/* 15 | client/build/* 16 | !client/build/icons 17 | coverage 18 | client/node_modules/ -------------------------------------------------------------------------------- /client/src/renderer/services/unknown.js: -------------------------------------------------------------------------------- 1 | // stop drag file to the app 2 | document.addEventListener('dragover', function(event) { 3 | event.preventDefault(); 4 | }); 5 | 6 | document.addEventListener('drop', function(event) { 7 | event.preventDefault(); 8 | }); 9 | 10 | // 禁止 fs 模块将 asar 文件视为虚拟文件夹 11 | process.noAsar = true; 12 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | ### Build Steps 2 | 3 | ```bash 4 | # now at server root folder 5 | 6 | ### start database server 7 | # launched at http://localhost:27017/ 8 | mkdir -p .db && mongod --dbpath ./.db 9 | 10 | ### new terminal tab 11 | # install dependency packs 12 | npm install 13 | 14 | # start app server 15 | npm start 16 | ``` 17 | -------------------------------------------------------------------------------- /client/src/renderer/store/modules/Main.js: -------------------------------------------------------------------------------- 1 | const state = { 2 | userInfo: {} 3 | }; 4 | 5 | const mutations = { 6 | RESET_STATE(state) { 7 | state.userInfo = {}; 8 | }, 9 | 10 | SET_USERINFO(state, payload) { 11 | state.userInfo = payload; 12 | } 13 | }; 14 | 15 | export default { 16 | namespaced: true, 17 | state, 18 | mutations 19 | }; 20 | -------------------------------------------------------------------------------- /client/src/renderer/store/modules/Contact.js: -------------------------------------------------------------------------------- 1 | const state = { 2 | currentContact: {} 3 | }; 4 | 5 | const mutations = { 6 | RESET_STATE(state) { 7 | state.currentContact = {}; 8 | }, 9 | 10 | CURRENT_CONTACT(state, obj) { 11 | state.currentContact = obj; 12 | } 13 | }; 14 | 15 | export default { 16 | namespaced: true, 17 | state, 18 | mutations 19 | }; 20 | -------------------------------------------------------------------------------- /server/services/generate_folder.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | // 生成文件上传目录 5 | if (!fs.existsSync(path.resolve(__dirname, '../upload'))) { 6 | fs.mkdirSync(path.resolve(__dirname, '../upload')); 7 | } 8 | 9 | // 生成软件release目录 10 | if (!fs.existsSync(path.resolve(__dirname, '../release'))) { 11 | fs.mkdirSync(path.resolve(__dirname, '../release')); 12 | } 13 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Hola 2 | 3 | #### Build Setup 4 | 5 | ``` bash 6 | # install dependencies 7 | npm install 8 | 9 | # serve with hot reload at localhost:9080 10 | npm run dev 11 | 12 | # 构建软件包 13 | # Windows 32位 14 | npm run build:win32 15 | 16 | # Windows 64位 17 | npm run build:win64 18 | 19 | # run unit tests 20 | npm test 21 | 22 | # lint all JS/Vue component files in `src/` 23 | npm run lint 24 | ``` 25 | 26 | -------------------------------------------------------------------------------- /client/src/renderer/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | import VueExtend from './vue-extend'; 4 | 5 | import App from './app'; 6 | import router from './router'; 7 | import store from './store'; 8 | 9 | import './services'; 10 | 11 | import './styles/style.styl'; 12 | 13 | Vue.use(VueExtend); 14 | 15 | new Vue({ 16 | components: { App }, 17 | router, 18 | store, 19 | template: '' 20 | }).$mount('#app'); 21 | -------------------------------------------------------------------------------- /client/src/renderer/utils/index.js: -------------------------------------------------------------------------------- 1 | // 获取本地主机的IP地址 2 | export function getLocalIpAddress() { 3 | const ifaces = require('os').networkInterfaces(); 4 | let address; 5 | 6 | Object.keys(ifaces).forEach(dev => { 7 | ifaces[dev].filter(details => { 8 | if (details.family === 'IPv4' && details.internal === false) { 9 | address = details.address; 10 | } 11 | }); 12 | }); 13 | 14 | return address; 15 | } 16 | -------------------------------------------------------------------------------- /client/src/renderer/vue-extend/modules/index.js: -------------------------------------------------------------------------------- 1 | import md5 from './md5'; 2 | import decrypt from './decrypt'; 3 | import encrypt from './encrypt'; 4 | import error from './error'; 5 | import dialog from './dialog'; 6 | import page from './page'; 7 | import utils from './utils'; 8 | 9 | export default Object.assign( 10 | {}, 11 | md5, 12 | decrypt, 13 | encrypt, 14 | error, 15 | dialog, 16 | page, 17 | utils 18 | ); 19 | -------------------------------------------------------------------------------- /client/src/renderer/vue-extend/modules/dialog.js: -------------------------------------------------------------------------------- 1 | function showErrorDialog(msg) { 2 | this.$remote.dialog.showErrorBox(msg.title, msg.content); 3 | } 4 | 5 | function showMessageDialog(options, callback) { 6 | this.$remote.dialog.showMessageBox( 7 | this.$win, 8 | { 9 | ...options, 10 | title: 'Hola' // for windows 11 | }, 12 | callback 13 | ); 14 | } 15 | 16 | export default { 17 | showErrorDialog, 18 | showMessageDialog 19 | }; 20 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/chat-box/parts/message-types/video-message.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | 19 | 24 | 25 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/chat-box/parts/message-types/voice-message.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | 19 | 24 | 25 | -------------------------------------------------------------------------------- /server/services/mongoose.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const log = require('../utils/log'); 3 | 4 | const dbConnection = mongoose.connection; 5 | const DB_NAME = 'hola_db'; 6 | 7 | mongoose.connect(`mongodb://localhost:27017/${DB_NAME}`, { 8 | useNewUrlParser: true 9 | }); 10 | 11 | dbConnection.on('error', err => { 12 | log.error(`${err.name}: ${err.message}`); 13 | }); 14 | 15 | dbConnection.once('open', () => { 16 | log.success('Database: connect success'); 17 | }); 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hola v2 2 | 3 | 相比v1,去掉了很多功能,专注实现消息稳定的发送,优化大量交互、细节。 4 | 5 | 用户数据:使用了[**三国在线**](http://www.e3ol.com)的人物数据。(`server/initData/data/users.js`) 6 | 7 | 1. 增加文件的发送功能。 8 | 2. 优化大量交互:支持拖动窗口(Windows下做了适配)、更友好地新消息提示、增加消息已读未读以及消息时间提示。 9 | 3. 支持消息加密解密。暂时只在发送文件的时候进行了数据流的加密和压缩。 10 | 4. 服务端设置定时器,定时清除文件。 11 | 5. 软件更新:实现方法有点鸡肋,是替换 app.asar 文件,然后重启软件实现更新。 12 | 6. 视频聊天的交互有点繁琐,暂时先不做了,不过v1版本中视频聊天的相关代码还是可以继续拿来用的。 13 | 7. 实现本地消息存储。以及离线消息的服务端存储和分发。 14 | 15 | 16 | ```bash 17 | # 本地测试,登录 18 | # 账户名: 六位数的uid,比如诸葛亮的 000284 19 | # 密码: 统一 123456 20 | ``` 21 | 22 | 23 | Just for fun. -------------------------------------------------------------------------------- /client/src/renderer/components/content/chat-box/parts/message-types/text-message.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | 19 | 29 | 30 | -------------------------------------------------------------------------------- /server/utils/log.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | const clog = console.log; 3 | 4 | const INFO_COLOR = '#5C6270'; 5 | const SUCCESS_COLOR = '#67C23A'; 6 | const WARNING_COLOR = '#E6A23C'; 7 | const ERROR_COLOR = '#F56C6C'; 8 | 9 | const log = { 10 | info(msg) { 11 | clog(chalk.hex(INFO_COLOR)(msg)); 12 | }, 13 | success(msg) { 14 | clog(chalk.hex(SUCCESS_COLOR)(msg)); 15 | }, 16 | 17 | warning(msg) { 18 | clog(chalk.hex(WARNING_COLOR)(msg)); 19 | }, 20 | 21 | error(msg) { 22 | clog(chalk.hex(ERROR_COLOR)(msg)); 23 | } 24 | }; 25 | 26 | module.exports = log; 27 | -------------------------------------------------------------------------------- /client/src/renderer/config.js: -------------------------------------------------------------------------------- 1 | // 敏感数据,存放这里,方便 webpack-obfuscator 插件混淆加密 2 | import { md5Str } from './vue-extend/modules/md5'; 3 | import { getLocalIpAddress } from './utils'; 4 | 5 | // 本地数据存储 6 | export const LOCAL_DB_NAME = md5Str('hola-db'); 7 | export const LOCAL_DB_ENCRYPT_KEY = LOCAL_DB_NAME.replace('9', '0'); 8 | 9 | // 服务器相关 10 | export const SOCKET_SERVER = `http://${getLocalIpAddress() || 11 | 'localhost'}:3010`; 12 | export const SOCKET_PATH = '/hola'; 13 | 14 | // 通信加密 15 | export const COMMUNICATION_ENCRYPT_METHOD = 'aes-128-ecb'; 16 | export const COMMUNICATION_ENCRYPT_KEY = '00qXqFtnfPdjYg5p'; 17 | -------------------------------------------------------------------------------- /client/src/renderer/vue-extend/modules/page.js: -------------------------------------------------------------------------------- 1 | function toLoginPage() { 2 | localStorage.clear(); 3 | this.$win.setSize(280, 400); 4 | this.$win.center(); 5 | 6 | // clear local data 7 | this.$electronStore.clear(); 8 | 9 | // clear all state 10 | this.$store.dispatch('clearAllState'); 11 | 12 | this.$router.push({ 13 | path: '/login' 14 | }); 15 | } 16 | 17 | function toHomePage() { 18 | this.$win.setSize(885, 550); 19 | this.$win.center(); 20 | 21 | this.$router.push({ 22 | path: '/app/chat' 23 | }); 24 | } 25 | 26 | export default { 27 | toLoginPage, 28 | toHomePage 29 | }; 30 | -------------------------------------------------------------------------------- /client/src/renderer/styles/_variables.styl: -------------------------------------------------------------------------------- 1 | $app-width = 885px; 2 | $app-height = 550px; 3 | $app-bg = #F6F9FD; 4 | 5 | $sidebar-width = 300px; 6 | 7 | $menu-bar-width = 60px; 8 | $menu-bar-bg = #323A43; 9 | 10 | $search-bar-width = $sidebar-width - $menu-bar-width; 11 | $search-bar-height = 50px; 12 | $search-bar-bg = #353A42; 13 | 14 | $menu-content-bg = #383F47; 15 | 16 | 17 | $content-width = $app-width - $sidebar-width; 18 | 19 | 20 | 21 | 22 | 23 | 24 | // message box 25 | $left-message-box-bg-color = #6c7989; 26 | $left-message-box-font-color = #e0e0e0; 27 | 28 | $right-message-box-bg-color = #525C68; 29 | $right-message-box-font-color = #d6d6d6; -------------------------------------------------------------------------------- /server/models/category.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const CategorySchema = mongoose.Schema( 3 | { 4 | gid: { 5 | type: String, 6 | required: [true, 'gid is required'] 7 | }, 8 | title: String, 9 | children: [Object] // 是否在线 10 | }, 11 | { 12 | // auto generate `createdAt` and `updatedAt` field 13 | timestamps: true 14 | } 15 | ); 16 | 17 | CategorySchema.statics = { 18 | async getCategoryList() { 19 | const categories = await this.where({}) 20 | .lean() 21 | .exec(); 22 | 23 | return categories || []; 24 | } 25 | }; 26 | 27 | module.exports = mongoose.model('Category', CategorySchema); 28 | -------------------------------------------------------------------------------- /client/src/renderer/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | 4 | const actions = { 5 | clearAllState({ commit }) { 6 | commit('Chat/RESET_STATE'); 7 | commit('Contact/RESET_STATE'); 8 | commit('Main/RESET_STATE'); 9 | commit('Message/RESET_STATE'); 10 | } 11 | }; 12 | 13 | const modules = {}; 14 | const files = require.context('./modules', false, /\.js$/); 15 | 16 | files.keys().forEach((key) => { 17 | if (key === './index.js') { 18 | return; 19 | } 20 | 21 | modules[key.replace(/(\.\/|\.js)/g, '')] = files(key).default; 22 | }); 23 | 24 | Vue.use(Vuex); 25 | 26 | export default new Vuex.Store({ 27 | actions, 28 | modules, 29 | strict: process.env.NODE_ENV !== 'production' 30 | }); 31 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "0.0.0", 4 | "author": "percy507", 5 | "description": "", 6 | "main": "index.js", 7 | "scripts": { 8 | "start": "nodemon ./app.js", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "keywords": [], 12 | "license": "MIT", 13 | "dependencies": { 14 | "@koa/cors": "^2.2.3", 15 | "axios": "^0.18.0", 16 | "bytes": "^3.0.0", 17 | "koa": "^2.5.0", 18 | "koa-mount": "^3.0.0", 19 | "koa-static": "^4.0.2", 20 | "mongoose": "^5.0.11", 21 | "node-schedule": "^1.3.1", 22 | "socket.io": "^2.0.4", 23 | "socket.io-stream": "^0.9.1", 24 | "walk": "^2.3.14" 25 | }, 26 | "devDependencies": { 27 | "nodemon": "^1.17.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /client/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "comments": false, 3 | "env": { 4 | "test": { 5 | "presets": [ 6 | ["env", { 7 | "targets": { "node": 7 } 8 | }], 9 | "stage-0" 10 | ], 11 | "plugins": ["istanbul"] 12 | }, 13 | "main": { 14 | "presets": [ 15 | ["env", { 16 | "targets": { "node": 7 } 17 | }], 18 | "stage-0" 19 | ] 20 | }, 21 | "renderer": { 22 | "presets": [ 23 | ["env", { 24 | "modules": false 25 | }], 26 | "stage-0" 27 | ] 28 | }, 29 | "web": { 30 | "presets": [ 31 | ["env", { 32 | "modules": false 33 | }], 34 | "stage-0" 35 | ] 36 | } 37 | }, 38 | "plugins": ["transform-runtime"] 39 | } 40 | -------------------------------------------------------------------------------- /client/src/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | app_client 6 | <% if (htmlWebpackPlugin.options.nodeModules) { %> 7 | 8 | 11 | <% } %> 12 | 13 | 14 |
15 | 16 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /client/src/renderer/components/@parts/dotting.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 42 | -------------------------------------------------------------------------------- /server/controllers/modules/contact.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'get-user-info': getUserInfo, 3 | 'get-contacts': getContacts 4 | }; 5 | 6 | const UserModel = require('../../models/user'); 7 | const CategoryModel = require('../../models/category'); 8 | 9 | async function getUserInfo(uid, callback) { 10 | const userInfo = await UserModel.getUserInfo(uid); 11 | 12 | if (userInfo.uid === uid) { 13 | callback({ 14 | code: 0, 15 | data: { 16 | ...userInfo 17 | }, 18 | message: null 19 | }); 20 | } else { 21 | callback({ 22 | code: 1001, 23 | data: null, 24 | message: '获取用户信息异常' 25 | }); 26 | } 27 | } 28 | 29 | async function getContacts(uid, callback) { 30 | const category = await CategoryModel.getCategoryList(uid); 31 | 32 | callback({ 33 | code: 0, 34 | data: category, 35 | message: null 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/_parts/default-page.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 27 | 28 | 45 | -------------------------------------------------------------------------------- /server/controllers/modules/login.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | login: loginFunc, 3 | logout: logoutFunc 4 | }; 5 | 6 | const decrypt = require('../../utils/decrypt'); 7 | const UserModel = require('../../models/user'); 8 | 9 | async function loginFunc(obj, callback) { 10 | const uid = decrypt.str(obj.uid); 11 | const pmd5 = decrypt.str(obj.pmd5); 12 | 13 | // 用户名为uid,密码为123456 14 | const userInfo = await UserModel.getUserInfo(uid); 15 | const isSuccess = 16 | userInfo.uid && 17 | pmd5.toLocaleUpperCase() === 'E10ADC3949BA59ABBE56E057F20F883E'; 18 | 19 | if (isSuccess) { 20 | callback({ 21 | code: 0, 22 | data: { 23 | ...userInfo 24 | }, 25 | message: null 26 | }); 27 | } else { 28 | callback({ 29 | code: 1, 30 | data: null, 31 | message: '用户名或密码不正确' 32 | }); 33 | } 34 | } 35 | 36 | async function logoutFunc(uid, callback) { 37 | callback(); 38 | } 39 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/contact-info/index.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 40 | 41 | 43 | -------------------------------------------------------------------------------- /server/services/file_delete.js: -------------------------------------------------------------------------------- 1 | const schedule = require('node-schedule'); 2 | const walk = require('walk'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | // 时效:3天,清除策略:每天凌晨3点 7 | const scheduleRule = '0 0 3 * * *'; 8 | const expireRule = 3 * 24 * 60 * 60 * 1000; 9 | 10 | schedule.scheduleJob(scheduleRule, handleFile); 11 | 12 | function handleFile() { 13 | const folderPath = path.resolve(__dirname, '../upload'); 14 | const walker = walk.walk(folderPath); 15 | 16 | walker.on('file', function(root, fileStats, next) { 17 | const fileBirthTime = new Date(fileStats.birthtime).getTime(); 18 | 19 | if (Date.now() - fileBirthTime > expireRule) { 20 | const filePath = path.resolve(folderPath, fileStats.name); 21 | 22 | fs.unlinkSync(filePath); 23 | } 24 | 25 | next(); 26 | }); 27 | 28 | walker.on('errors', function(root, nodeStatsArray, next) { 29 | next(); 30 | }); 31 | 32 | walker.on('end', function() { 33 | console.log('handle file done'); 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /client/src/main/index.dev.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is used specifically and only for development. It installs 3 | * `electron-debug` & `vue-devtools`. There shouldn't be any need to 4 | * modify this file, but it can be used to extend your development 5 | * environment. 6 | */ 7 | 8 | /* eslint-disable */ 9 | 10 | // Set environment for development 11 | process.env.NODE_ENV = 'development'; 12 | 13 | // Install `electron-debug` 14 | require('electron-debug')({ showDevTools: false }); 15 | 16 | require('electron').app.on('ready', () => { 17 | let installExtension = require('electron-devtools-installer'); 18 | 19 | // Install `vue-devtools` 20 | installExtension 21 | .default(installExtension.VUEJS_DEVTOOLS) 22 | .then(extension => { 23 | console.log(`${extension} installed successfull!`); 24 | }) 25 | .catch(err => { 26 | console.log('Unable to install `vue-devtools`: \n', err); 27 | }); 28 | 29 | // Install `devtron` 30 | require('devtron').install(); 31 | }); 32 | 33 | // Require `main` process to boot app 34 | require('./index'); 35 | -------------------------------------------------------------------------------- /client/src/renderer/components/sidebar/contact/parts/contact-list.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Percy 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 | -------------------------------------------------------------------------------- /client/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Percy 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 | -------------------------------------------------------------------------------- /server/models/message.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const MessageSchema = mongoose.Schema({ 3 | uuid: { 4 | type: String, 5 | required: [true, 'message-private uuid is required'] 6 | }, 7 | from: { 8 | type: String, 9 | required: [true, 'message-private from is required'] 10 | }, 11 | to: { 12 | type: String, 13 | required: [true, 'message-private to is required'] 14 | }, 15 | type: { 16 | type: String, 17 | required: [true, 'message-private type is required'] 18 | }, 19 | hasRead: { 20 | type: Number, 21 | required: [true, 'message-hasRead type is required'] 22 | }, 23 | time: mongoose.Schema.Types.Mixed, 24 | content: mongoose.Schema.Types.Mixed 25 | }); 26 | 27 | MessageSchema.statics = { 28 | async saveMessage(message) { 29 | let newMessage = new this(message); 30 | 31 | await newMessage.save(); 32 | }, 33 | 34 | async updateMessageStatus(uuid) { 35 | await this.where({ 36 | uuid 37 | }) 38 | .update({ 39 | hasRead: 1 40 | }) 41 | .exec(); 42 | } 43 | }; 44 | 45 | module.exports = mongoose.model('Message', MessageSchema); 46 | -------------------------------------------------------------------------------- /client/src/renderer/vue-extend/modules/md5.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import fs from 'fs'; 3 | 4 | export function md5Str(data) { 5 | return crypto 6 | .createHash('md5') 7 | .update(data) 8 | .digest('hex') 9 | .toUpperCase(); 10 | } 11 | 12 | export function md5File(filePath) { 13 | return new Promise((reslove) => { 14 | const md5sum = crypto.createHash('md5'); 15 | const stream = fs.createReadStream(filePath); 16 | 17 | stream.on('data', function(chunk) { 18 | md5sum.update(chunk); 19 | }); 20 | 21 | stream.on('end', function() { 22 | reslove(md5sum.digest('hex')); 23 | }); 24 | }); 25 | } 26 | 27 | // export function md5AsarFile(filePath) { 28 | // return new Promise((reslove) => { 29 | // const md5sum = crypto.createHash('md5'); 30 | // const stream = ofs.createReadStream(filePath); 31 | 32 | // stream.on('data', function(chunk) { 33 | // md5sum.update(chunk); 34 | // }); 35 | 36 | // stream.on('end', function() { 37 | // reslove(md5sum.digest('hex')); 38 | // }); 39 | // }); 40 | // } 41 | 42 | export default { 43 | md5Str, 44 | md5File 45 | }; 46 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "beautify.language": { 3 | "js": { 4 | "type": ["javascript", "json"], 5 | "filename": [".jshintrc", ".jsbeautify"] 6 | }, 7 | "css": ["css", "scss"], 8 | "html": [] 9 | }, 10 | "beautify.config": { 11 | "indent_size": 2, 12 | "indent_char": " ", 13 | "end_with_newline": true, 14 | "css": { 15 | "indent_size": 2 16 | }, 17 | "js": { 18 | "break_chained_methods": true 19 | } 20 | }, 21 | "editor.dragAndDrop": true, 22 | "editor.fontSize": 16, 23 | "editor.formatOnSave": true, 24 | "editor.minimap.enabled": true, 25 | "editor.renderControlCharacters": false, 26 | "editor.renderLineHighlight": "none", 27 | "editor.renderWhitespace": "none", 28 | "editor.tabSize": 2, 29 | "editor.wordWrap": "on", 30 | "files.eol": "\n", 31 | "html.format.wrapAttributes": "force-aligned", 32 | "prettier.tabWidth": 2, 33 | "vetur.format.defaultFormatterOptions": { 34 | "prettyhtml": { 35 | "wrapAttributes": true 36 | }, 37 | "prettier": { 38 | "semi": true, 39 | "singleQuote": true 40 | } 41 | }, 42 | "eslint.validate": ["javascript", "javascriptreact"] 43 | } 44 | -------------------------------------------------------------------------------- /server/models/offline-message.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const OfflineMessageSchema = mongoose.Schema({ 3 | uuid: { 4 | type: String, 5 | required: [true, 'message-private uuid is required'] 6 | }, 7 | from: { 8 | type: String, 9 | required: [true, 'message-private from is required'] 10 | }, 11 | to: { 12 | type: String, 13 | required: [true, 'message-private to is required'] 14 | }, 15 | type: { 16 | type: String, 17 | required: [true, 'message-private type is required'] 18 | }, 19 | hasRead: { 20 | type: Number, 21 | required: [true, 'message-hasRead type is required'] 22 | }, 23 | time: mongoose.Schema.Types.Mixed, 24 | content: mongoose.Schema.Types.Mixed 25 | }); 26 | 27 | OfflineMessageSchema.statics = { 28 | async saveMessage(message) { 29 | let newMessage = new this(message); 30 | 31 | await newMessage.save(); 32 | }, 33 | 34 | async getMessagesByUserId(uid) { 35 | const messages = await this.where({ 36 | to: uid 37 | }).exec(); 38 | 39 | await this.deleteMany({ 40 | to: uid 41 | }).exec(); 42 | 43 | return messages; 44 | } 45 | }; 46 | 47 | module.exports = mongoose.model('OfflineMessage', OfflineMessageSchema); 48 | -------------------------------------------------------------------------------- /client/src/renderer/styles/style.styl: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | ::-webkit-scrollbar { 8 | display: none; 9 | } 10 | 11 | ::selection { 12 | background:#5BBFEB; 13 | color:#032764; 14 | } 15 | 16 | html { 17 | user-select: none; 18 | -webkit-user-drag: none; 19 | cursor: default; 20 | } 21 | 22 | body,pre,input,textarea { 23 | font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, 'PingFang SC', 'Hiragino Sans GB', STHeiti, 'Microsoft YaHei', 'Microsoft JhengHei', 'Source Han Sans SC', 'Noto Sans CJK SC', 'Source Han Sans CN', 'Noto Sans SC', 'Source Han Sans TC', 'Noto Sans CJK TC', 'WenQuanYi Micro Hei', SimSun, sans-serif; 24 | line-height: 1.7; 25 | } 26 | 27 | // disable the ability of user drag img、text in input field 28 | input, textarea, select, button, img { 29 | -webkit-user-drag: none; 30 | } 31 | 32 | // remove the arrows from input[type=“number”] 33 | input[type='number']::-webkit-inner-spin-button, input[type='number']::-webkit-outer-spin-button { 34 | -webkit-appearance: none; 35 | margin: 0; 36 | } 37 | 38 | /* 39 | define logo font 40 | */ 41 | @font-face { 42 | font-family: 'local-Flavors'; 43 | src: url('./fonts/custom-fonts/ChenJiShi_xing_shu.ttf'); 44 | } 45 | 46 | // import fonts 47 | @import './fonts/style.css'; -------------------------------------------------------------------------------- /client/.electron-vue/dev-client.js: -------------------------------------------------------------------------------- 1 | const hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true') 2 | 3 | hotClient.subscribe(event => { 4 | /** 5 | * Reload browser when HTMLWebpackPlugin emits a new index.html 6 | * 7 | * Currently disabled until jantimon/html-webpack-plugin#680 is resolved. 8 | * https://github.com/SimulatedGREG/electron-vue/issues/437 9 | * https://github.com/jantimon/html-webpack-plugin/issues/680 10 | */ 11 | // if (event.action === 'reload') { 12 | // window.location.reload() 13 | // } 14 | 15 | /** 16 | * Notify `mainWindow` when `main` process is compiling, 17 | * giving notice for an expected reload of the `electron` process 18 | */ 19 | if (event.action === 'compiling') { 20 | document.body.innerHTML += ` 21 | 34 | 35 |
36 | Compiling Main Process... 37 |
38 | ` 39 | } 40 | }) 41 | -------------------------------------------------------------------------------- /server/utils/decrypt.js: -------------------------------------------------------------------------------- 1 | const aes = require('./aes'); 2 | 3 | // 解密字符串 4 | function str(str) { 5 | return aes.decrypt(str); 6 | } 7 | 8 | // 按指定key,解密对象 9 | function obj(obj, encryptKey = []) { 10 | const tempObj = Object.assign({}, obj); 11 | 12 | Object.keys(tempObj).forEach((key) => { 13 | if (encryptKey.includes(key)) { 14 | tempObj[key] = tempObj[key] && aes.decrypt(tempObj[key]); 15 | } 16 | }); 17 | 18 | return tempObj; 19 | } 20 | 21 | // 按指定key,解密对象数组 [{}, {}] 22 | function arr(arr, encryptKey = []) { 23 | const tempArr = []; 24 | 25 | if (!Array.isArray(arr)) { 26 | return tempArr; 27 | } 28 | 29 | arr.forEach((item) => { 30 | let el = Object.assign({}, item.map); 31 | 32 | Object.keys(el).forEach((key) => { 33 | if (encryptKey.includes(key)) { 34 | el[key] = aes.decrypt(el[key]); 35 | } 36 | }); 37 | 38 | tempArr.push(el); 39 | }); 40 | 41 | return tempArr; 42 | } 43 | 44 | // 解密消息 45 | function message(obj) { 46 | const tempObj = Object.assign({}, obj); 47 | const encryptKey = ['from', 'to', 'content']; 48 | 49 | encryptKey.forEach((key) => { 50 | if (key !== 'content') { 51 | tempObj[key] = aes.decrypt(tempObj[key]); 52 | } else { 53 | tempObj[key] = JSON.parse(aes.decrypt(tempObj[key])); 54 | } 55 | }); 56 | 57 | return tempObj; 58 | } 59 | 60 | module.exports = { 61 | str, 62 | obj, 63 | arr, 64 | message 65 | }; 66 | -------------------------------------------------------------------------------- /server/utils/encrypt.js: -------------------------------------------------------------------------------- 1 | const aes = require('./aes'); 2 | 3 | // 加密字符串 4 | function str(str) { 5 | return aes.encrypt(str); 6 | } 7 | 8 | // 按指定key,加密对象 9 | function obj(obj, encryptKey = []) { 10 | const tempObj = Object.assign({}, obj); 11 | 12 | Object.keys(tempObj).forEach((key) => { 13 | if (encryptKey.includes(key)) { 14 | tempObj[key] = tempObj[key] && aes.encrypt(tempObj[key]); 15 | } 16 | }); 17 | 18 | return tempObj; 19 | } 20 | 21 | // 按指定key,加密对象数组 [{}, {}] 22 | function arr(arr, encryptKey = []) { 23 | const tempArr = []; 24 | 25 | if (!Array.isArray(arr)) { 26 | return tempArr; 27 | } 28 | 29 | arr.forEach((item) => { 30 | let el = Object.assign({}, item.map); 31 | 32 | Object.keys(el).forEach((key) => { 33 | if (encryptKey.includes(key)) { 34 | el[key] = aes.encrypt(el[key]); 35 | } 36 | }); 37 | 38 | tempArr.push(el); 39 | }); 40 | 41 | return tempArr; 42 | } 43 | 44 | // 加密消息 45 | function message(obj) { 46 | const tempObj = Object.assign({}, obj); 47 | const encryptKey = ['from', 'to', 'content']; 48 | 49 | encryptKey.forEach((key) => { 50 | if (key !== 'content') { 51 | tempObj[key] = aes.encrypt(tempObj[key]); 52 | } else { 53 | tempObj[key] = aes.encrypt(JSON.stringify(tempObj[key])); 54 | } 55 | }); 56 | 57 | return tempObj; 58 | } 59 | 60 | module.exports = { 61 | str, 62 | obj, 63 | arr, 64 | message 65 | }; 66 | -------------------------------------------------------------------------------- /client/src/renderer/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Router from 'vue-router'; 3 | 4 | import Login from '../components/login.vue'; 5 | import Main from '../components/main.vue'; 6 | 7 | import MenuChat from '../components/sidebar/chat'; 8 | import MenuContact from '../components/sidebar/contact'; 9 | 10 | import ContentChatBox from '../components/content/chat-box'; 11 | import ContentContactInfo from '../components/content/contact-info'; 12 | 13 | import VideoChat from '../components/content/chat-box/special-pages/video-chat'; 14 | 15 | Vue.use(Router); 16 | 17 | export default new Router({ 18 | routes: [ 19 | { 20 | path: '*', 21 | redirect: '/login' 22 | }, 23 | { 24 | path: '/login', 25 | name: 'login', 26 | component: Login 27 | }, 28 | { 29 | path: '/app', 30 | name: 'app', 31 | component: Main, 32 | children: [ 33 | { 34 | path: 'chat', 35 | name: 'chat', 36 | components: { 37 | menus: MenuChat, 38 | contents: ContentChatBox 39 | } 40 | }, 41 | { 42 | path: 'contact', 43 | name: 'contact', 44 | components: { 45 | menus: MenuContact, 46 | contents: ContentContactInfo 47 | } 48 | } 49 | ] 50 | }, 51 | { 52 | path: '/video-chat', 53 | name: 'video-chat', 54 | component: VideoChat 55 | } 56 | ] 57 | }); 58 | -------------------------------------------------------------------------------- /client/src/renderer/components/@parts/win-menu.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 36 | 37 | 66 | -------------------------------------------------------------------------------- /client/src/renderer/vue-extend/modules/decrypt.js: -------------------------------------------------------------------------------- 1 | import aes from './aes'; 2 | 3 | // 加密字符串 4 | function decryptStr(str) { 5 | return aes.decrypt(str); 6 | } 7 | 8 | // 按指定key,解密对象 9 | function decryptObj(obj, encryptKey = []) { 10 | const tempObj = Object.assign({}, obj); 11 | 12 | Object.keys(tempObj).forEach((key) => { 13 | if (encryptKey.includes(key)) { 14 | tempObj[key] = tempObj[key] && aes.decrypt(tempObj[key]); 15 | } 16 | }); 17 | 18 | return tempObj; 19 | } 20 | 21 | // 按指定key,解密对象数组 [{}, {}] 22 | function decryptArr(arr, encryptKey = []) { 23 | const tempArr = []; 24 | 25 | if (!Array.isArray(arr)) { 26 | return tempArr; 27 | } 28 | 29 | arr.forEach((item) => { 30 | let el = Object.assign({}, item.map); 31 | 32 | Object.keys(el).forEach((key) => { 33 | if (encryptKey.includes(key)) { 34 | el[key] = aes.decrypt(el[key]); 35 | } 36 | }); 37 | 38 | tempArr.push(el); 39 | }); 40 | 41 | return tempArr; 42 | } 43 | 44 | // 解密消息 45 | function decryptMessage(obj) { 46 | const tempObj = Object.assign({}, obj); 47 | const encryptKey = ['from', 'to', 'content']; 48 | 49 | encryptKey.forEach((key) => { 50 | if (key !== 'content') { 51 | tempObj[key] = aes.decrypt(tempObj[key]); 52 | } else { 53 | tempObj[key] = JSON.parse(aes.decrypt(tempObj[key])); 54 | } 55 | }); 56 | 57 | return tempObj; 58 | } 59 | 60 | export default { 61 | decryptStr, 62 | decryptObj, 63 | decryptArr, 64 | decryptMessage 65 | }; 66 | -------------------------------------------------------------------------------- /client/src/renderer/vue-extend/modules/encrypt.js: -------------------------------------------------------------------------------- 1 | import aes from './aes'; 2 | 3 | // 加密字符串 4 | function encryptStr(str) { 5 | return aes.encrypt(str); 6 | } 7 | 8 | // 按指定key,加密对象 9 | function encryptObj(obj, encryptKey = []) { 10 | const tempObj = Object.assign({}, obj); 11 | 12 | Object.keys(tempObj).forEach((key) => { 13 | if (encryptKey.includes(key)) { 14 | tempObj[key] = tempObj[key] && aes.encrypt(tempObj[key]); 15 | } 16 | }); 17 | 18 | return tempObj; 19 | } 20 | 21 | // 按指定key,加密对象数组 [{}, {}] 22 | function encryptArr(arr, encryptKey = []) { 23 | const tempArr = []; 24 | 25 | if (!Array.isArray(arr)) { 26 | return tempArr; 27 | } 28 | 29 | arr.forEach((item) => { 30 | let el = Object.assign({}, item.map); 31 | 32 | Object.keys(el).forEach((key) => { 33 | if (encryptKey.includes(key)) { 34 | el[key] = aes.encrypt(el[key]); 35 | } 36 | }); 37 | 38 | tempArr.push(el); 39 | }); 40 | 41 | return tempArr; 42 | } 43 | 44 | // 加密消息 45 | function encryptMessage(obj) { 46 | const tempObj = Object.assign({}, obj); 47 | const encryptKey = ['from', 'to', 'content']; 48 | 49 | encryptKey.forEach((key) => { 50 | if (key !== 'content') { 51 | tempObj[key] = aes.encrypt(tempObj[key]); 52 | } else { 53 | tempObj[key] = aes.encrypt(JSON.stringify(tempObj[key])); 54 | } 55 | }); 56 | 57 | return tempObj; 58 | } 59 | 60 | export default { 61 | encryptStr, 62 | encryptObj, 63 | encryptArr, 64 | encryptMessage 65 | }; 66 | -------------------------------------------------------------------------------- /client/src/renderer/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /server/models/user.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const UserSchema = mongoose.Schema( 3 | { 4 | uid: { 5 | type: String, 6 | required: [true, 'uid is required'] 7 | }, 8 | avatar: { 9 | type: String, 10 | maxlength: 500, 11 | default: 'http://localhost:3000/upload/default/default-user-avatar.png' 12 | }, // 头像 13 | name: String, // 姓名 14 | pinyin: String, // 拼音 15 | courtesyName: String, // 表字 16 | devoteFor: String, // 主要效力于 17 | birthAndDeath: String, // 生卒 18 | type: String, // 正史/虚构 19 | rank: String, // 排名 20 | gender: String, // 性别 21 | nativePlace: String, // 籍贯 22 | otherInfo: String, // 其他信息 23 | isOnline: Boolean // 是否在线 24 | }, 25 | { 26 | // auto generate `createdAt` and `updatedAt` field 27 | timestamps: true 28 | } 29 | ); 30 | 31 | UserSchema.statics = { 32 | async registerUser(phone) { 33 | // this equal to UserModel 34 | let user = await this.where('phone') 35 | .equals(phone) 36 | .exec(); 37 | 38 | user = user[0]; 39 | 40 | if (!user) { 41 | let len = await this.count().exec(); 42 | let info = { 43 | uid: `u${10000 + len}`, 44 | phone: `${phone}` 45 | }; 46 | 47 | user = new this(info); 48 | 49 | await user.save(); 50 | } 51 | 52 | return user.uid; 53 | }, 54 | async getUserInfo(uid) { 55 | let userInfo = await this.where({ 56 | uid 57 | }) 58 | .lean() 59 | .exec(); 60 | 61 | userInfo = userInfo[0]; 62 | 63 | return userInfo || {}; 64 | } 65 | }; 66 | 67 | module.exports = mongoose.model('User', UserSchema); 68 | -------------------------------------------------------------------------------- /client/src/renderer/components/sidebar/contact/parts/search-result.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /client/src/renderer/services/contextmenu.js: -------------------------------------------------------------------------------- 1 | import electron from 'electron'; 2 | 3 | const remote = electron.remote; 4 | const Menu = remote.Menu; 5 | 6 | const templateForEditArea = [ 7 | { 8 | label: '撤消', 9 | role: 'undo' 10 | }, 11 | { 12 | label: '恢复', 13 | role: 'redo' 14 | }, 15 | { 16 | type: 'separator' 17 | }, 18 | { 19 | label: '剪切', 20 | role: 'cut' 21 | }, 22 | { 23 | label: '复制', 24 | role: 'copy' 25 | }, 26 | { 27 | label: '粘贴', 28 | role: 'paste' 29 | }, 30 | { 31 | type: 'separator' 32 | }, 33 | { 34 | label: '全选', 35 | role: 'selectall' 36 | } 37 | ]; 38 | const templateForCopyArea = [ 39 | { 40 | label: '复制', 41 | role: 'copy' 42 | } 43 | ]; 44 | const InputMenu = Menu.buildFromTemplate(templateForEditArea); 45 | const CopyMenu = Menu.buildFromTemplate(templateForCopyArea); 46 | 47 | function addListener() { 48 | document.body.addEventListener('contextmenu', (e) => { 49 | e.preventDefault(); 50 | e.stopPropagation(); 51 | 52 | let node = e.target; 53 | 54 | while (node) { 55 | // 节点是否可编辑 56 | const isNodeEditable = 57 | node.nodeName.match(/^(input|textarea)$/i) || node.isContentEditable; 58 | // 是否有选中的文本 59 | const hasSelectedText = window.getSelection().toString(); 60 | 61 | if (isNodeEditable || hasSelectedText) { 62 | (hasSelectedText && !isNodeEditable ? CopyMenu : InputMenu).popup(); 63 | 64 | break; 65 | } 66 | 67 | node = node.parentNode; 68 | } 69 | }); 70 | } 71 | 72 | function listenContextMenu() { 73 | if (document.body) { 74 | addListener(); 75 | } else { 76 | document.addEventListener('DOMContentLoaded', () => { 77 | addListener(); 78 | }); 79 | } 80 | } 81 | 82 | listenContextMenu(); 83 | -------------------------------------------------------------------------------- /electron.md: -------------------------------------------------------------------------------- 1 | ### 通过元素拖动窗口 2 | 3 | ```css 4 | .menu-bar { 5 | -webkit-app-region: drag; 6 | } 7 | ``` 8 | 9 | > Windows系统在元素设置了上面的属性后,子元素会无法被点击(click) 10 | > 解决方案如下: 11 | 12 | ```css 13 | .menu-item { 14 | -webkit-app-region: no-drag; 15 | } 16 | ``` 17 | 18 | ### 自定义标题栏 19 | 20 | 使用自定义标题栏,因为原生的标题栏在不同系统上的样式太丑、不统一。 21 | 22 | 解决方案:创建窗口时,指定 `frame: false` 属性,创建无边框窗口。然后使用css自定义标题栏,并且设置css属性 `-webkit-app-region: drag;`,使标题栏可被拖动。 23 | 24 | ### Windows通知栏不起作用 25 | 26 | https://electronjs.org/docs/tutorial/notifications#%E9%80%9A%E7%9F%A5-windows-linux-macos 27 | 28 | 29 | ### 资源 30 | 31 | https://www.emojione.com/ 32 | 33 | ### electron 加密源码方案 34 | 35 | https://github.com/electron/electron/issues/3041 36 | 37 | 1. 使用混淆工具混淆 js 代码 38 | 2. 将核心模块封装为 node 模块,用C++写 39 | 3. 更改electron源码(更改打包机制),使electron在打包或提取 `app.asar` 文件时进行加密解密 40 | 41 | ```bash 42 | # https://www.v2ex.com/t/493344 43 | # 加密方法 44 | 在 asar 打包时写入文件之前, 通过加密算法把写入的文件进行加密 45 | 46 | # 解密方法 47 | 修改 asar::Archive 类增加 C++解密方式, 供 Browser 和 atom 加载 asar 资源 48 | 修改 asar.js 增加 js 解密方式, 供 nodejs 加载 asar 资源 49 | ``` 50 | 51 | * 图片加密隐写 52 | 53 | https://www.v2ex.com/t/278480 54 | https://github.com/zeruniverse/CryptoStego 55 | 56 | 57 | ### 软件更新 58 | 59 | [Electron应用自动更新实现思路](https://toyobayashi.github.io/2018/11/07/ElectronAsarUpdater/) 60 | 61 | 1. 因为软件基于Electron开发,所以我们在进行软件更新时,无需更新整个软件包,只需要将 `软件目录/resources/app.asar` 文件替换成最新软件包的 app.asar 文件即可。 62 | 63 | 文章参考:https://toyobayashi.github.io/2018/11/07/ElectronAsarUpdater/ 64 | 具体逻辑:参看 `client/src/renderer/app.vue` 65 | 目前机制:软件每次打开的时候,都会从服务器下载 `服务器目录/release/app_update.asar` 文件至 `软件目录/resources` 目录,然后计算 `app.asar` 与 `app_update.asar` 文件的 md5 值,若不相同,弹窗提示更新软件。若用户点击确认更新,则执行 Windows系统的 `copy` 命令,将 app.asar 替换为最新的版本,然后重启软件。 66 | 67 | 68 | [正确设置 ELECTRON_MIRROR ,加速下载 electron 预编译文件](https://newsn.net/say/electron-download-mirror.html) 69 | -------------------------------------------------------------------------------- /client/src/renderer/vue-extend/index.js: -------------------------------------------------------------------------------- 1 | import electron from 'electron'; 2 | import SocketClient from 'socket.io-client'; 3 | import Store from 'electron-store'; 4 | import modules from './modules'; 5 | import { 6 | SOCKET_SERVER, 7 | SOCKET_PATH, 8 | LOCAL_DB_NAME, 9 | LOCAL_DB_ENCRYPT_KEY 10 | } from '../config'; 11 | 12 | function install(Vue) { 13 | // do not show the tips of the develop mode 14 | Vue.config.productionTip = false; 15 | 16 | /* 17 | * @desc: bind some useful methods to the prototype of Vue 18 | * @use: this.$[method] 19 | */ 20 | Object.keys(modules).forEach((key) => { 21 | Vue.prototype[`$${key}`] = modules[key]; 22 | }); 23 | 24 | /* 25 | * @desc: judge if the platform is windows OS 26 | * @use: this.$isWindowsOS 27 | */ 28 | Vue.prototype.$isWindowsOS = process.platform === 'win32'; 29 | 30 | /* 31 | * @desc: use electron 32 | * @use: this.$electron 33 | */ 34 | Vue.prototype.$electron = electron; 35 | 36 | /* 37 | * @desc: use remote 38 | * @use: this.$remote 39 | */ 40 | Vue.prototype.$remote = electron.remote; 41 | 42 | /* 43 | * @desc: control current window 44 | * @use: this.$electron 45 | */ 46 | Vue.prototype.$win = electron.remote.getCurrentWindow(); 47 | 48 | /* 49 | * @desc: socket.io for communicate 50 | * @use: this.$socket 51 | */ 52 | Vue.prototype.$socket = SocketClient(SOCKET_SERVER, { 53 | path: SOCKET_PATH, 54 | reconnection: true, 55 | transports: ['polling', 'websocket'] 56 | }); 57 | 58 | /* 59 | * @desc: to store some config to local file 60 | * @use: this.$electronStore 61 | */ 62 | Vue.prototype.$electronStore = new Store({ 63 | name: LOCAL_DB_NAME, 64 | fileExtension: 'json', 65 | encryptionKey: LOCAL_DB_ENCRYPT_KEY 66 | }); 67 | } 68 | 69 | export default install; 70 | -------------------------------------------------------------------------------- /server/utils/aes.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const config = require('../config'); 3 | 4 | class Crypto { 5 | /** 6 | * 加解密必须使用同一套 key 和 iv 7 | * @param {String} algorithm 算法名称,比如 `aes-128-ecb` 8 | * @param {String} key 秘钥 9 | * @param {String} iv initialization vector,默认空字符串 10 | */ 11 | constructor(algorithm, key, iv = '') { 12 | this.algorithm = algorithm; 13 | this.key = key; 14 | this.iv = iv; 15 | } 16 | 17 | /** 18 | * 加密算法 19 | * 20 | * @param {String} message 明文 21 | * @param {String} messageEncoding 明文编码 22 | * @param {String} cipherEncoding 密文编码 23 | * 24 | * @return {String} encrypted 密文 25 | */ 26 | encrypt(message, messageEncoding = 'utf8', cipherEncoding = 'base64') { 27 | const cipher = crypto.createCipheriv(this.algorithm, this.key, this.iv); 28 | cipher.setAutoPadding(true); 29 | 30 | let encrypted = cipher.update(message, messageEncoding, cipherEncoding); 31 | encrypted += cipher.final(cipherEncoding); 32 | 33 | return encrypted; 34 | } 35 | 36 | /** 37 | * 解密算法 38 | * 39 | * @param {String} encrypted 密文 40 | * @param {String} cipherEncoding 密文编码 41 | * @param {String} messageEncoding 明文编码 42 | * 43 | * @return {String} decrypted 明文 44 | */ 45 | decrypt(encrypted, cipherEncoding = 'base64', messageEncoding = 'utf8') { 46 | const decipher = crypto.createDecipheriv(this.algorithm, this.key, this.iv); 47 | decipher.setAutoPadding(true); 48 | 49 | let decrypted = decipher.update(encrypted, cipherEncoding, messageEncoding); 50 | decrypted += decipher.final(messageEncoding); 51 | 52 | return decrypted; 53 | } 54 | } 55 | 56 | module.exports = new Crypto( 57 | config.COMMUNICATION_ENCRYPT_METHOD, 58 | config.COMMUNICATION_ENCRYPT_KEY 59 | ); 60 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/chat-box/parts/message-item.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 60 | 61 | 66 | -------------------------------------------------------------------------------- /server/initData/index.js: -------------------------------------------------------------------------------- 1 | const UserModel = require('../models/user'); 2 | const CategoryModel = require('../models/category'); 3 | 4 | const dataUsers = require('./data/users'); 5 | const { generateUUID } = require('../utils/utils'); 6 | 7 | init(); 8 | 9 | async function init() { 10 | initUser(); 11 | initCategory(); 12 | } 13 | 14 | // 初始化用户数据 15 | async function initUser() { 16 | const userArr = await UserModel.find({}).exec(); 17 | 18 | if (userArr.length > 0) { 19 | return; 20 | } 21 | 22 | await UserModel.create(dataUsers); 23 | } 24 | 25 | // 初始化联系人列表 26 | async function initCategory() { 27 | const categoryArr = await CategoryModel.find({}).exec(); 28 | const categoryMap = {}; 29 | 30 | if (categoryArr.length > 0) { 31 | return; 32 | } 33 | 34 | dataUsers.forEach(el => { 35 | const data = { 36 | uid: el.uid, 37 | name: el.name, 38 | avatar: el.avatar, 39 | courtesyName: el.courtesyName 40 | }; 41 | 42 | if (categoryMap[el.devoteFor]) { 43 | if (categoryMap[el.devoteFor][el.gender]) { 44 | categoryMap[el.devoteFor][el.gender].push(data); 45 | } else { 46 | categoryMap[el.devoteFor][el.gender] = [data]; 47 | } 48 | } else { 49 | categoryMap[el.devoteFor] = {}; 50 | categoryMap[el.devoteFor][el.gender] = [data]; 51 | } 52 | }); 53 | 54 | Object.keys(categoryMap).forEach((devoteFor, index) => { 55 | categoryArr[index] = { 56 | gid: generateUUID(), 57 | title: devoteFor, 58 | children: [] 59 | }; 60 | 61 | Object.keys(categoryMap[devoteFor]).forEach(gender => { 62 | categoryArr[index].children.push({ 63 | gid: generateUUID(), 64 | title: gender, 65 | members: categoryMap[devoteFor][gender] || [] 66 | }); 67 | }); 68 | }); 69 | 70 | await CategoryModel.create(categoryArr); 71 | } 72 | -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | const OFF = 0; 2 | const WARN = 1; 3 | const ERROR = 2; 4 | 5 | module.exports = { 6 | env: { 7 | es6: true, 8 | browser: true, 9 | node: true 10 | }, 11 | 12 | parserOptions: { 13 | ecmaVersion: 2018, 14 | sourceType: 'module' 15 | }, 16 | 17 | plugins: ['html'], 18 | 19 | extends: 'eslint:recommended', 20 | 21 | rules: { 22 | 'no-console': OFF, 23 | 'no-debugger': OFF, 24 | 'no-extra-parens': ERROR, 25 | 'consistent-return': ERROR, 26 | curly: ERROR, 27 | 'default-case': ERROR, 28 | eqeqeq: ERROR, 29 | 'no-caller': ERROR, 30 | 'no-else-return': ERROR, 31 | 'no-empty-function': ERROR, 32 | 'no-eq-null': ERROR, 33 | 'no-eval': ERROR, 34 | 'no-extra-bind': ERROR, 35 | 'no-extra-label': ERROR, 36 | 'no-floating-decimal': ERROR, 37 | 'no-labels': ERROR, 38 | 'no-lone-blocks': ERROR, 39 | 40 | 'no-multi-spaces': ERROR, 41 | 'no-new-func': ERROR, 42 | 'no-new-wrappers': ERROR, 43 | 'no-octal-escape': ERROR, 44 | 'no-param-reassign': ERROR, 45 | 'no-proto': ERROR, 46 | 'no-return-assign': ERROR, 47 | 'no-return-await': ERROR, 48 | 'no-self-compare': ERROR, 49 | 'no-sequences': ERROR, 50 | 'no-throw-literal': ERROR, 51 | 'no-unmodified-loop-condition': ERROR, 52 | 'no-unused-expressions': ERROR, 53 | 'no-with': ERROR, 54 | 55 | 'prefer-promise-reject-errors': WARN, 56 | 57 | radix: ERROR, 58 | 59 | 'require-await': ERROR, 60 | 61 | 'wrap-iife': ERROR, 62 | 'no-undefined': ERROR, 63 | 64 | 'new-cap': OFF, 65 | 'new-parens': ERROR, 66 | 'no-array-constructor': ERROR, 67 | 'no-new-object': ERROR, 68 | 'no-multi-assign': ERROR, 69 | 70 | semi: [ERROR, 'always'], 71 | 72 | 'no-duplicate-imports': ERROR, 73 | 'no-useless-rename': ERROR, 74 | 'no-var': ERROR 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /client/src/renderer/vue-extend/modules/aes.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import { 3 | COMMUNICATION_ENCRYPT_METHOD, 4 | COMMUNICATION_ENCRYPT_KEY 5 | } from '../../config'; 6 | 7 | class Crypto { 8 | /** 9 | * 加解密必须使用同一套 key 和 iv 10 | * @param {String} algorithm 算法名称,比如 `aes-128-ecb` 11 | * @param {String} key 秘钥 12 | * @param {String} iv initialization vector,默认空字符串 13 | */ 14 | constructor(algorithm, key, iv = '') { 15 | this.algorithm = algorithm; 16 | this.key = key; 17 | this.iv = iv; 18 | } 19 | 20 | /** 21 | * 加密算法 22 | * 23 | * @param {String} message 明文 24 | * @param {String} messageEncoding 明文编码 25 | * @param {String} cipherEncoding 密文编码 26 | * 27 | * @return {String} encrypted 密文 28 | */ 29 | encrypt(message, messageEncoding = 'utf8', cipherEncoding = 'base64') { 30 | const cipher = crypto.createCipheriv(this.algorithm, this.key, this.iv); 31 | cipher.setAutoPadding(true); 32 | 33 | let encrypted = cipher.update(message, messageEncoding, cipherEncoding); 34 | encrypted += cipher.final(cipherEncoding); 35 | 36 | return encrypted; 37 | } 38 | 39 | /** 40 | * 解密算法 41 | * 42 | * @param {String} encrypted 密文 43 | * @param {String} cipherEncoding 密文编码 44 | * @param {String} messageEncoding 明文编码 45 | * 46 | * @return {String} decrypted 明文 47 | */ 48 | decrypt(encrypted, cipherEncoding = 'base64', messageEncoding = 'utf8') { 49 | const decipher = crypto.createDecipheriv(this.algorithm, this.key, this.iv); 50 | decipher.setAutoPadding(true); 51 | 52 | let decrypted = decipher.update(encrypted, cipherEncoding, messageEncoding); 53 | decrypted += decipher.final(messageEncoding); 54 | 55 | return decrypted; 56 | } 57 | } 58 | 59 | export default new Crypto( 60 | COMMUNICATION_ENCRYPT_METHOD, 61 | COMMUNICATION_ENCRYPT_KEY 62 | ); 63 | -------------------------------------------------------------------------------- /client/src/renderer/services/shortcut.js: -------------------------------------------------------------------------------- 1 | import electron from 'electron'; 2 | 3 | const { remote } = electron; 4 | 5 | const KEY_A = 0x41; // (65) "A" key. 6 | const KEY_C = 0x43; // (67) "C" key. 7 | const KEY_V = 0x56; // (86) "V" key. 8 | const KEY_X = 0x58; // (88) "X" key. 9 | const KEY_Z = 0x5a; // (90) "Z" key. 10 | 11 | function action(name, evt) { 12 | const contents = remote.getCurrentWebContents(); 13 | 14 | contents[name](); 15 | evt.preventDefault(); 16 | 17 | return false; 18 | } 19 | 20 | function handleInputShortcuts(evt) { 21 | const node = evt.target; 22 | const c = evt.keyCode; 23 | const ctrlDown = evt.ctrlKey || evt.metaKey; // OSX support 24 | const altDown = evt.altKey; 25 | const shiftDown = evt.shiftKey; 26 | 27 | if (altDown) { 28 | return true; 29 | } 30 | 31 | if (!(node.nodeName.match(/^(input|textarea)$/i) || node.isContentEditable)) { 32 | return true; 33 | } 34 | 35 | if (ctrlDown && !shiftDown && c === KEY_C) { 36 | return action('copy', evt); 37 | } 38 | 39 | if (ctrlDown && !shiftDown && c === KEY_V) { 40 | return action('paste', evt); 41 | } 42 | 43 | if (ctrlDown && !shiftDown && c === KEY_X) { 44 | return action('cut', evt); 45 | } 46 | 47 | if (ctrlDown && !shiftDown && c === KEY_A) { 48 | return action('selectAll', evt); 49 | } 50 | 51 | if (ctrlDown && !shiftDown && c === KEY_Z) { 52 | return action('undo', evt); 53 | } 54 | 55 | if (ctrlDown && shiftDown && c === KEY_Z) { 56 | return action('redo', evt); 57 | } 58 | 59 | return true; 60 | } 61 | 62 | function addListener() { 63 | document.body.addEventListener('keydown', handleInputShortcuts); 64 | } 65 | 66 | function registerShortcuts() { 67 | if (document.body) { 68 | addListener(); 69 | } else { 70 | document.addEventListener('DOMContentLoaded', () => { 71 | addListener(); 72 | }); 73 | } 74 | } 75 | 76 | registerShortcuts(); 77 | -------------------------------------------------------------------------------- /client/.electron-vue/webpack.main.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | process.env.BABEL_ENV = 'main'; 4 | 5 | const path = require('path'); 6 | const { dependencies } = require('../package.json'); 7 | const webpack = require('webpack'); 8 | 9 | const BabiliWebpackPlugin = require('babili-webpack-plugin'); 10 | 11 | let mainConfig = { 12 | entry: { 13 | main: path.join(__dirname, '../src/main/index.js') 14 | }, 15 | externals: [...Object.keys(dependencies || {})], 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.(js)$/, 20 | enforce: 'pre', 21 | exclude: /node_modules/, 22 | use: { 23 | loader: 'eslint-loader', 24 | options: { 25 | formatter: require('eslint-friendly-formatter') 26 | } 27 | } 28 | }, 29 | { 30 | test: /\.js$/, 31 | use: 'babel-loader', 32 | exclude: /node_modules/ 33 | }, 34 | { 35 | test: /\.node$/, 36 | use: 'node-loader' 37 | } 38 | ] 39 | }, 40 | node: { 41 | __dirname: process.env.NODE_ENV !== 'production', 42 | __filename: process.env.NODE_ENV !== 'production' 43 | }, 44 | output: { 45 | filename: '[name].js', 46 | libraryTarget: 'commonjs2', 47 | path: path.join(__dirname, '../dist/electron') 48 | }, 49 | plugins: [new webpack.NoEmitOnErrorsPlugin()], 50 | resolve: { 51 | extensions: ['.js', '.json', '.node'] 52 | }, 53 | target: 'electron-main' 54 | }; 55 | 56 | /** 57 | * Adjust mainConfig for development settings 58 | */ 59 | if (process.env.NODE_ENV !== 'production') { 60 | mainConfig.plugins.push( 61 | new webpack.DefinePlugin({ 62 | __static: `"${path.join(__dirname, '../static').replace(/\\/g, '\\\\')}"` 63 | }) 64 | ); 65 | } 66 | 67 | /** 68 | * Adjust mainConfig for production settings 69 | */ 70 | if (process.env.NODE_ENV === 'production') { 71 | mainConfig.plugins.push( 72 | new webpack.DefinePlugin({ 73 | 'process.env.NODE_ENV': '"production"' 74 | }), 75 | new BabiliWebpackPlugin() 76 | ); 77 | } 78 | 79 | module.exports = mainConfig; 80 | -------------------------------------------------------------------------------- /server/controllers/modules/message.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | message: receiveMessage, 3 | 'message-has-read': markMessageAsRead, 4 | 'receive-offline-message': receiveOfflineMessage 5 | }; 6 | 7 | const path = require('path'); 8 | const MessageModel = require('../../models/message'); 9 | const OfflineMessageModel = require('../../models/offline-message'); 10 | 11 | async function receiveMessage(message, callback) { 12 | const userHash = global.$userHash; 13 | const { from, to } = message; 14 | const fromSocketId = userHash[from]; 15 | const toSocketId = userHash[to]; 16 | const socket = global.$sockets[fromSocketId]; 17 | 18 | // set message time only on server 19 | message.time = Date.now(); 20 | message.hasRead = 0; 21 | 22 | // save message to DB[messages] 23 | await MessageModel.saveMessage(message); 24 | 25 | // if user online 26 | if (toSocketId) { 27 | // dispatch message 28 | socket.to(toSocketId).send(message); 29 | 30 | callback({ 31 | code: 0, 32 | data: { 33 | uuid: message.uuid, 34 | time: message.time 35 | }, 36 | message: null 37 | }); 38 | } else { 39 | // save message to DB[offlineMessages] 40 | await OfflineMessageModel.saveMessage(message); 41 | 42 | callback({ 43 | code: 0, 44 | data: { 45 | uuid: message.uuid, 46 | time: message.time 47 | }, 48 | message: null 49 | }); 50 | } 51 | } 52 | 53 | async function receiveOfflineMessage(uid, callback) { 54 | const messages = await OfflineMessageModel.getMessagesByUserId(uid); 55 | 56 | callback({ 57 | code: 0, 58 | data: Array.isArray(messages) ? messages : [], 59 | message: null 60 | }); 61 | } 62 | 63 | async function markMessageAsRead(payload, callback) { 64 | const userHash = global.$userHash; 65 | const { from, to } = payload; 66 | const fromSocketId = userHash[from]; 67 | const toSocketId = userHash[to]; 68 | const socket = global.$sockets[fromSocketId]; 69 | 70 | MessageModel.updateMessageStatus(payload.uuid); 71 | 72 | // dispatch message 73 | socket.to(toSocketId).emit('message-has-read', payload); 74 | } 75 | -------------------------------------------------------------------------------- /client/src/main/config/tray.js: -------------------------------------------------------------------------------- 1 | export const trayIconBase64Code = 2 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACB0lEQVQ4T42Sz2sTURDHv7ObBk00pmBowErXUvQiNOChtakQ0Uu9RND6A0+5iSDEg4K99ah7KBrrNSk2IkVI/AMkvShGKCaH0oBFFy2WVpqkTTQ12d2R96wlBkvyvTzezHw/MwxD2EParbmAAjUMcIAJXgLnbGDemB5/1WyhVr92M6WRasUJCP2PzQyDFY4YT8bnRf4fgOhKpGRIsTagoCIBFlxg9XgrzGaKGE8vJXYBgfiU1/zmiR84UjqrdvGhZkOj5lgpF3oMs+YcbY7blnpMAkaSDwMAUgTS9tqJiG8XXbnSkq8fpHjEn4EZEp1dzkaGQALSVtXVg9nK58NDO4AyBWf1KAhTbZ1NBavZo3lYjkG5xOCsngNBfjpV5Yv3TXWlO8hAnoJJnYXxTO8AJobHMPYyJjmx81fxsbSOxwsZDHT7EDt3Dbdfv8By6Ttqxf35csE/CMYjGknqBgF9ouhC/0lpELpy4pQEfFj/Cr/bI3NzhQVUG7/wY829uLXs67WdDk0A0gSEOx1f1G0s9mTrm66hT9OXiYaf6SFVwZ+2HcgyaWvtfR8I5Nm9g7976MCP7eK+u8Ul/ygRhcE8Ke/A7TRL7cwM3gQQfXvjXkLUatGUF/WGRqefP7iosJKSAMaMfAghsdidWJ4JaVsxE++u3zdaG8lDYuLyz3pXOhe5U243SWv+N5lmxDAYUJBPAAAAAElFTkSuQmCC'; 3 | 4 | export const transparentTrayIconBase64Code = 5 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAA6ZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iPgogICAgICAgICA8eG1wOk1vZGlmeURhdGU+MjAxOS0wMS0wMlQxMTowMTo4NDwveG1wOk1vZGlmeURhdGU+CiAgICAgICAgIDx4bXA6Q3JlYXRvclRvb2w+UGl4ZWxtYXRvciAzLjY8L3htcDpDcmVhdG9yVG9vbD4KICAgICAgICAgPHRpZmY6T3JpZW50YXRpb24+MTwvdGlmZjpPcmllbnRhdGlvbj4KICAgICAgICAgPHRpZmY6Q29tcHJlc3Npb24+NTwvdGlmZjpDb21wcmVzc2lvbj4KICAgICAgICAgPHRpZmY6UmVzb2x1dGlvblVuaXQ+MjwvdGlmZjpSZXNvbHV0aW9uVW5pdD4KICAgICAgICAgPHRpZmY6WVJlc29sdXRpb24+NzI8L3RpZmY6WVJlc29sdXRpb24+CiAgICAgICAgIDx0aWZmOlhSZXNvbHV0aW9uPjcyPC90aWZmOlhSZXNvbHV0aW9uPgogICAgICAgICA8ZXhpZjpQaXhlbFhEaW1lbnNpb24+MTY8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl4ZWxZRGltZW5zaW9uPjE2PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+CsvEYnAAAAAVSURBVDgRY2AYBaMhMBoCoyEACQEABBAAAb5jUyIAAAAASUVORK5CYII='; 6 | -------------------------------------------------------------------------------- /client/src/renderer/components/@parts/loading.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 22 | 23 | 102 | -------------------------------------------------------------------------------- /client/src/renderer/store/modules/Chat.js: -------------------------------------------------------------------------------- 1 | const CHAT_ARR_KEY = 'chatArr'; 2 | 3 | const state = { 4 | currentChat: {}, 5 | chatArr: [], 6 | isShowSidebar: false 7 | }; 8 | 9 | const mutations = { 10 | RESET_STATE(state) { 11 | state.currentChat = {}; 12 | state.chatArr = []; 13 | state.isShowSidebar = false; 14 | }, 15 | 16 | LOAD_RECENT_CHAT(state) { 17 | state.chatArr = JSON.parse(localStorage.getItem(CHAT_ARR_KEY)) || []; 18 | }, 19 | 20 | CURRENT_CHAT(state, obj) { 21 | state.currentChat = obj; 22 | }, 23 | 24 | UPDATE_CHAT_ARR_STATUS(state, unreadMessageObj) { 25 | const unreadUidArr = Object.keys(unreadMessageObj); 26 | 27 | if (unreadUidArr.length === 0) { 28 | return; 29 | } 30 | 31 | state.chatArr.forEach((chat, index) => { 32 | if (unreadUidArr.includes(chat.uid)) { 33 | state.chatArr.splice(index, 1, { 34 | ...chat, 35 | unreadNum: unreadMessageObj[chat.uid] 36 | }); 37 | } 38 | }); 39 | 40 | localStorage.setItem(CHAT_ARR_KEY, JSON.stringify(state.chatArr)); 41 | }, 42 | 43 | ADD_CHAT(state, chatObj) { 44 | const chatArr = JSON.parse(localStorage.getItem(CHAT_ARR_KEY)) || []; 45 | const _uid = chatObj.uid; 46 | 47 | let isUserExist = false; 48 | 49 | chatArr.forEach((el) => { 50 | if (el.uid === _uid) { 51 | isUserExist = true; 52 | } 53 | }); 54 | 55 | if (!isUserExist) { 56 | chatArr.push(chatObj); 57 | } 58 | 59 | state.chatArr = chatArr; 60 | localStorage.setItem(CHAT_ARR_KEY, JSON.stringify(chatArr)); 61 | }, 62 | 63 | DELETE_CHAT(state, uid) { 64 | const chatArr = JSON.parse(localStorage.getItem(CHAT_ARR_KEY)) || []; 65 | 66 | let deleteIndex; 67 | 68 | chatArr.find((el, index) => { 69 | if (el.uid === uid) { 70 | deleteIndex = index; 71 | return true; 72 | } 73 | return false; 74 | }); 75 | 76 | // delete the target chat 77 | if (deleteIndex >= 0) { 78 | chatArr.splice(deleteIndex, 1); 79 | } 80 | 81 | state.chatArr = chatArr; 82 | localStorage.setItem(CHAT_ARR_KEY, JSON.stringify(chatArr)); 83 | }, 84 | 85 | SHOW_CHAT_BOX_SIDEBAR(state) { 86 | state.isShowSidebar = true; 87 | }, 88 | 89 | HIDE_CHAT_BOX_SIDEBAR(state) { 90 | state.isShowSidebar = false; 91 | } 92 | }; 93 | 94 | export default { 95 | namespaced: true, 96 | state, 97 | mutations 98 | }; 99 | -------------------------------------------------------------------------------- /client/src/renderer/store/modules/Message.js: -------------------------------------------------------------------------------- 1 | const state = { 2 | messageObj: {} 3 | }; 4 | 5 | const getters = { 6 | unreadMessageObj(state) { 7 | const obj = {}; 8 | 9 | Object.keys(state.messageObj).forEach(uid => { 10 | obj[uid] = state.messageObj[uid].filter( 11 | message => 12 | !message.hasRead && message.from !== localStorage.getItem('uid') 13 | ).length; 14 | }); 15 | 16 | return obj; 17 | } 18 | }; 19 | 20 | const mutations = { 21 | RESET_STATE(state) { 22 | state.messageObj = {}; 23 | }, 24 | 25 | LOAD_HISTORY_MESSAGE(state, data) { 26 | state.messageObj = data; 27 | }, 28 | 29 | // 新消息,包括发送出去的和接收到的,还有首次登录拉取的离线消息 30 | NEW_MESSAGE(state, { message, isReceiveMessage }) { 31 | const uid = isReceiveMessage ? message.from : message.to; 32 | 33 | if (state.messageObj[uid]) { 34 | if (!state.messageObj[uid].find(el => el.uuid === message.uuid)) { 35 | state.messageObj[uid].push(message); 36 | } 37 | } else { 38 | state.messageObj = { 39 | ...state.messageObj, 40 | [uid]: [message] 41 | }; 42 | } 43 | }, 44 | 45 | // 用来更新消息时间(消息发送成功后,服务器返回消息成功时间) 46 | // 更新文件上传消息的进度信息等 47 | UPDATE_MESSAGE(state, message) { 48 | const uid = message.to; 49 | const uuid = message.uuid; 50 | const arr = state.messageObj[uid]; 51 | 52 | if (Array.isArray(arr)) { 53 | arr.find((el, index) => { 54 | if (el.uuid === uuid) { 55 | arr.splice(index, 1, message); 56 | 57 | if (el.time !== message.time) { 58 | // sort by time 59 | arr.sort((a, b) => a.time - b.time); 60 | } 61 | 62 | return true; 63 | } 64 | 65 | return false; 66 | }); 67 | } 68 | }, 69 | 70 | // 更新消息的已读未读状态 71 | UPDATE_MESSAGE_STATUS(state, { data, isReceiveMessage }) { 72 | const uid = isReceiveMessage ? data.from : data.to; 73 | const uuid = data.uuid; 74 | const arr = state.messageObj[uid]; 75 | 76 | if (Array.isArray(arr)) { 77 | arr.find((el, index) => { 78 | if (el.uuid === uuid) { 79 | arr.splice(index, 1, { 80 | ...el, 81 | hasRead: 1 82 | }); 83 | 84 | return true; 85 | } 86 | 87 | return false; 88 | }); 89 | } 90 | } 91 | }; 92 | 93 | export default { 94 | namespaced: true, 95 | state, 96 | getters, 97 | mutations 98 | }; 99 | -------------------------------------------------------------------------------- /client/src/renderer/components/sidebar/contact/parts/group-item.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 66 | 67 | 106 | -------------------------------------------------------------------------------- /client/src/renderer/components/sidebar/contact/parts/user-item.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 63 | 64 | 120 | -------------------------------------------------------------------------------- /client/src/renderer/vue-extend/modules/utils.js: -------------------------------------------------------------------------------- 1 | function formatTimestamp(timestamp, format = 'YYYY/MM/DD hh:mm:ss') { 2 | let time = Number.parseInt(timestamp, 10); 3 | let date = new Date(time); 4 | 5 | let year = date.getFullYear(); 6 | let month = date.getMonth() + 1; 7 | let day = date.getDate(); 8 | let hour = date.getHours(); 9 | let minute = date.getMinutes(); 10 | let second = date.getSeconds(); 11 | 12 | month = month > 9 ? month : `0${month}`; 13 | day = day > 9 ? day : `0${day}`; 14 | hour = hour > 9 ? hour : `0${hour}`; 15 | minute = minute > 9 ? minute : `0${minute}`; 16 | second = second > 9 ? second : `0${second}`; 17 | 18 | return format 19 | .replace('YYYY', year) 20 | .replace('MM', month) 21 | .replace('DD', day) 22 | .replace('hh', hour) 23 | .replace('mm', minute) 24 | .replace('ss', second); 25 | } 26 | 27 | function generateUUID() { 28 | let s4 = function() { 29 | return Math.floor((1 + Math.random()) * 0x10000) 30 | .toString(16) 31 | .substring(1); 32 | }; 33 | 34 | return `${s4() + s4()}-${new Date().getTime()}`; 35 | } 36 | 37 | function capitalizeFirstLetter(str) { 38 | return str.charAt(0).toUpperCase() + str.slice(1); 39 | } 40 | 41 | function mergeObjects(targetObj, ...objArr) { 42 | if (targetObj.toString() !== '[object Object]') { 43 | return targetObj; 44 | } 45 | 46 | if (objArr.length) { 47 | objArr.forEach((obj) => { 48 | if (obj.toString() !== '[object Object]') { 49 | return; 50 | } 51 | 52 | Object.keys(obj).forEach((key) => { 53 | if (targetObj.hasOwnProperty(key)) { 54 | targetObj[key] = obj[key]; 55 | } 56 | }); 57 | }); 58 | } 59 | 60 | return targetObj; 61 | } 62 | 63 | function removeDuplicateForArr(objArr, property) { 64 | let len = objArr.length; 65 | let hash = {}; 66 | let arr = []; 67 | 68 | for (let i = 0; i < len; i++) { 69 | let el = objArr[i]; 70 | let key = el[property]; 71 | 72 | if (!hash[key]) { 73 | hash[key] = true; 74 | arr.push(el); 75 | } 76 | } 77 | 78 | return arr; 79 | } 80 | 81 | function isElementInViewport(el) { 82 | const rect = el.getBoundingClientRect(); 83 | 84 | return ( 85 | rect.top >= 0 && 86 | rect.left >= 0 && 87 | rect.bottom <= 88 | (window.innerHeight || document.documentElement.clientHeight) && 89 | rect.right <= (window.innerWidth || document.documentElement.clientWidth) 90 | ); 91 | } 92 | 93 | export default { 94 | formatTimestamp, 95 | generateUUID, 96 | capitalizeFirstLetter, 97 | mergeObjects, 98 | removeDuplicateForArr, 99 | isElementInViewport 100 | }; 101 | -------------------------------------------------------------------------------- /server/controllers/modules/webrtc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'request-video-chat': requestVideoChat, 3 | 'refuse-video-chat': refuseVideoChat, 4 | 'video-chat-ready': videoChatReady, 5 | 'rtc-candidate': handleCandidate, 6 | 'rtc-offer': handleOffer, 7 | 'rtc-answer': handleAnswer 8 | }; 9 | 10 | const { generateUUID } = require('../../utils/utils'); 11 | 12 | async function requestVideoChat(data) { 13 | const { from, to } = data; 14 | const userHash = global.$userHash; 15 | const fromSocketId = userHash[from]; 16 | const toSocketId = userHash[to]; 17 | const socket = global.$sockets[fromSocketId]; 18 | 19 | if (toSocketId) { 20 | socket.to(toSocketId).emit('request-video-chat', { 21 | from 22 | }); 23 | } else { 24 | socket.to(fromSocketId).emit('close-video-chat', { 25 | uuid: generateUUID(), 26 | from: to, 27 | to: from, 28 | type: 'video', 29 | content: { 30 | text: '对方不在线' 31 | } 32 | }); 33 | } 34 | } 35 | 36 | async function refuseVideoChat(data) { 37 | const { from, to } = data; 38 | const userHash = global.$userHash; 39 | const fromSocketId = userHash[from]; 40 | const toSocketId = userHash[to]; 41 | const socket = global.$sockets[fromSocketId]; 42 | 43 | socket.to(toSocketId).emit('refuse-video-chat', { 44 | from 45 | }); 46 | } 47 | 48 | async function videoChatReady({ from, to }) { 49 | const userHash = global.$userHash; 50 | const fromSocketId = userHash[from]; 51 | const toSocketId = userHash[to]; 52 | const socket = global.$sockets[fromSocketId]; 53 | 54 | socket.to(toSocketId).emit('video-chat-ready', { 55 | from 56 | }); 57 | } 58 | 59 | async function handleCandidate({ from, to, candidateSdp }) { 60 | const userHash = global.$userHash; 61 | const fromSocketId = userHash[from]; 62 | const toSocketId = userHash[to]; 63 | const socket = global.$sockets[fromSocketId]; 64 | 65 | console.log(`candidateSdp: ${candidateSdp}`); 66 | 67 | socket.to(toSocketId).emit('rtc-candidate', { 68 | from, 69 | candidateSdp 70 | }); 71 | } 72 | 73 | async function handleOffer({ from, to, offerSdp }) { 74 | const userHash = global.$userHash; 75 | const fromSocketId = userHash[from]; 76 | const toSocketId = userHash[to]; 77 | const socket = global.$sockets[fromSocketId]; 78 | 79 | console.log(`offerSdp: ${offerSdp}`); 80 | 81 | socket.to(toSocketId).emit('rtc-offer', { 82 | from, 83 | offerSdp 84 | }); 85 | } 86 | 87 | async function handleAnswer({ from, to, answerSdp }) { 88 | const userHash = global.$userHash; 89 | const fromSocketId = userHash[from]; 90 | const toSocketId = userHash[to]; 91 | const socket = global.$sockets[fromSocketId]; 92 | 93 | console.log(`answerSdp: ${answerSdp}`); 94 | 95 | socket.to(toSocketId).emit('rtc-answer', { 96 | from, 97 | answerSdp 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/chat-box/parts/message-boxes/left-message-box.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 45 | 46 | 124 | 125 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/chat-box/parts/message-boxes/right-message-box.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 46 | 47 | 125 | 126 | -------------------------------------------------------------------------------- /client/src/renderer/styles/fonts/style.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'icomoon'; 3 | src: url('fonts/icomoon.eot?rykwf1'); 4 | src: url('fonts/icomoon.eot?rykwf1#iefix') format('embedded-opentype'), 5 | url('fonts/icomoon.ttf?rykwf1') format('truetype'), 6 | url('fonts/icomoon.woff?rykwf1') format('woff'), 7 | url('fonts/icomoon.svg?rykwf1#icomoon') format('svg'); 8 | font-weight: normal; 9 | font-style: normal; 10 | } 11 | 12 | [class^="icon-"], 13 | [class*=" icon-"] { 14 | /* use !important to prevent issues with browser extensions that change fonts */ 15 | font-family: 'icomoon' !important; 16 | speak: none; 17 | font-style: normal; 18 | font-weight: normal; 19 | font-variant: normal; 20 | text-transform: none; 21 | line-height: 1; 22 | 23 | /* Better Font Rendering =========== */ 24 | -webkit-font-smoothing: antialiased; 25 | -moz-osx-font-smoothing: grayscale; 26 | } 27 | 28 | .icon-check:before { 29 | content: "\e5ca"; 30 | } 31 | 32 | .icon-photo_size_select_actual:before { 33 | content: "\e432"; 34 | } 35 | 36 | .icon-sentiment_satisfied:before { 37 | content: "\e813"; 38 | } 39 | 40 | .icon-add:before { 41 | content: "\e900"; 42 | } 43 | 44 | .icon-delete:before { 45 | content: "\e901"; 46 | } 47 | 48 | .icon-keyboard_arrow_down:before { 49 | content: "\e902"; 50 | } 51 | 52 | .icon-keyboard_arrow_right:before { 53 | content: "\e903"; 54 | } 55 | 56 | .icon-close:before { 57 | content: "\e904"; 58 | } 59 | 60 | .icon-plus:before { 61 | content: "\f067"; 62 | } 63 | 64 | .icon-search:before { 65 | content: "\f002"; 66 | } 67 | 68 | .icon-user:before { 69 | content: "\f007"; 70 | } 71 | 72 | .icon-th-large2:before { 73 | content: "\f009"; 74 | } 75 | 76 | .icon-cog:before { 77 | content: "\f013"; 78 | } 79 | 80 | .icon-gear:before { 81 | content: "\f013"; 82 | } 83 | 84 | .icon-video-camera:before { 85 | content: "\f03d"; 86 | } 87 | 88 | .icon-edit:before { 89 | content: "\f044"; 90 | } 91 | 92 | .icon-pencil-square-o:before { 93 | content: "\f044"; 94 | } 95 | 96 | .icon-folder:before { 97 | content: "\f07b"; 98 | } 99 | 100 | .icon-cogs:before { 101 | content: "\f085"; 102 | } 103 | 104 | .icon-gears:before { 105 | content: "\f085"; 106 | } 107 | 108 | .icon-list-ul:before { 109 | content: "\f0ca"; 110 | } 111 | 112 | .icon-microphone:before { 113 | content: "\f130"; 114 | } 115 | 116 | .icon-paper-plane-o:before { 117 | content: "\f1d9"; 118 | } 119 | 120 | .icon-send-o:before { 121 | content: "\f1d9"; 122 | } 123 | 124 | .icon-user-plus:before { 125 | content: "\f234"; 126 | } 127 | 128 | .icon-commenting:before { 129 | content: "\f27a"; 130 | } 131 | 132 | .icon-address-book:before { 133 | content: "\f2b9"; 134 | } 135 | 136 | .icon-drivers-license-o:before { 137 | content: "\f2c3"; 138 | } 139 | 140 | .icon-id-card-o:before { 141 | content: "\f2c3"; 142 | } 143 | 144 | .icon-phone-hang-up:before { 145 | content: "\e943"; 146 | } 147 | 148 | .icon-users:before { 149 | content: "\e972"; 150 | } 151 | 152 | .icon-key:before { 153 | content: "\e98d"; 154 | } 155 | 156 | .icon-happy2:before { 157 | content: "\e9e0"; 158 | } 159 | 160 | .icon-info:before { 161 | content: "\ea0c"; 162 | } 163 | 164 | .icon-exit:before { 165 | content: "\ea14"; 166 | } 167 | 168 | .icon-file-text2:before { 169 | content: "\e926"; 170 | } 171 | 172 | .icon-minus:before { 173 | content: "\f068"; 174 | } 175 | 176 | .icon-window-maximize:before { 177 | content: "\f2d0"; 178 | } 179 | -------------------------------------------------------------------------------- /client/.electron-vue/build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | process.env.NODE_ENV = 'production'; 4 | 5 | const { 6 | say 7 | } = require('cfonts'); 8 | const chalk = require('chalk'); 9 | const del = require('del'); 10 | const { 11 | spawn 12 | } = require('child_process'); 13 | const webpack = require('webpack'); 14 | const Multispinner = require('multispinner'); 15 | 16 | const mainConfig = require('./webpack.main.config'); 17 | const rendererConfig = require('./webpack.renderer.config'); 18 | const webConfig = require('./webpack.web.config'); 19 | 20 | const doneLog = chalk.bgGreen.white(' DONE ') + ' '; 21 | const errorLog = chalk.bgRed.white(' ERROR ') + ' '; 22 | const okayLog = chalk.bgBlue.white(' OKAY ') + ' '; 23 | const isCI = process.env.CI || false; 24 | 25 | if (process.env.BUILD_TARGET === 'clean') clean(); 26 | else if (process.env.BUILD_TARGET === 'web') web(); 27 | else build(); 28 | 29 | function clean() { 30 | del.sync(['build/*', '!build/icons', '!build/icons/icon.*']); 31 | console.log(`\n${doneLog}\n`); 32 | process.exit(); 33 | } 34 | 35 | function build() { 36 | greeting(); 37 | 38 | del.sync(['dist/electron/*', '!.gitkeep']); 39 | 40 | const tasks = ['main', 'renderer']; 41 | const m = new Multispinner(tasks, { 42 | preText: 'building', 43 | postText: 'process' 44 | }); 45 | 46 | let results = ''; 47 | 48 | m.on('success', () => { 49 | process.stdout.write('\x1B[2J\x1B[0f'); 50 | console.log(`\n\n${results}`); 51 | console.log( 52 | `${okayLog}take it away ${chalk.yellow('`electron-builder`')}\n` 53 | ); 54 | process.exit(); 55 | }); 56 | 57 | pack(mainConfig) 58 | .then(result => { 59 | results += result + '\n\n'; 60 | m.success('main'); 61 | }) 62 | .catch(err => { 63 | m.error('main'); 64 | console.log(`\n ${errorLog}failed to build main process`); 65 | console.error(`\n${err}\n`); 66 | process.exit(1); 67 | }); 68 | 69 | pack(rendererConfig) 70 | .then(result => { 71 | results += result + '\n\n'; 72 | m.success('renderer'); 73 | }) 74 | .catch(err => { 75 | m.error('renderer'); 76 | console.log(`\n ${errorLog}failed to build renderer process`); 77 | console.error(`\n${err}\n`); 78 | process.exit(1); 79 | }); 80 | } 81 | 82 | function pack(config) { 83 | return new Promise((resolve, reject) => { 84 | webpack(config, (err, stats) => { 85 | if (err) reject(err.stack || err); 86 | else if (stats.hasErrors()) { 87 | let err = ''; 88 | 89 | stats 90 | .toString({ 91 | chunks: false, 92 | colors: true 93 | }) 94 | .split(/\r?\n/) 95 | .forEach(line => { 96 | err += ` ${line}\n`; 97 | }); 98 | 99 | reject(err); 100 | } else { 101 | resolve( 102 | stats.toString({ 103 | chunks: false, 104 | colors: true 105 | }) 106 | ); 107 | } 108 | }); 109 | }); 110 | } 111 | 112 | function web() { 113 | del.sync(['dist/web/*', '!.gitkeep']); 114 | webpack(webConfig, (err, stats) => { 115 | if (err || stats.hasErrors()) console.log(err); 116 | 117 | console.log( 118 | stats.toString({ 119 | chunks: false, 120 | colors: true 121 | }) 122 | ); 123 | 124 | process.exit(); 125 | }); 126 | } 127 | 128 | function greeting() { 129 | const cols = process.stdout.columns; 130 | let text = ''; 131 | 132 | if (cols > 85) text = 'lets-build'; 133 | else if (cols > 60) text = 'lets-|build'; 134 | else text = false; 135 | 136 | if (text && !isCI) { 137 | say(text, { 138 | colors: ['yellow'], 139 | font: 'simple3d', 140 | space: false 141 | }); 142 | } else console.log(chalk.yellow.bold('\n lets-build')); 143 | console.log(); 144 | } 145 | -------------------------------------------------------------------------------- /client/.electron-vue/webpack.web.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | process.env.BABEL_ENV = 'web'; 4 | 5 | const path = require('path'); 6 | const webpack = require('webpack'); 7 | 8 | const BabiliWebpackPlugin = require('babili-webpack-plugin'); 9 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 10 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 11 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 12 | 13 | let webConfig = { 14 | devtool: '#cheap-module-eval-source-map', 15 | entry: { 16 | web: path.join(__dirname, '../src/renderer/main.js') 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.(js|vue)$/, 22 | enforce: 'pre', 23 | exclude: /node_modules/, 24 | use: { 25 | loader: 'eslint-loader', 26 | options: { 27 | formatter: require('eslint-friendly-formatter') 28 | } 29 | } 30 | }, 31 | { 32 | test: /\.css$/, 33 | use: ExtractTextPlugin.extract({ 34 | fallback: 'style-loader', 35 | use: 'css-loader' 36 | }) 37 | }, 38 | { 39 | test: /\.html$/, 40 | use: 'vue-html-loader' 41 | }, 42 | { 43 | test: /\.js$/, 44 | use: 'babel-loader', 45 | include: [path.resolve(__dirname, '../src/renderer')], 46 | exclude: /node_modules/ 47 | }, 48 | { 49 | test: /\.vue$/, 50 | use: { 51 | loader: 'vue-loader', 52 | options: { 53 | extractCSS: true, 54 | loaders: { 55 | sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax=1', 56 | scss: 'vue-style-loader!css-loader!sass-loader' 57 | } 58 | } 59 | } 60 | }, 61 | { 62 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 63 | use: { 64 | loader: 'url-loader', 65 | query: { 66 | limit: 10000, 67 | name: 'imgs/[name].[ext]' 68 | } 69 | } 70 | }, 71 | { 72 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 73 | use: { 74 | loader: 'url-loader', 75 | query: { 76 | limit: 10000, 77 | name: 'fonts/[name].[ext]' 78 | } 79 | } 80 | } 81 | ] 82 | }, 83 | plugins: [ 84 | new ExtractTextPlugin('styles.css'), 85 | new HtmlWebpackPlugin({ 86 | filename: 'index.html', 87 | template: path.resolve(__dirname, '../src/index.ejs'), 88 | minify: { 89 | collapseWhitespace: true, 90 | removeAttributeQuotes: true, 91 | removeComments: true 92 | }, 93 | nodeModules: false 94 | }), 95 | new webpack.DefinePlugin({ 96 | 'process.env.IS_WEB': 'true' 97 | }), 98 | new webpack.HotModuleReplacementPlugin(), 99 | new webpack.NoEmitOnErrorsPlugin() 100 | ], 101 | output: { 102 | filename: '[name].js', 103 | path: path.join(__dirname, '../dist/web') 104 | }, 105 | resolve: { 106 | alias: { 107 | '@': path.join(__dirname, '../src/renderer'), 108 | vue$: 'vue/dist/vue.esm.js' 109 | }, 110 | extensions: ['.js', '.vue', '.json', '.css'] 111 | }, 112 | target: 'web' 113 | }; 114 | 115 | /** 116 | * Adjust webConfig for production settings 117 | */ 118 | if (process.env.NODE_ENV === 'production') { 119 | webConfig.devtool = ''; 120 | 121 | webConfig.plugins.push( 122 | new BabiliWebpackPlugin(), 123 | new CopyWebpackPlugin([ 124 | { 125 | from: path.join(__dirname, '../static'), 126 | to: path.join(__dirname, '../dist/web/static'), 127 | ignore: ['.*'] 128 | } 129 | ]), 130 | new webpack.DefinePlugin({ 131 | 'process.env.NODE_ENV': '"production"' 132 | }), 133 | new webpack.LoaderOptionsPlugin({ 134 | minimize: true 135 | }) 136 | ); 137 | } 138 | 139 | module.exports = webConfig; 140 | -------------------------------------------------------------------------------- /client/src/renderer/components/sidebar/chat/parts/chat-item.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 70 | 71 | 177 | -------------------------------------------------------------------------------- /client/src/renderer/components/sidebar/contact/index.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 84 | 85 | 160 | -------------------------------------------------------------------------------- /client/src/renderer/app.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 152 | 153 | -------------------------------------------------------------------------------- /client/src/main/index.js: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, nativeImage, Tray, Menu, ipcMain } from 'electron'; 2 | import { 3 | trayIconBase64Code, 4 | transparentTrayIconBase64Code 5 | } from './config/tray'; 6 | import './service'; 7 | 8 | // hold window instance 9 | let win = null; 10 | 11 | // hold tray instance 12 | let appTray = null; 13 | 14 | const createTray = () => { 15 | const trayMenuTemplate = [ 16 | { 17 | label: '退出软件', 18 | click: function() { 19 | if (win) { 20 | win.close(); 21 | } 22 | } 23 | } 24 | ]; 25 | const trayIcon = nativeImage.createFromDataURL(trayIconBase64Code); 26 | const transparentTrayIcon = nativeImage.createFromDataURL( 27 | transparentTrayIconBase64Code 28 | ); 29 | const trayMenu = Menu.buildFromTemplate(trayMenuTemplate); 30 | 31 | let count = 0; 32 | let timer = null; 33 | 34 | appTray = new Tray(trayIcon); 35 | 36 | ipcMain.on('new-message', function() { 37 | //系统托盘图标闪烁 38 | if (!timer) { 39 | timer = setInterval(function() { 40 | count++; 41 | if (count % 2 === 0) { 42 | appTray.setImage(trayIcon); 43 | } else { 44 | appTray.setImage(transparentTrayIcon); 45 | } 46 | }, 600); 47 | } 48 | }); 49 | 50 | ipcMain.on('has-read-new-message', function() { 51 | if (timer) { 52 | clearInterval(timer); 53 | appTray.setImage(trayIcon); 54 | timer = null; 55 | } 56 | }); 57 | 58 | appTray.setToolTip('Hola'); 59 | 60 | if (process.platform === 'win32') { 61 | appTray.setContextMenu(trayMenu); 62 | } 63 | 64 | appTray.on('click', () => { 65 | if (timer) { 66 | clearInterval(timer); 67 | appTray.setImage(trayIcon); 68 | } 69 | 70 | if (win.isMinimized()) { 71 | win.restore(); 72 | } else { 73 | win.setSkipTaskbar(false); 74 | win.show(); 75 | } 76 | }); 77 | }; 78 | 79 | const createWindow = () => { 80 | const devServer = 'http://localhost:9080'; 81 | const winURL = 82 | process.env.NODE_ENV === 'development' 83 | ? devServer 84 | : `file://${__dirname}/index.html`; 85 | 86 | const config = { 87 | width: 280, 88 | height: 400, 89 | show: false, 90 | frame: process.platform === 'darwin' ? true : false, 91 | useContentSize: true, 92 | resizable: false, 93 | maximizable: false, 94 | fullscreen: false, 95 | titleBarStyle: 'hiddenInset', 96 | webPreferences: { 97 | devTools: true 98 | } 99 | }; 100 | 101 | win = new BrowserWindow(config); 102 | 103 | win.once('ready-to-show', () => { 104 | // win.setMenu(null); 105 | win.show(); 106 | }); 107 | 108 | win.on('closed', () => { 109 | win = null; 110 | appTray = null; 111 | }); 112 | 113 | win.on('focus', () => { 114 | win.flashFrame(false); 115 | }); 116 | 117 | // disable page zoom 118 | win.webContents.on('did-finish-load', function() { 119 | this.setZoomFactor(1); 120 | this.setVisualZoomLevelLimits(1, 1); 121 | this.setLayoutZoomLevelLimits(0, 0); 122 | }); 123 | 124 | win.loadURL(winURL); 125 | 126 | // https://www.electron.build/configuration/configuration#configuration-asarUnpack 127 | // asar 排除掉node_modules目录,否则Windows系统会报错 128 | // 引入未打包到app.asar里的node_modules路径 129 | win.webContents.executeJavaScript(` 130 | var path = require('path'); 131 | module.paths.push(path.resolve(__dirname, '..', '..', '..', 'app.asar.unpacked', 'node_modules')); 132 | path = undefined; 133 | `); 134 | }; 135 | 136 | ipcMain.on('relaunch-app', () => { 137 | app.relaunch(); 138 | app.quit(); 139 | }); 140 | 141 | const shouldQuit = app.makeSingleInstance(() => { 142 | if (win) { 143 | if (win.isMinimized()) { 144 | win.restore(); 145 | } else { 146 | win.setSkipTaskbar(false); 147 | win.show(); 148 | } 149 | 150 | win.focus(); 151 | } 152 | }); 153 | 154 | if (shouldQuit) { 155 | app.quit(); 156 | } 157 | 158 | app.on('ready', () => { 159 | createWindow(); 160 | createTray(); 161 | }); 162 | 163 | app.on('open-file', e => { 164 | e.preventDefault(); 165 | }); 166 | 167 | app.on('open-url', e => { 168 | e.preventDefault(); 169 | }); 170 | 171 | app.on('activate', () => { 172 | if (win === null) { 173 | createWindow(); 174 | createTray(); 175 | } 176 | }); 177 | 178 | app.on('window-all-closed', () => { 179 | if (appTray) { 180 | appTray.destroy(); 181 | } 182 | 183 | if (process.platform !== 'darwin') { 184 | app.quit(); 185 | } 186 | }); 187 | -------------------------------------------------------------------------------- /client/.electron-vue/dev-runner.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const chalk = require('chalk') 4 | const electron = require('electron') 5 | const path = require('path') 6 | const { say } = require('cfonts') 7 | const { spawn } = require('child_process') 8 | const webpack = require('webpack') 9 | const WebpackDevServer = require('webpack-dev-server') 10 | const webpackHotMiddleware = require('webpack-hot-middleware') 11 | 12 | const mainConfig = require('./webpack.main.config') 13 | const rendererConfig = require('./webpack.renderer.config') 14 | 15 | let electronProcess = null 16 | let manualRestart = false 17 | let hotMiddleware 18 | 19 | function logStats (proc, data) { 20 | let log = '' 21 | 22 | log += chalk.yellow.bold(`┏ ${proc} Process ${new Array((19 - proc.length) + 1).join('-')}`) 23 | log += '\n\n' 24 | 25 | if (typeof data === 'object') { 26 | data.toString({ 27 | colors: true, 28 | chunks: false 29 | }).split(/\r?\n/).forEach(line => { 30 | log += ' ' + line + '\n' 31 | }) 32 | } else { 33 | log += ` ${data}\n` 34 | } 35 | 36 | log += '\n' + chalk.yellow.bold(`┗ ${new Array(28 + 1).join('-')}`) + '\n' 37 | 38 | console.log(log) 39 | } 40 | 41 | function startRenderer () { 42 | return new Promise((resolve, reject) => { 43 | rendererConfig.entry.renderer = [path.join(__dirname, 'dev-client')].concat(rendererConfig.entry.renderer) 44 | 45 | const compiler = webpack(rendererConfig) 46 | hotMiddleware = webpackHotMiddleware(compiler, { 47 | log: false, 48 | heartbeat: 2500 49 | }) 50 | 51 | compiler.plugin('compilation', compilation => { 52 | compilation.plugin('html-webpack-plugin-after-emit', (data, cb) => { 53 | hotMiddleware.publish({ action: 'reload' }) 54 | cb() 55 | }) 56 | }) 57 | 58 | compiler.plugin('done', stats => { 59 | logStats('Renderer', stats) 60 | }) 61 | 62 | const server = new WebpackDevServer( 63 | compiler, 64 | { 65 | contentBase: path.join(__dirname, '../'), 66 | quiet: true, 67 | before (app, ctx) { 68 | app.use(hotMiddleware) 69 | ctx.middleware.waitUntilValid(() => { 70 | resolve() 71 | }) 72 | } 73 | } 74 | ) 75 | 76 | server.listen(9080) 77 | }) 78 | } 79 | 80 | function startMain () { 81 | return new Promise((resolve, reject) => { 82 | mainConfig.entry.main = [path.join(__dirname, '../src/main/index.dev.js')].concat(mainConfig.entry.main) 83 | 84 | const compiler = webpack(mainConfig) 85 | 86 | compiler.plugin('watch-run', (compilation, done) => { 87 | logStats('Main', chalk.white.bold('compiling...')) 88 | hotMiddleware.publish({ action: 'compiling' }) 89 | done() 90 | }) 91 | 92 | compiler.watch({}, (err, stats) => { 93 | if (err) { 94 | console.log(err) 95 | return 96 | } 97 | 98 | logStats('Main', stats) 99 | 100 | if (electronProcess && electronProcess.kill) { 101 | manualRestart = true 102 | process.kill(electronProcess.pid) 103 | electronProcess = null 104 | startElectron() 105 | 106 | setTimeout(() => { 107 | manualRestart = false 108 | }, 5000) 109 | } 110 | 111 | resolve() 112 | }) 113 | }) 114 | } 115 | 116 | function startElectron () { 117 | electronProcess = spawn(electron, ['--inspect=5858', path.join(__dirname, '../dist/electron/main.js')]) 118 | 119 | electronProcess.stdout.on('data', data => { 120 | electronLog(data, 'blue') 121 | }) 122 | electronProcess.stderr.on('data', data => { 123 | electronLog(data, 'red') 124 | }) 125 | 126 | electronProcess.on('close', () => { 127 | if (!manualRestart) process.exit() 128 | }) 129 | } 130 | 131 | function electronLog (data, color) { 132 | let log = '' 133 | data = data.toString().split(/\r?\n/) 134 | data.forEach(line => { 135 | log += ` ${line}\n` 136 | }) 137 | if (/[0-9A-z]+/.test(log)) { 138 | console.log( 139 | chalk[color].bold('┏ Electron -------------------') + 140 | '\n\n' + 141 | log + 142 | chalk[color].bold('┗ ----------------------------') + 143 | '\n' 144 | ) 145 | } 146 | } 147 | 148 | function greeting () { 149 | const cols = process.stdout.columns 150 | let text = '' 151 | 152 | if (cols > 104) text = 'electron-vue' 153 | else if (cols > 76) text = 'electron-|vue' 154 | else text = false 155 | 156 | if (text) { 157 | say(text, { 158 | colors: ['yellow'], 159 | font: 'simple3d', 160 | space: false 161 | }) 162 | } else console.log(chalk.yellow.bold('\n electron-vue')) 163 | console.log(chalk.blue(' getting ready...') + '\n') 164 | } 165 | 166 | function init () { 167 | greeting() 168 | 169 | Promise.all([startRenderer(), startMain()]) 170 | .then(() => { 171 | startElectron() 172 | }) 173 | .catch(err => { 174 | console.error(err) 175 | }) 176 | } 177 | 178 | init() 179 | -------------------------------------------------------------------------------- /client/src/renderer/components/sidebar/chat/index.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 135 | 136 | 206 | -------------------------------------------------------------------------------- /client/src/renderer/components/login.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 105 | 106 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/chat-box/parts/message-types/file-message.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 143 | 144 | 197 | 198 | -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa'); 2 | const koaMount = require('koa-mount'); 3 | const koaStatic = require('koa-static'); 4 | const cors = require('@koa/cors'); 5 | const Http = require('http'); 6 | const Socket = require('socket.io'); 7 | const ss = require('socket.io-stream'); 8 | const fs = require('fs'); 9 | const path = require('path'); 10 | const zlib = require('zlib'); 11 | const crypto = require('crypto'); 12 | const axios = require('axios'); 13 | const bytes = require('bytes'); 14 | const config = require('./config'); 15 | const controllers = require('./controllers'); 16 | const log = require('./utils/log'); 17 | const encrypt = require('./utils/encrypt'); 18 | 19 | const app = new Koa(); 20 | const server = Http.createServer(app.callback()); 21 | 22 | const io = Socket(server, { 23 | origins: '*:*', 24 | path: '/hola', 25 | pingTimeout: 15000, 26 | pingInterval: 5000 27 | }); 28 | 29 | // 注册服务 30 | require('./services/generate_folder'); 31 | require('./services/file_delete'); 32 | require('./services/mongoose'); 33 | 34 | // 初始化数据 35 | require('./initData'); 36 | 37 | // 维护一个对象,用来记录连接的用户状态 38 | global.$userHash = {}; 39 | global.$sockets = io.sockets.sockets; 40 | global.axios = axios; 41 | 42 | let userHash = global.$userHash; 43 | 44 | app.keys = ['secret key']; 45 | 46 | app.use( 47 | cors({ 48 | origin: '*' 49 | }) 50 | ); 51 | 52 | app.use(koaMount('/upload', koaStatic('./upload'))); 53 | app.use(koaMount('/release', koaStatic('./release'))); 54 | 55 | io.on('connection', function(socket) { 56 | // bind events and handlers 57 | Object.keys(controllers).forEach(module => { 58 | Object.keys(controllers[module]).forEach(key => { 59 | socket.on(key, controllers[module][key]); 60 | }); 61 | }); 62 | 63 | ss(socket).on('download-file', function({ stream, fileName }, callback) { 64 | const filePath = path.resolve(__dirname, './upload', fileName); 65 | const zip = zlib.createGzip(); 66 | const encryptData = crypto.createCipheriv( 67 | config.COMMUNICATION_ENCRYPT_METHOD, 68 | config.COMMUNICATION_ENCRYPT_KEY, 69 | '' 70 | ); 71 | 72 | if (!fs.existsSync(filePath)) { 73 | callback({ 74 | code: 1, 75 | data: null, 76 | message: '下载失败,文件不存在' 77 | }); 78 | } else { 79 | try { 80 | stream.on('end', () => { 81 | const encryptUrl = encrypt.str(`${config.SERVER}/upload/${fileName}`); 82 | 83 | callback({ 84 | code: 0, 85 | data: { 86 | url: encryptUrl 87 | }, 88 | message: '' 89 | }); 90 | }); 91 | 92 | fs.createReadStream(filePath) 93 | .pipe(zip) 94 | .pipe(encryptData) 95 | .pipe(stream); 96 | } catch (err) { 97 | callback({ 98 | code: 1, 99 | data: null, 100 | message: err 101 | }); 102 | } 103 | } 104 | }); 105 | 106 | ss(socket).on('upload-file', function({ stream, fileName }, callback) { 107 | try { 108 | const filePath = path.resolve(__dirname, './upload', fileName); 109 | const unzip = zlib.createGunzip(); 110 | const decryptData = crypto.createDecipheriv( 111 | config.COMMUNICATION_ENCRYPT_METHOD, 112 | config.COMMUNICATION_ENCRYPT_KEY, 113 | '' 114 | ); 115 | 116 | stream.on('end', () => { 117 | const encryptUrl = encrypt.str(`${config.SERVER}/upload/${fileName}`); 118 | 119 | callback({ 120 | code: 0, 121 | data: { url: encryptUrl }, 122 | message: '' 123 | }); 124 | }); 125 | 126 | stream 127 | .pipe(decryptData) 128 | .pipe(unzip) 129 | .pipe(fs.createWriteStream(filePath)); 130 | } catch (err) { 131 | callback({ 132 | code: 1, 133 | data: null, 134 | message: err 135 | }); 136 | } 137 | }); 138 | 139 | // handle user connect,trigger at login success 140 | socket.on('user-connect', function(uid) { 141 | if (userHash[uid]) { 142 | const toSocketId = userHash[uid]; 143 | 144 | socket.to(toSocketId).emit('login-another-place'); 145 | } 146 | 147 | userHash[uid] = socket.id; 148 | 149 | log.success(`\nuser[${uid}] connected`); 150 | log.success(JSON.stringify(userHash, null, 2)); 151 | }); 152 | 153 | // handle user disconnect,trigger at logout success 154 | socket.on('user-disconnect', function(uid) { 155 | delete userHash[uid]; 156 | 157 | log.success(`\nuser[${uid}] disconnected`); 158 | log.success(JSON.stringify(userHash, null, 2)); 159 | }); 160 | 161 | // handle unexpected disconnect,such as app process been killed 162 | socket.on('disconnect', function() { 163 | let uid = ''; 164 | 165 | Object.keys(userHash).find(key => { 166 | if (userHash[key] === socket.id) { 167 | uid = key; 168 | return true; 169 | } 170 | }); 171 | 172 | delete userHash[uid]; 173 | 174 | log.error(`\nuser[${uid}] disconnected`); 175 | log.error(socket.id); 176 | log.success(JSON.stringify(userHash, null, 2)); 177 | }); 178 | }); 179 | 180 | // error handling 181 | app.on('error', (err, ctx) => { 182 | log.error('server error', err, ctx); 183 | }); 184 | 185 | server.listen(3010); 186 | 187 | log.success('\nServer: http://localhost:3010'); 188 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hola", 3 | "version": "1.0.0", 4 | "author": "percy507 ", 5 | "description": "", 6 | "license": "MIT", 7 | "main": "./dist/electron/main.js", 8 | "scripts": { 9 | "build": "node .electron-vue/build.js && electron-builder", 10 | "build:mac": "node .electron-vue/build.js && electron-builder --platform=mac", 11 | "build:win32": "node .electron-vue/build.js && electron-builder --platform=win32 --arch=ia32", 12 | "build:win64": "node .electron-vue/build.js && electron-builder --platform=win32 --arch=x64", 13 | "build:dir": "node .electron-vue/build.js && electron-builder --dir", 14 | "build:clean": "cross-env BUILD_TARGET=clean node .electron-vue/build.js", 15 | "build:web": "cross-env BUILD_TARGET=web node .electron-vue/build.js", 16 | "dev": "node .electron-vue/dev-runner.js", 17 | "lint": "eslint --ext .js,.vue -f ./node_modules/eslint-friendly-formatter src", 18 | "lint:fix": "eslint --ext .js,.vue -f ./node_modules/eslint-friendly-formatter --fix src", 19 | "pack": "npm run pack:main && npm run pack:renderer", 20 | "pack:main": "cross-env NODE_ENV=production webpack --progress --colors --config .electron-vue/webpack.main.config.js", 21 | "pack:renderer": "cross-env NODE_ENV=production webpack --progress --colors --config .electron-vue/webpack.renderer.config.js", 22 | "test": "npm run unit", 23 | "unit": "karma start test/unit/karma.conf.js", 24 | "postinstall": "npm run lint:fix" 25 | }, 26 | "build": { 27 | "productName": "hola", 28 | "appId": "org.percy507.hola", 29 | "compression": "maximum", 30 | "asar": true, 31 | "asarUnpack": [ 32 | "node_modules" 33 | ], 34 | "directories": { 35 | "output": "build" 36 | }, 37 | "nsis": { 38 | "oneClick": false, 39 | "allowElevation": true, 40 | "allowToChangeInstallationDirectory": true, 41 | "createDesktopShortcut": true, 42 | "createStartMenuShortcut": true 43 | }, 44 | "files": [ 45 | "dist/electron/**/*" 46 | ], 47 | "dmg": { 48 | "contents": [ 49 | { 50 | "x": 410, 51 | "y": 150, 52 | "type": "link", 53 | "path": "/Applications" 54 | }, 55 | { 56 | "x": 130, 57 | "y": 150, 58 | "type": "file" 59 | } 60 | ] 61 | }, 62 | "mac": { 63 | "icon": "build/icons/icon.icns", 64 | "artifactName": "${productName}_setup_${version}.${ext}" 65 | }, 66 | "win": { 67 | "icon": "build/icons/icon.ico", 68 | "artifactName": "${productName}_setup_${version}.${ext}", 69 | "target": [ 70 | { 71 | "target": "nsis" 72 | } 73 | ] 74 | }, 75 | "linux": { 76 | "icon": "build/icons", 77 | "artifactName": "${productName}_setup_${version}.${ext}" 78 | } 79 | }, 80 | "dependencies": { 81 | "axios": "^0.16.1", 82 | "bytes": "^3.0.0", 83 | "electron-store": "^1.3.0", 84 | "normalize.css": "^8.0.0", 85 | "progress-stream": "^2.0.0", 86 | "socket.io-client": "^2.0.4", 87 | "socket.io-stream": "^0.9.1", 88 | "vue": "^2.3.3", 89 | "vue-electron": "^1.0.6", 90 | "vue-router": "^2.5.3", 91 | "vuex": "^2.3.1", 92 | "webrtc-adapter": "^7.3.0" 93 | }, 94 | "devDependencies": { 95 | "babel-core": "^6.25.0", 96 | "babel-eslint": "^7.2.3", 97 | "babel-loader": "^7.1.1", 98 | "babel-plugin-istanbul": "^4.1.1", 99 | "babel-plugin-transform-runtime": "^6.23.0", 100 | "babel-preset-env": "^1.6.0", 101 | "babel-preset-stage-0": "^6.24.1", 102 | "babel-register": "^6.24.1", 103 | "babili-webpack-plugin": "^0.1.2", 104 | "cfonts": "^1.1.3", 105 | "chai": "^4.0.0", 106 | "chalk": "^2.1.0", 107 | "copy-webpack-plugin": "^4.0.1", 108 | "cross-env": "^5.0.5", 109 | "css-loader": "^0.28.4", 110 | "del": "^3.0.0", 111 | "devtron": "^1.4.0", 112 | "electron": "^1.8.4", 113 | "electron-builder": "^19.19.1", 114 | "electron-debug": "^1.4.0", 115 | "electron-devtools-installer": "^2.2.0", 116 | "eslint": "^4.19.1", 117 | "eslint-config-airbnb-base": "^11.2.0", 118 | "eslint-friendly-formatter": "^3.0.0", 119 | "eslint-import-resolver-webpack": "^0.8.1", 120 | "eslint-loader": "^1.9.0", 121 | "eslint-plugin-html": "^3.1.1", 122 | "eslint-plugin-import": "^2.2.0", 123 | "extract-text-webpack-plugin": "^3.0.0", 124 | "file-loader": "^0.11.2", 125 | "html-webpack-plugin": "^2.30.1", 126 | "inject-loader": "^3.0.0", 127 | "karma": "^1.3.0", 128 | "karma-chai": "^0.1.0", 129 | "karma-coverage": "^1.1.2", 130 | "karma-electron": "^5.1.1", 131 | "karma-mocha": "^1.2.0", 132 | "karma-sourcemap-loader": "^0.3.7", 133 | "karma-spec-reporter": "^0.0.31", 134 | "karma-webpack": "^2.0.1", 135 | "mocha": "^3.0.2", 136 | "multispinner": "^0.2.1", 137 | "node-loader": "^0.6.0", 138 | "style-loader": "^0.18.2", 139 | "stylus": "^0.54.5", 140 | "stylus-loader": "^3.0.2", 141 | "url-loader": "^0.5.9", 142 | "vue-html-loader": "^1.2.4", 143 | "vue-loader": "^13.0.5", 144 | "vue-style-loader": "^3.0.1", 145 | "vue-template-compiler": "^2.4.2", 146 | "webpack": "^3.5.2", 147 | "webpack-dev-server": "^2.7.1", 148 | "webpack-hot-middleware": "^2.18.2", 149 | "webpack-merge": "^4.1.0" 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /client/src/renderer/components/sidebar/menu-bar.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 155 | 156 | 244 | -------------------------------------------------------------------------------- /client/.electron-vue/webpack.renderer.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | process.env.BABEL_ENV = 'renderer'; 4 | 5 | const path = require('path'); 6 | const { dependencies } = require('../package.json'); 7 | const webpack = require('webpack'); 8 | 9 | const BabiliWebpackPlugin = require('babili-webpack-plugin'); 10 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 11 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 12 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 13 | 14 | /** 15 | * List of node_modules to include in webpack bundle 16 | * 17 | * Required for specific packages like Vue UI libraries 18 | * that provide pure *.vue files that need compiling 19 | * https://simulatedgreg.gitbooks.io/electron-vue/content/en/webpack-configurations.html#white-listing-externals 20 | */ 21 | let whiteListedModules = ['vue']; 22 | 23 | let rendererConfig = { 24 | devtool: '#cheap-module-eval-source-map', 25 | entry: { 26 | renderer: path.join(__dirname, '../src/renderer/main.js') 27 | }, 28 | externals: [ 29 | ...Object.keys(dependencies || {}).filter( 30 | (d) => !whiteListedModules.includes(d) 31 | ) 32 | ], 33 | module: { 34 | rules: [ 35 | { 36 | test: /\.(js|vue)$/, 37 | enforce: 'pre', 38 | exclude: /node_modules/, 39 | use: { 40 | loader: 'eslint-loader', 41 | options: { 42 | formatter: require('eslint-friendly-formatter') 43 | } 44 | } 45 | }, 46 | { 47 | test: /\.css$/, 48 | use: ExtractTextPlugin.extract({ 49 | fallback: 'style-loader', 50 | use: 'css-loader' 51 | }) 52 | }, 53 | { 54 | test: /\.styl$/, 55 | use: ExtractTextPlugin.extract({ 56 | fallback: 'style-loader', 57 | use: 'css-loader!stylus-loader' 58 | }) 59 | }, 60 | { 61 | test: /\.html$/, 62 | use: 'vue-html-loader' 63 | }, 64 | { 65 | test: /\.js$/, 66 | use: 'babel-loader', 67 | exclude: /node_modules/ 68 | }, 69 | { 70 | test: /\.node$/, 71 | use: 'node-loader' 72 | }, 73 | { 74 | test: /\.vue$/, 75 | use: { 76 | loader: 'vue-loader', 77 | options: { 78 | extractCSS: process.env.NODE_ENV === 'production', 79 | loaders: { 80 | sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax=1', 81 | scss: 'vue-style-loader!css-loader!sass-loader', 82 | stylus: 'vue-style-loader!css-loader!stylus-loader' 83 | } 84 | } 85 | } 86 | }, 87 | { 88 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 89 | use: { 90 | loader: 'url-loader', 91 | query: { 92 | limit: 10000, 93 | name: 'imgs/[name]--[folder].[ext]' 94 | } 95 | } 96 | }, 97 | { 98 | test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, 99 | loader: 'url-loader', 100 | options: { 101 | limit: 10000, 102 | name: 'media/[name]--[folder].[ext]' 103 | } 104 | }, 105 | { 106 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 107 | use: { 108 | loader: 'url-loader', 109 | query: { 110 | limit: 10000, 111 | name: 'fonts/[name]--[folder].[ext]' 112 | } 113 | } 114 | } 115 | ] 116 | }, 117 | node: { 118 | __dirname: process.env.NODE_ENV !== 'production', 119 | __filename: process.env.NODE_ENV !== 'production' 120 | }, 121 | plugins: [ 122 | new ExtractTextPlugin('styles.css'), 123 | new HtmlWebpackPlugin({ 124 | filename: 'index.html', 125 | template: path.resolve(__dirname, '../src/index.ejs'), 126 | minify: { 127 | collapseWhitespace: true, 128 | removeComments: true 129 | }, 130 | nodeModules: 131 | process.env.NODE_ENV !== 'production' 132 | ? path.resolve(__dirname, '../node_modules') 133 | : false 134 | }), 135 | new webpack.HotModuleReplacementPlugin(), 136 | new webpack.NoEmitOnErrorsPlugin(), 137 | 138 | // load global stylus variables for every Vue components through webpack config 139 | new webpack.LoaderOptionsPlugin({ 140 | options: { 141 | stylus: { 142 | import: [ 143 | path.resolve(__dirname, '../src/renderer/styles/_variables.styl') 144 | ] 145 | } 146 | } 147 | }) 148 | ], 149 | output: { 150 | filename: '[name].js', 151 | libraryTarget: 'commonjs2', 152 | path: path.join(__dirname, '../dist/electron') 153 | }, 154 | resolve: { 155 | alias: { 156 | '@': path.join(__dirname, '../src/renderer'), 157 | vue$: 'vue/dist/vue.esm.js' 158 | }, 159 | extensions: ['.js', '.vue', '.json', '.css', '.node'] 160 | }, 161 | target: 'electron-renderer' 162 | }; 163 | 164 | /** 165 | * Adjust rendererConfig for development settings 166 | */ 167 | if (process.env.NODE_ENV !== 'production') { 168 | rendererConfig.plugins.push( 169 | new webpack.DefinePlugin({ 170 | __static: `"${path.join(__dirname, '../static').replace(/\\/g, '\\\\')}"` 171 | }) 172 | ); 173 | } 174 | 175 | /** 176 | * Adjust rendererConfig for production settings 177 | */ 178 | if (process.env.NODE_ENV === 'production') { 179 | rendererConfig.devtool = ''; 180 | 181 | rendererConfig.plugins.push( 182 | // new CopyWebpackPlugin([ 183 | // { 184 | // from: path.join(__dirname, '../static'), 185 | // to: path.join(__dirname, '../dist/electron/static'), 186 | // ignore: ['.*'] 187 | // }, 188 | // { 189 | // from: path.join(__dirname, '../src/renderer/lang/langs'), 190 | // to: path.join(__dirname, '../dist/electron/langs') 191 | // } 192 | // ]), 193 | new webpack.DefinePlugin({ 194 | 'process.env.NODE_ENV': '"production"' 195 | }), 196 | new BabiliWebpackPlugin(), 197 | new webpack.LoaderOptionsPlugin({ 198 | minimize: true 199 | }) 200 | ); 201 | } 202 | 203 | module.exports = rendererConfig; 204 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/contact-info/parts/user-info.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 134 | 135 | 290 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/chat-box/parts/message-types/image-message.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 175 | 176 | 296 | 297 | -------------------------------------------------------------------------------- /client/src/renderer/components/main.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 289 | 290 | 304 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/chat-box/special-pages/video-chat.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 299 | 300 | 385 | --------------------------------------------------------------------------------