├── 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 |
2 |
3 | {{ message.content.text }}
4 |
5 |
6 |
7 |
18 |
19 |
24 |
25 |
--------------------------------------------------------------------------------
/client/src/renderer/components/content/chat-box/parts/message-types/voice-message.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ message.content.text }}
4 |
5 |
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 |
2 |
3 |
{{ message.content.text }}
4 |
5 |
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 |
2 |
3 |
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 |
2 |
3 |
4 |

6 |
7 |
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 |
2 |
3 |
5 |
6 |
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 |
2 |
3 |
6 |
7 |
10 |
11 |
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 |
2 |
11 |
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 |
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 |
2 |
13 |
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 |
2 |
3 |
4 |
5 |
6 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
17 |
18 |
19 |
20 |
21 |
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 |
2 |
7 |
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 |
2 |
3 |
5 |
7 |
9 |
10 | {{ groupName }}
12 |
13 |
14 |
16 |
17 |
18 |
22 |
23 |
24 |
25 |
66 |
67 |
106 |
--------------------------------------------------------------------------------
/client/src/renderer/components/sidebar/contact/parts/user-item.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
![avatar]()
9 |
10 |
11 |
12 |
13 | {{ userObj.name }}
14 |
15 |
16 | {{ userObj.courtesyName }}
17 |
18 |
19 |
20 |
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 |
2 |
3 |
4 |
![]()
5 |
6 |
7 |
9 |
10 |
11 |
12 |
14 | {{ messageStatus }}
15 |
16 |
17 |
18 | {{ $formatTimestamp(message.time || Date.now()) }}
19 |
20 |
21 |
22 |
23 |
45 |
46 |
124 |
125 |
--------------------------------------------------------------------------------
/client/src/renderer/components/content/chat-box/parts/message-boxes/right-message-box.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
6 |
7 |
8 |
9 |
10 |
![]()
11 |
12 |
13 |
15 | {{ messageStatus }}
16 |
17 |
18 |
19 | {{ $formatTimestamp(message.time || Date.now()) }}
20 |
21 |
22 |
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 |
2 |
5 |
6 |
![avatar]()
8 |
10 |
99+
12 |
{{ contactInfo.unreadNum }}
13 |
14 |
15 |
16 |
17 |
18 |
19 | {{ contactInfo.name || '-' }}
20 |
21 |
22 | {{ contactInfo.courtesyName }}
23 |
24 |
25 |
26 |
27 |
28 |
30 |
31 |
32 |
33 |
34 |
70 |
71 |
177 |
--------------------------------------------------------------------------------
/client/src/renderer/components/sidebar/contact/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
34 |
35 |
36 |
84 |
85 |
160 |
--------------------------------------------------------------------------------
/client/src/renderer/app.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
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 |
2 |
28 |
29 |
30 |
135 |
136 |
206 |
--------------------------------------------------------------------------------
/client/src/renderer/components/login.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
Hola
7 |
8 |
9 |

11 |
12 |
13 |
40 |
41 |
42 |
43 |
105 |
106 |
--------------------------------------------------------------------------------
/client/src/renderer/components/content/chat-box/parts/message-types/file-message.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
7 |
8 | {{ message.content.err }}
9 |
10 |
11 |
12 |
13 |
{{message.content.fileName}}
14 |
15 | {{message.content.fileSize}}
16 |
17 | {{ `文件下载中: ${downloadProgress}%` }}
18 | 点击下载
21 | {{ `文件上传中: ${message.content.percentage}%` }}
22 |
23 |
24 |
25 |
32 |
33 |
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 |
2 |
34 |
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 |
2 |
3 |
5 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
![avatar]()
17 |
18 |
19 |
20 | {{ userInfo.name }}
21 |
22 |
23 |
24 | {{ userInfo.courtesyName }}
25 |
26 |
27 |
28 |

30 |
31 |
32 |
33 |
34 |
37 |
{{ item }}
38 |
39 |
40 | {{ userInfo[labelKey[item]] || '-' }}
41 |
42 |
43 |
44 |
45 |
46 |
50 |
51 |
52 |
53 |
134 |
135 |
290 |
--------------------------------------------------------------------------------
/client/src/renderer/components/content/chat-box/parts/message-types/image-message.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ message.content.err }}
6 |
7 |
8 |
9 |
11 |
12 |
17 |
18 |
20 | 图片加载中
21 |
22 |
23 |
24 |
26 | {{ `图片上传中: ${message.content.percentage}%` }}
27 |
28 |
29 |
30 |
31 |
![]()
34 |
35 |
36 |
37 |
38 |
175 |
176 |
296 |
297 |
--------------------------------------------------------------------------------
/client/src/renderer/components/main.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
289 |
290 |
304 |
--------------------------------------------------------------------------------
/client/src/renderer/components/content/chat-box/special-pages/video-chat.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
7 |
10 |
11 |
12 |
14 |
17 |
18 |
19 |
25 |
26 |
27 |
28 |
299 |
300 |
385 |
--------------------------------------------------------------------------------